diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml deleted file mode 100644 index 5fc34e5de..000000000 --- a/.github/workflows/mirror.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Snapshot to private repo (cross-org) -on: - push: - branches: [main] - -permissions: - contents: read - -jobs: - snapshot: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - submodules: false - - - name: Create GitHub App token (target org) - id: tgt - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: adamavaralabs - repositories: aave-v4 - - - name: Build snapshot repo with gitlinks - shell: bash - run: | - set -euo pipefail - SNAP=/tmp/snap - mkdir -p "$SNAP" - cd "$SNAP" - git init - - # Build exclusions for all submodule paths - RSYNC_EXCLUDES="--exclude .git --exclude .github --exclude .gitmodules" - if [[ -f "${GITHUB_WORKSPACE}/.gitmodules" ]]; then - while read -r path; do - RSYNC_EXCLUDES="$RSYNC_EXCLUDES --exclude $path" - done < <(git -C "${GITHUB_WORKSPACE}" config -f .gitmodules --get-regexp '^submodule\..*\.path' | awk '{print $2}') - fi - - # Copy top-level tracked files, but NOT submodule contents or directories - eval "rsync -a --delete $RSYNC_EXCLUDES \"${GITHUB_WORKSPACE}/\" \"$SNAP/\"" - - # If the source has a .gitmodules, copy it verbatim - if [[ -f "${GITHUB_WORKSPACE}/.gitmodules" ]]; then - cp "${GITHUB_WORKSPACE}/.gitmodules" "$SNAP/.gitmodules" - fi - - # Recreate each submodule as a real submodule gitlink pointing to the pinned commit - if [[ -f "${GITHUB_WORKSPACE}/.gitmodules" ]]; then - # Parse submodules: name, path, url - git -C "${GITHUB_WORKSPACE}" config -f .gitmodules --get-regexp '^submodule\..*\.path' | while read -r key path; do - name="${key#submodule.}"; name="${name%.path}" - url="$(git -C "${GITHUB_WORKSPACE}" config -f .gitmodules "submodule.${name}.url")" - - # Pinned commit of the submodule at HEAD (gitlink object) - sha="$(git -C "${GITHUB_WORKSPACE}" ls-tree HEAD -- "$path" | awk '{print $3}')" - - # Add submodule pointing at the same URL and then checkout the exact sha - git submodule add -f "$url" "$path" - # Fetch minimal data and checkout pinned sha (keeps target lightweight) - git -C "$path" fetch --depth=1 origin "$sha" || git -C "$path" fetch origin "$sha" - git -C "$path" checkout --detach "$sha" - - done - fi - - git add . - git -c user.name="Snapshot Bot" -c user.email="snapshot-bot@local" commit -m "Snapshot (preserve submodules) ${GITHUB_SHA}" - git branch -M main - - - name: Push to target - run: | - cd /tmp/snap - git branch -M main - git remote add origin https://x-access-token:${{ steps.tgt.outputs.token }}@github.com/adamavaralabs/aave-v4.git - git push origin main --force - - - name: Clean up - if: always() - run: | - rm -rf /tmp/snap diff --git a/.github/workflows/tests-merge.yml b/.github/workflows/tests-merge.yml index 85f8733f4..602020e6b 100644 --- a/.github/workflows/tests-merge.yml +++ b/.github/workflows/tests-merge.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - dev jobs: lint: @@ -21,7 +22,7 @@ jobs: - name: Run Foundry setup uses: bgd-labs/github-workflows/.github/actions/foundry-setup@main with: - FOUNDRY_VERSION: stable + FOUNDRY_VERSION: nightly - name: Run Forge size uses: bgd-labs/github-workflows/.github/actions/foundry-size@main diff --git a/.github/workflows/tests-pr.yml b/.github/workflows/tests-pr.yml index cb1ff90f1..95efca663 100644 --- a/.github/workflows/tests-pr.yml +++ b/.github/workflows/tests-pr.yml @@ -19,7 +19,7 @@ jobs: - name: Run Foundry setup uses: bgd-labs/github-workflows/.github/actions/foundry-setup@main with: - FOUNDRY_VERSION: stable + FOUNDRY_VERSION: nightly - name: Run Forge size uses: bgd-labs/github-workflows/.github/actions/foundry-size@main diff --git a/.gitignore b/.gitignore index 619603d4d..c92814bc4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ out/ package-lock.json node_modules +__pycache__ # ignore foundry deploy artifacts broadcast/ @@ -22,4 +23,4 @@ lcov* report/ .DS_Store -.venv/ \ No newline at end of file +.venv/ diff --git a/.gitmodules b/.gitmodules index 888d42dcd..665e0dd74 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/erc4626-tests"] + path = lib/erc4626-tests + url = https://github.com/a16z/erc4626-tests diff --git a/.prettierrc b/.prettierrc index ad975579e..a86aaa903 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { + "plugins": ["prettier-plugin-solidity"], "overrides": [ { "files": "*.sol", diff --git a/Makefile b/Makefile index 45bd7435d..9081609b9 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ git-diff : gas-report :; forge test --mp 'tests/gas/**' # Coverage -coverage-base :; forge coverage --fuzz-runs 50 --report lcov --no-match-coverage "(scripts|tests|deployments|mocks)" +coverage-base :; FOUNDRY_PROFILE=coverage forge coverage --report lcov --no-match-coverage "(scripts|tests|deployments|mocks)" coverage-clean :; lcov --rc derive_function_end_line=0 --remove ./lcov.info -o ./lcov.info.p --ignore-errors inconsistent 'src/dependencies/*' coverage-report :; genhtml ./lcov.info.p -o report --branch-coverage --rc derive_function_end_line=0 coverage-badge :; coverage=$$(awk -F '[<>]' '/headerCovTableEntryHi/{print $3}' ./report/index.html | sed 's/[^0-9.]//g' | head -n 1); \ diff --git a/docs/overview.md b/docs/overview.md index ac87ff068..847987ceb 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -157,9 +157,9 @@ This represents the principal liquidity provided by the Hub to the Spoke on the Over time, the base debt accrues interest at the Hub’s base borrow rate strategy $R_{sbase,i}$. This means that as time progresses, the accrued base interest is added to the user’s base debt, increasing the amount the user owes to the protocol’s liquidity providers for that particular asset. -$D_{u,i} = D_{u,ibase} + R_{sbase,i}D_{u,ibase}$ +$D_{u,ibase}(t) = D_{u,ibase} (t-1) + R_{sbase,i}D_{u,ibase}(t-1)$ -$R_{sbase,i}D_{u,ibase} = ΔD_{u,ibase}$ +$ΔD_{u,ibase} = R_{sbase,i}D_{u,ibase}$ ## Premium Debt @@ -169,15 +169,15 @@ $D_{u,premium}$ is a running total of the extra interest accrued on user u Unlike base debt, premium debt does not originate from an actual asset withdrawal from the Hub; instead, it is a bookkeeping entry that tracks how much extra the user owes because of the User Risk Premium. -$D_{u,premium}= D_{u,premium} + R_{sbase,i}RP_uD_{u,ibase}$ +$D_{u,premium}(t)= D_{u,premium}(t-1) + R_{sbase,i}RP_uD_{u,ibase}(t-1)$ -$R_{sbase,i}RP_uD_{u,ibase} = ΔD_{u,premium}$ +$ΔD_{u,premium} = R_{sbase,i}RP_uD_{u,ibase}$ # Dynamic Risk Configuration One of the major risk‑side limitations of V3 lies in its single, global risk configuration per asset. This design creates significant governance overhead and potential user harm through unexpected liquidations, as any parameter change, in particular lowering the liquidation threshold, immediately affects every open position. -V4 makes it possible for multiple risk configurations to exist side‑by‑side. Whenever the Governor adjusts collateralization parameters (currently the Collateral Factor (CF), Liquidation Bonus (LB) or Protocol Fee (PF)), the protocol adds a new configuration instead of replacing the old one. Earlier configurations continue to govern positions opened under them while updated parameters apply to new positions. In particular cases where there could be a negative impact to the protocol, the Governor may decide to permissioned trigger an update of existing positions to the latest parameters. +V4 makes it possible for multiple risk configurations to exist side‑by‑side. Whenever the Governor adjusts collateralization parameters (currently the Collateral Factor (CF), Liquidation Bonus (LB) or Protocol Fee (PF)), the protocol adds a new configuration instead of replacing the old one. Earlier configurations continue to govern positions opened under them while updated parameters apply to new positions. In particular cases where there could be a negative impact to the protocol, the Governor may decide to trigger an authorized update of existing positions to the latest parameters. Every time the Governor adjusts the collateralization parameters, it corresponds to a new configuration. These configurations are stored in a bounded dictionary of up to 16M entries (2^24) identified by incremental keys, with each reserve holding the key that points to the current active configuration. @@ -235,9 +235,9 @@ Aave V4 exposes several configurable parameters that influence liquidation: | **Parameter** **Description** **Constraints** | | | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `TargetHealthFactor` | A spoke‑wide value set by the Governor representing the HF to which a borrower should be restored after liquidation. Liquidators repay only enough debt to reach this HF under normal circumstances that do not result in dust collateral or debt remaining. | Must be ≥ the `HEALTH_FACTOR_LIQUIDATION_THRESHOLD` constant. | -| `DUST_LIQUIDATION_THRESHOLD` | Hard‑coded threshold used to prevent extremely small leftover debt. The maximum debt that can be liquidated is increased to ensure that debt or collateral dust less than this threshold does not remain unless the corresponding respective collateral or debt reserve is fully liquidated. | Hard‑coded constant set to 1_000 USD in base units. | +| `DUST_LIQUIDATION_THRESHOLD` | Hard‑coded threshold used to prevent extremely small leftover debt. The maximum debt that can be liquidated is increased to ensure that debt or collateral dust less than this threshold does not remain unless the respective collateral or debt reserve is fully liquidated. | Hard‑coded constant set to 1_000 USD in base units. | | `maxLiquidationBonus` | Per reserve defined maximum liquidation bonus for a collateral, expressed in basis points (BPS). A value of 105_00 means there is 5_00 extra seized collateral over the amount of debt repaid in base currency. | Must be ≥ 100_00 | -| `healthFactorForMaxBonus` | Spoke‑wide value expressed in WAD units defining the HF below which the max bonus applies. It must be less than or equal to `HEALTH_FACTOR_LIQUIDATION_THRESHOLD` to avoid division‑by‑zero. | `healthFactorForMaxBonus` < `HEALTH_FACTOR_LIQUIDATION_THRESHOLD`. | +| `healthFactorForMaxBonus` | Spoke‑wide value expressed in WAD units defining the HF below which the max bonus applies. It must be less than `HEALTH_FACTOR_LIQUIDATION_THRESHOLD` to avoid division‑by‑zero. | `healthFactorForMaxBonus` < `HEALTH_FACTOR_LIQUIDATION_THRESHOLD`. | | `liquidationBonusFactor` | Spoke‑wide percentage (expressed in BPS) specifying the fraction of the max bonus earned at the threshold `HEALTH_FACTOR_LIQUIDATION_THRESHOLD`. It defines the minimum bonus; e.g., a factor of 80_00 yields a bonus equal to 80% of the max bonus when HF equals the liquidation threshold. | liquidationBonusFactor must be ≤ 100_00 | ## Liquidation Process in V4 @@ -257,7 +257,7 @@ V4 introduces a dynamic dust prevention mechanism. If the debt remaining after a Due to rounding effects and the creation of negligible interest premiums during liquidations, the borrower’s final health factor after liquidation may not exactly match the `TargetHealthFactor`. In rare cases the final HF may be slightly above or below the target. -A deficit is only reported if, after liquidation, the borrower has no more collateral left across any of his reserves and debt still remains. +A deficit is only reported if, after liquidation, the borrower has no more collateral left across any of their reserves and debt still remains. ## Dutch‑Auction Style Liquidation Bonus diff --git a/foundry.lock b/foundry.lock index 313f592b1..a0f8c7ad4 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,4 +1,10 @@ { + "lib/erc4626-tests": { + "tag": { + "name": "v0.1.1", + "rev": "232ff9ba8194e406967f52ecc5cb52ed764209e9" + } + }, "lib/forge-std": { "rev": "60acb7aaadcce2d68e52986a0a66fe79f07d138f" } diff --git a/foundry.toml b/foundry.toml index 7ce9cc4c7..4fdd4cf5b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -7,14 +7,27 @@ fs_permissions = [{ access = "read", path = "tests/mocks/JsonBindings.sol" }] solc_version = "0.8.28" evm_version = "cancun" optimizer = true -optimizer_runs = 200 +optimizer_runs = 444444444444 bytecode_hash = "none" gas_snapshot_check = false gas_limit = 1099511627776 +dynamic_test_linking = true + +additional_compiler_profiles = [ + { name = "hub", optimizer = true, via_ir = true, optimizer_runs = 22_300 }, + { name = "spoke", optimizer = true, via_ir = true, optimizer_runs = 750 }, + { name = "tests", optimizer = true, via_ir = false, optimizer_runs = 444444444444 }, +] + +compilation_restrictions = [ + { paths = "src/hub/Hub.sol", optimizer = true, via_ir = true, optimizer_runs = 22_300 }, + { paths = "src/spoke/instances/SpokeInstance.sol", optimizer = true, via_ir = true, optimizer_runs = 750 }, + { paths = "tests/**", optimizer = true, via_ir = false, optimizer_runs = 444444444444 }, +] [bind_json] out = "tests/mocks/JsonBindings.sol" -include = ["src/libraries/types/EIP712Types.sol"] +include = ["tests/mocks/EIP712Types.sol"] [lint] ignore = ["src/dependencies/**/*", "tests/**/*"] @@ -35,6 +48,14 @@ gas_snapshot_check = true test = 'tests/gas' isolate = true +[profile.coverage] +optimizer = true +optimizer_runs = 444444444444 +via_ir = false +fuzz.runs = 50 +additional_compiler_profiles = [] +compilation_restrictions = [] + [rpc_endpoints] mainnet = "${RPC_MAINNET}" optimism = "${RPC_OPTIMISM}" diff --git a/lib/erc4626-tests b/lib/erc4626-tests new file mode 160000 index 000000000..232ff9ba8 --- /dev/null +++ b/lib/erc4626-tests @@ -0,0 +1 @@ +Subproject commit 232ff9ba8194e406967f52ecc5cb52ed764209e9 diff --git a/package.json b/package.json index 918dc5229..86806f2d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aave-v4", - "version": "0.5.6", + "version": "0.5.9", "scripts": { "lint": "prettier . --check", "lint:fix": "prettier . --write", @@ -19,8 +19,8 @@ "devDependencies": { "husky": "9.1.7", "lint-staged": "16.2.3", - "prettier": "3.6.2", - "prettier-plugin-solidity": "2.1.0" + "prettier": "3.7.4", + "prettier-plugin-solidity": "2.2.1" }, "lint-staged": { "*.{sol,md,py,ts}": "prettier . --write" diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 000000000..a74b4d07a --- /dev/null +++ b/remappings.txt @@ -0,0 +1,2 @@ +erc4626-tests/=lib/erc4626-tests/ +forge-std/=lib/forge-std/src/ diff --git a/snapshots/Hub.Operations.json b/snapshots/Hub.Operations.json index 54b1b87c4..76bcd079b 100644 --- a/snapshots/Hub.Operations.json +++ b/snapshots/Hub.Operations.json @@ -1,18 +1,18 @@ { - "add": "88006", - "add: with transfer": "109613", - "draw": "105931", - "eliminateDeficit: full": "59781", - "eliminateDeficit: partial": "69429", - "mintFeeShares": "84007", - "payFee": "72302", - "refreshPremium": "71999", - "remove: full": "76993", - "remove: partial": "81640", - "reportDeficit": "115225", - "restore: full": "80471", - "restore: full - with transfer": "173377", - "restore: partial": "89137", - "restore: partial - with transfer": "147400", - "transferShares": "71192" + "add": "86692", + "add: with transfer": "107989", + "draw": "104148", + "eliminateDeficit: full": "72567", + "eliminateDeficit: partial": "82172", + "mintFeeShares": "82741", + "payFee": "70805", + "refreshPremium": "70362", + "remove: full": "75596", + "remove: partial": "80734", + "reportDeficit": "111882", + "restore: full": "76552", + "restore: full - with transfer": "169161", + "restore: partial": "85262", + "restore: partial - with transfer": "143242", + "transferShares": "69619" } \ No newline at end of file diff --git a/snapshots/NativeTokenGateway.Operations.json b/snapshots/NativeTokenGateway.Operations.json index 45aeca137..1593c113b 100644 --- a/snapshots/NativeTokenGateway.Operations.json +++ b/snapshots/NativeTokenGateway.Operations.json @@ -1,8 +1,8 @@ { - "borrowNative": "229316", - "repayNative": "168024", - "supplyAsCollateralNative": "160373", - "supplyNative": "136476", - "withdrawNative: full": "125620", - "withdrawNative: partial": "136825" + "borrowNative": "228557", + "repayNative": "166460", + "supplyAsCollateralNative": "160122", + "supplyNative": "135753", + "withdrawNative: full": "125548", + "withdrawNative: partial": "136735" } \ No newline at end of file diff --git a/snapshots/SignatureGateway.Operations.json b/snapshots/SignatureGateway.Operations.json index 96eb0ef3e..f16cfa12f 100644 --- a/snapshots/SignatureGateway.Operations.json +++ b/snapshots/SignatureGateway.Operations.json @@ -1,10 +1,10 @@ { - "borrowWithSig": "215605", - "repayWithSig": "188872", - "setSelfAsUserPositionManagerWithSig": "75402", - "setUsingAsCollateralWithSig": "85053", - "supplyWithSig": "153205", - "updateUserDynamicConfigWithSig": "62769", - "updateUserRiskPremiumWithSig": "61579", - "withdrawWithSig": "131696" + "borrowWithSig": "213790", + "repayWithSig": "186732", + "setSelfAsUserPositionManagerWithSig": "75118", + "setUsingAsCollateralWithSig": "85387", + "supplyWithSig": "151985", + "updateUserDynamicConfigWithSig": "63120", + "updateUserRiskPremiumWithSig": "62090", + "withdrawWithSig": "130803" } \ No newline at end of file diff --git a/snapshots/Spoke.Getters.json b/snapshots/Spoke.Getters.json index 000034236..74970e9e7 100644 --- a/snapshots/Spoke.Getters.json +++ b/snapshots/Spoke.Getters.json @@ -1,7 +1,7 @@ { - "getUserAccountData: supplies: 0, borrows: 0": "11937", - "getUserAccountData: supplies: 1, borrows: 0": "48600", - "getUserAccountData: supplies: 2, borrows: 0": "80378", - "getUserAccountData: supplies: 2, borrows: 1": "100166", - "getUserAccountData: supplies: 2, borrows: 2": "118596" + "getUserAccountData: supplies: 0, borrows: 0": "13014", + "getUserAccountData: supplies: 1, borrows: 0": "49426", + "getUserAccountData: supplies: 2, borrows: 0": "81102", + "getUserAccountData: supplies: 2, borrows: 1": "101454", + "getUserAccountData: supplies: 2, borrows: 2": "120714" } \ No newline at end of file diff --git a/snapshots/Spoke.Operations.ZeroRiskPremium.json b/snapshots/Spoke.Operations.ZeroRiskPremium.json index ed2faa23d..d565f7bab 100644 --- a/snapshots/Spoke.Operations.ZeroRiskPremium.json +++ b/snapshots/Spoke.Operations.ZeroRiskPremium.json @@ -1,33 +1,34 @@ { - "borrow: first": "191325", - "borrow: second action, same reserve": "171297", - "liquidationCall (receiveShares): full": "300103", - "liquidationCall (receiveShares): partial": "299821", - "liquidationCall: full": "310468", - "liquidationCall: partial": "310186", - "permitReserve + repay (multicall)": "166029", - "permitReserve + supply (multicall)": "146862", - "permitReserve + supply + enable collateral (multicall)": "160573", - "repay: full": "126094", - "repay: partial": "130983", - "setUserPositionManagerWithSig: disable": "44846", - "setUserPositionManagerWithSig: enable": "68875", - "supply + enable collateral (multicall)": "140624", - "supply: 0 borrows, collateral disabled": "123679", - "supply: 0 borrows, collateral enabled": "106601", - "supply: second action, same reserve": "106579", - "updateUserDynamicConfig: 1 collateral": "73694", - "updateUserDynamicConfig: 2 collaterals": "88551", - "updateUserRiskPremium: 1 borrow": "94804", - "updateUserRiskPremium: 2 borrows": "104619", - "usingAsCollateral: 0 borrows, enable": "58915", - "usingAsCollateral: 1 borrow, disable": "105072", - "usingAsCollateral: 1 borrow, enable": "41803", - "usingAsCollateral: 2 borrows, disable": "126055", - "usingAsCollateral: 2 borrows, enable": "41815", - "withdraw: 0 borrows, full": "128910", - "withdraw: 0 borrows, partial": "133473", - "withdraw: 1 borrow, partial": "161036", - "withdraw: 2 borrows, partial": "174214", - "withdraw: non collateral": "106544" + "borrow: first": "190296", + "borrow: second action, same reserve": "170162", + "liquidationCall (receiveShares): full": "303081", + "liquidationCall (receiveShares): partial": "302499", + "liquidationCall (reportDeficit): full": "367565", + "liquidationCall: full": "320706", + "liquidationCall: partial": "320124", + "permitReserve + repay (multicall)": "164565", + "permitReserve + supply (multicall)": "146745", + "permitReserve + supply + enable collateral (multicall)": "161196", + "repay: full": "123903", + "repay: partial": "128861", + "setUserPositionManagersWithSig: disable": "46772", + "setUserPositionManagersWithSig: enable": "68684", + "supply + enable collateral (multicall)": "141398", + "supply: 0 borrows, collateral disabled": "122835", + "supply: 0 borrows, collateral enabled": "105806", + "supply: second action, same reserve": "105735", + "updateUserDynamicConfig: 1 collateral": "74545", + "updateUserDynamicConfig: 2 collaterals": "89413", + "updateUserRiskPremium: 1 borrow": "95657", + "updateUserRiskPremium: 2 borrows": "105337", + "usingAsCollateral: 0 borrows, enable": "59616", + "usingAsCollateral: 1 borrow, disable": "105701", + "usingAsCollateral: 1 borrow, enable": "42504", + "usingAsCollateral: 2 borrows, disable": "127250", + "usingAsCollateral: 2 borrows, enable": "42516", + "withdraw: 0 borrows, full": "127944", + "withdraw: 0 borrows, partial": "132840", + "withdraw: 1 borrow, partial": "159894", + "withdraw: 2 borrows, partial": "174452", + "withdraw: non collateral": "105891" } \ No newline at end of file diff --git a/snapshots/Spoke.Operations.json b/snapshots/Spoke.Operations.json index deed9c95d..4d15ad498 100644 --- a/snapshots/Spoke.Operations.json +++ b/snapshots/Spoke.Operations.json @@ -1,33 +1,34 @@ { - "borrow: first": "261721", - "borrow: second action, same reserve": "204693", - "liquidationCall (receiveShares): full": "333666", - "liquidationCall (receiveShares): partial": "333384", - "liquidationCall: full": "344031", - "liquidationCall: partial": "343749", - "permitReserve + repay (multicall)": "163273", - "permitReserve + supply (multicall)": "146862", - "permitReserve + supply + enable collateral (multicall)": "160573", - "repay: full": "120256", - "repay: partial": "139545", - "setUserPositionManagerWithSig: disable": "44846", - "setUserPositionManagerWithSig: enable": "68875", - "supply + enable collateral (multicall)": "140624", - "supply: 0 borrows, collateral disabled": "123679", - "supply: 0 borrows, collateral enabled": "106601", - "supply: second action, same reserve": "106579", - "updateUserDynamicConfig: 1 collateral": "73694", - "updateUserDynamicConfig: 2 collaterals": "88551", - "updateUserRiskPremium: 1 borrow": "151080", - "updateUserRiskPremium: 2 borrows": "204276", - "usingAsCollateral: 0 borrows, enable": "58915", - "usingAsCollateral: 1 borrow, disable": "161348", - "usingAsCollateral: 1 borrow, enable": "41803", - "usingAsCollateral: 2 borrows, disable": "233712", - "usingAsCollateral: 2 borrows, enable": "41815", - "withdraw: 0 borrows, full": "128910", - "withdraw: 0 borrows, partial": "133473", - "withdraw: 1 borrow, partial": "214810", - "withdraw: 2 borrows, partial": "259272", - "withdraw: non collateral": "106544" + "borrow: first": "259220", + "borrow: second action, same reserve": "202086", + "liquidationCall (receiveShares): full": "335114", + "liquidationCall (receiveShares): partial": "334532", + "liquidationCall (reportDeficit): full": "359765", + "liquidationCall: full": "352739", + "liquidationCall: partial": "352157", + "permitReserve + repay (multicall)": "162036", + "permitReserve + supply (multicall)": "146745", + "permitReserve + supply + enable collateral (multicall)": "161196", + "repay: full": "117982", + "repay: partial": "137340", + "setUserPositionManagersWithSig: disable": "46772", + "setUserPositionManagersWithSig: enable": "68684", + "supply + enable collateral (multicall)": "141398", + "supply: 0 borrows, collateral disabled": "122835", + "supply: 0 borrows, collateral enabled": "105806", + "supply: second action, same reserve": "105735", + "updateUserDynamicConfig: 1 collateral": "74545", + "updateUserDynamicConfig: 2 collaterals": "89413", + "updateUserRiskPremium: 1 borrow": "149005", + "updateUserRiskPremium: 2 borrows": "199256", + "usingAsCollateral: 0 borrows, enable": "59616", + "usingAsCollateral: 1 borrow, disable": "159046", + "usingAsCollateral: 1 borrow, enable": "42504", + "usingAsCollateral: 2 borrows, disable": "229165", + "usingAsCollateral: 2 borrows, enable": "42516", + "withdraw: 0 borrows, full": "127944", + "withdraw: 0 borrows, partial": "132840", + "withdraw: 1 borrow, partial": "210737", + "withdraw: 2 borrows, partial": "256902", + "withdraw: non collateral": "105891" } \ No newline at end of file diff --git a/snapshots/TokenizationSpoke.Operations.json b/snapshots/TokenizationSpoke.Operations.json new file mode 100644 index 000000000..8f64d3e86 --- /dev/null +++ b/snapshots/TokenizationSpoke.Operations.json @@ -0,0 +1,17 @@ +{ + "deposit": "113234", + "depositWithSig": "124138", + "mint": "112915", + "mintWithSig": "123782", + "permit": "62766", + "redeem: on behalf, full": "90886", + "redeem: on behalf, partial": "113607", + "redeem: self, full": "88874", + "redeem: self, partial": "108074", + "redeemWithSig": "123456", + "withdraw: on behalf, full": "91302", + "withdraw: on behalf, partial": "114127", + "withdraw: self, full": "89394", + "withdraw: self, partial": "108594", + "withdrawWithSig": "123987" +} \ No newline at end of file diff --git a/src/access/AccessManagerEnumerable.sol b/src/access/AccessManagerEnumerable.sol index 94d30b2d3..e1bc23c48 100644 --- a/src/access/AccessManagerEnumerable.sol +++ b/src/access/AccessManagerEnumerable.sol @@ -9,27 +9,91 @@ import {IAccessManagerEnumerable} from 'src/access/interfaces/IAccessManagerEnum /// @title AccessManagerEnumerable /// @author Aave Labs /// @notice Extension of AccessManager that tracks role members and their function selectors using EnumerableSet. +/// @dev Roles, target contracts, and function selectors assigned to `ADMIN_ROLE` are excluded from tracking. contract AccessManagerEnumerable is AccessManager, IAccessManagerEnumerable { - using EnumerableSet for EnumerableSet.AddressSet; - using EnumerableSet for EnumerableSet.Bytes32Set; + using EnumerableSet for *; + + /// @dev Set of all role identifiers. + /// @dev `PUBLIC_ROLE` and `ADMIN_ROLE` are not part of this set. + EnumerableSet.UintSet private _rolesSet; + + /// @dev Set of all admin role identifiers. + /// @dev `ADMIN_ROLE` is not part of this set. + EnumerableSet.UintSet private _adminRolesSet; /// @dev Map of role identifiers to their respective member sets. - mapping(uint64 roleId => EnumerableSet.AddressSet) private _roleMembers; + mapping(uint64 roleId => EnumerableSet.AddressSet) private _roleMemberSet; + + /// @dev Map of admin role identifiers to their respective role identifier sets. + /// @dev Roles managed by the `ADMIN_ROLE` are not included. + mapping(uint64 roleId => EnumerableSet.UintSet) private _roleAdminToRoleSet; + + /// @dev Map of role identifiers to their respective target contract addresses. + /// @dev Target contracts assigned to `ADMIN_ROLE` are not included. + /// @dev A target is included in the set only if it has at least one selector assigned. + mapping(uint64 roleId => EnumerableSet.AddressSet) private _roleToTargetSet; + + /// @dev Map of target contract addresses and function selectors to their assigned role identifier. + mapping(address target => mapping(bytes4 selector => uint64 roleId)) + private _targetToSelectorToRole; /// @dev Map of role identifiers and target contract addresses to their respective set of function selectors. + /// @dev Function selectors assigned to `ADMIN_ROLE` are not included. mapping(uint64 roleId => mapping(address target => EnumerableSet.Bytes32Set)) - private _roleTargetFunctions; + private _roleToTargetToSelectorSet; + /// @dev Constructor. + /// @param initialAdmin_ The address of the initial admin. constructor(address initialAdmin_) AccessManager(initialAdmin_) {} + /// @inheritdoc IAccessManagerEnumerable + function getRole(uint256 index) external view returns (uint64) { + return uint64(_rolesSet.at(index)); + } + + /// @inheritdoc IAccessManagerEnumerable + function getRoleCount() external view returns (uint256) { + return _rolesSet.length(); + } + + /// @inheritdoc IAccessManagerEnumerable + function getRoles(uint256 start, uint256 end) external view returns (uint64[] memory) { + uint256[] memory listedRoles = _rolesSet.values(start, end); + uint64[] memory roles; + assembly ('memory-safe') { + roles := listedRoles + } + return roles; + } + + /// @inheritdoc IAccessManagerEnumerable + function getAdminRole(uint256 index) external view returns (uint64) { + return uint64(_adminRolesSet.at(index)); + } + + /// @inheritdoc IAccessManagerEnumerable + function getAdminRoleCount() external view returns (uint256) { + return _adminRolesSet.length(); + } + + /// @inheritdoc IAccessManagerEnumerable + function getAdminRoles(uint256 start, uint256 end) external view returns (uint64[] memory) { + uint256[] memory listedAdminRoles = _adminRolesSet.values(start, end); + uint64[] memory adminRoles; + assembly ('memory-safe') { + adminRoles := listedAdminRoles + } + return adminRoles; + } + /// @inheritdoc IAccessManagerEnumerable function getRoleMember(uint64 roleId, uint256 index) external view returns (address) { - return _roleMembers[roleId].at(index); + return _roleMemberSet[roleId].at(index); } /// @inheritdoc IAccessManagerEnumerable function getRoleMemberCount(uint64 roleId) external view returns (uint256) { - return _roleMembers[roleId].length(); + return _roleMemberSet[roleId].length(); } /// @inheritdoc IAccessManagerEnumerable @@ -38,34 +102,80 @@ contract AccessManagerEnumerable is AccessManager, IAccessManagerEnumerable { uint256 start, uint256 end ) external view returns (address[] memory) { - return _roleMembers[roleId].values(start, end); + return _roleMemberSet[roleId].values(start, end); + } + + /// @inheritdoc IAccessManagerEnumerable + function getRoleOfAdminRole(uint64 adminRoleId, uint256 index) external view returns (uint64) { + return uint64(_roleAdminToRoleSet[adminRoleId].at(index)); + } + + /// @inheritdoc IAccessManagerEnumerable + function getRoleOfAdminRoleCount(uint64 adminRoleId) external view returns (uint256) { + return _roleAdminToRoleSet[adminRoleId].length(); + } + + /// @inheritdoc IAccessManagerEnumerable + function getRolesOfAdminRole( + uint64 adminRoleId, + uint256 start, + uint256 end + ) external view returns (uint64[] memory) { + uint256[] memory listedRoles = _roleAdminToRoleSet[adminRoleId].values(start, end); + uint64[] memory roles; + assembly ('memory-safe') { + roles := listedRoles + } + return roles; + } + + /// @inheritdoc IAccessManagerEnumerable + function getRoleTarget(uint64 roleId, uint256 index) external view returns (address) { + return _roleToTargetSet[roleId].at(index); } /// @inheritdoc IAccessManagerEnumerable - function getRoleTargetFunction( + function getRoleTargetCount(uint64 roleId) external view returns (uint256) { + return _roleToTargetSet[roleId].length(); + } + + /// @inheritdoc IAccessManagerEnumerable + function getRoleTargets( + uint64 roleId, + uint256 start, + uint256 end + ) external view returns (address[] memory) { + return _roleToTargetSet[roleId].values(start, end); + } + + /// @inheritdoc IAccessManagerEnumerable + function getRoleTargetSelector( uint64 roleId, address target, uint256 index ) external view returns (bytes4) { - return bytes4(_roleTargetFunctions[roleId][target].at(index)); + return bytes4(_roleToTargetToSelectorSet[roleId][target].at(index)); } /// @inheritdoc IAccessManagerEnumerable - function getRoleTargetFunctionCount( + function getRoleTargetSelectorCount( uint64 roleId, address target ) external view returns (uint256) { - return _roleTargetFunctions[roleId][target].length(); + return _roleToTargetToSelectorSet[roleId][target].length(); } /// @inheritdoc IAccessManagerEnumerable - function getRoleTargetFunctions( + function getRoleTargetSelectors( uint64 roleId, address target, uint256 start, uint256 end ) external view returns (bytes4[] memory) { - bytes32[] memory targetFunctions = _roleTargetFunctions[roleId][target].values(start, end); + bytes32[] memory targetFunctions = _roleToTargetToSelectorSet[roleId][target].values( + start, + end + ); bytes4[] memory targetFunctionSelectors; assembly ('memory-safe') { targetFunctionSelectors := targetFunctions @@ -73,7 +183,24 @@ contract AccessManagerEnumerable is AccessManager, IAccessManagerEnumerable { return targetFunctionSelectors; } - /// @dev Override AccessManager `_grantRole` function to track role members. + /// @dev Overrides AccessManager `_setRoleAdmin` function to track admin roles. + function _setRoleAdmin(uint64 roleId, uint64 admin) internal override { + uint64 oldAdmin = getRoleAdmin(roleId); + + super._setRoleAdmin(roleId, admin); + + _trackRole(roleId); + _trackAdminRole(roleId, oldAdmin, admin); + } + + /// @dev Overrides AccessManager `_setRoleGuardian` function to track created roles. + function _setRoleGuardian(uint64 roleId, uint64 guardian) internal override { + super._setRoleGuardian(roleId, guardian); + + _trackRole(roleId); + } + + /// @dev Overrides AccessManager `_grantRole` function to track role members. function _grantRole( uint64 roleId, address account, @@ -81,34 +208,89 @@ contract AccessManagerEnumerable is AccessManager, IAccessManagerEnumerable { uint32 executionDelay ) internal override returns (bool) { bool granted = super._grantRole(roleId, account, grantDelay, executionDelay); + if (granted) { - _roleMembers[roleId].add(account); + _trackRole(roleId); + _trackRoleMember(roleId, account, granted); } + return granted; } - /// @dev Override AccessManager `_revokeRole` function to remove from tracked role members. + /// @dev Overrides AccessManager `_revokeRole` function to track removed role members. function _revokeRole(uint64 roleId, address account) internal override returns (bool) { bool revoked = super._revokeRole(roleId, account); + if (revoked) { - _roleMembers[roleId].remove(account); + _trackRoleMember(roleId, account, false); } + return revoked; } - /// @dev Override AccessManager `_setTargetFunctionRole` function to track function selectors attributed to roles. + /// @dev Overrides AccessManager `_setTargetFunctionRole` function to track function selectors attributed to roles. function _setTargetFunctionRole( address target, bytes4 selector, uint64 roleId ) internal override { - uint64 oldRoleId = getTargetFunctionRole(target, selector); super._setTargetFunctionRole(target, selector, roleId); + + _trackRoleTargetSelector(roleId, target, selector); + } + + /// @dev Tracks all role identifiers when a new role is created. + function _trackRole(uint64 roleId) internal { + if (roleId == ADMIN_ROLE) { + return; + } + + _rolesSet.add(uint256(roleId)); + } + + /// @dev Tracks all admin role identifiers when a new admin role is set. + function _trackAdminRole(uint64 roleId, uint64 oldAdmin, uint64 admin) internal { + if (oldAdmin == admin) { + return; + } + + if (oldAdmin != ADMIN_ROLE) { + _roleAdminToRoleSet[oldAdmin].remove(uint256(roleId)); + } + + if (admin != ADMIN_ROLE) { + _adminRolesSet.add(uint256(admin)); + _roleAdminToRoleSet[admin].add(uint256(roleId)); + } + } + + /// @dev Tracks all members of a role when granted or revoked. + function _trackRoleMember(uint64 roleId, address account, bool granted) internal { + if (granted) { + _roleMemberSet[roleId].add(account); + } else { + _roleMemberSet[roleId].remove(account); + } + } + + /// @dev Tracks all targets where a selector was assigned to a role and selectors. + function _trackRoleTargetSelector(uint64 roleId, address target, bytes4 selector) internal { + uint64 oldRoleId = _targetToSelectorToRole[target][selector]; + if (oldRoleId == roleId) { + return; + } + if (oldRoleId != ADMIN_ROLE) { - _roleTargetFunctions[oldRoleId][target].remove(bytes32(selector)); + _roleToTargetToSelectorSet[oldRoleId][target].remove(bytes32(selector)); + if (_roleToTargetToSelectorSet[oldRoleId][target].length() == 0) { + _roleToTargetSet[oldRoleId].remove(target); + } } + if (roleId != ADMIN_ROLE) { - _roleTargetFunctions[roleId][target].add(bytes32(selector)); + _roleToTargetToSelectorSet[roleId][target].add(bytes32(selector)); + _roleToTargetSet[roleId].add(target); } + _targetToSelectorToRole[target][selector] = roleId; } } diff --git a/src/access/interfaces/IAccessManagerEnumerable.sol b/src/access/interfaces/IAccessManagerEnumerable.sol index c62833460..3a11e81a3 100644 --- a/src/access/interfaces/IAccessManagerEnumerable.sol +++ b/src/access/interfaces/IAccessManagerEnumerable.sol @@ -8,6 +8,42 @@ import {IAccessManager} from 'src/dependencies/openzeppelin/IAccessManager.sol'; /// @author Aave Labs /// @notice Interface for AccessManagerEnumerable extension. interface IAccessManagerEnumerable is IAccessManager { + /// @notice Returns the identifier of the role at a specified index. + /// @dev `PUBLIC_ROLE` and `ADMIN_ROLE` are not accessible via any index. + /// @param index The index in the role list. + /// @return The identifier of the role. + function getRole(uint256 index) external view returns (uint64); + + /// @notice Returns the total number of existing roles. + /// @dev `PUBLIC_ROLE` and `ADMIN_ROLE` are not included in the role count. + /// @return The number of roles. + function getRoleCount() external view returns (uint256); + + /// @notice Returns the list of role identifiers between the specified indexes. + /// @dev `PUBLIC_ROLE` and `ADMIN_ROLE` are not accessible via any index. + /// @param start The starting index for the role list. + /// @param end The ending index for the role list. + /// @return The list of role identifiers. + function getRoles(uint256 start, uint256 end) external view returns (uint64[] memory); + + /// @notice Returns the identifier of the admin role at a specified index. + /// @dev `ADMIN_ROLE` is not accessible via any index. + /// @param index The index in the admin role list. + /// @return The identifier of the admin role. + function getAdminRole(uint256 index) external view returns (uint64); + + /// @notice Returns the total number of existing admin roles. + /// @dev `ADMIN_ROLE` is not included in the admin role count. + /// @return The number of admin roles. + function getAdminRoleCount() external view returns (uint256); + + /// @notice Returns the list of admin role identifiers between the specified indexes. + /// @dev `ADMIN_ROLE` is not accessible via any index. + /// @param start The starting index for the admin role list. + /// @param end The ending index for the admin role list. + /// @return The list of admin role identifiers. + function getAdminRoles(uint256 start, uint256 end) external view returns (uint64[] memory); + /// @notice Returns the address of the role member at a specified index. /// @param roleId The identifier of the role. /// @param index The index in the role member list. @@ -19,7 +55,7 @@ interface IAccessManagerEnumerable is IAccessManager { /// @return The number of members for the role. function getRoleMemberCount(uint64 roleId) external view returns (uint256); - /// @notice Returns the list of members for a specified role. + /// @notice Returns the list of members for a specified role between the specified indexes. /// @param roleId The identifier of the role. /// @param start The starting index for the member list. /// @param end The ending index for the member list. @@ -30,33 +66,86 @@ interface IAccessManagerEnumerable is IAccessManager { uint256 end ) external view returns (address[] memory); + /// @notice Returns the identifier of the role managed by the given admin role at a specified index. + /// @dev Roles managed by the `ADMIN_ROLE` are not accessible. + /// @param adminRoleId The identifier of the admin role. + /// @param index The index in the list of roles managed by the admin role. + /// @return The identifier of the role. + function getRoleOfAdminRole(uint64 adminRoleId, uint256 index) external view returns (uint64); + + /// @notice Returns the number of roles managed by a specified admin role. + /// @dev Roles managed by the `ADMIN_ROLE` are not accessible. + /// @param adminRoleId The identifier of the admin role. + /// @return The number of roles managed by the admin role. + function getRoleOfAdminRoleCount(uint64 adminRoleId) external view returns (uint256); + + /// @notice Returns the list of role identifiers managed by the given admin role between the specified indexes. + /// @dev Roles managed by the `ADMIN_ROLE` are not accessible. + /// @param adminRoleId The identifier of the admin role. + /// @param start The starting index in the list of roles managed by the admin role. + /// @param end The ending index in the list of roles managed by the admin role. + /// @return The list of role identifiers managed by the given admin role. + function getRolesOfAdminRole( + uint64 adminRoleId, + uint256 start, + uint256 end + ) external view returns (uint64[] memory); + + /// @notice Returns the address of the target contract for a specified role and index. + /// @dev Target contracts assigned to `ADMIN_ROLE` are not accessible. + /// @param roleId The identifier of the role. + /// @param index The index in the role target list. + /// @return The address of the target contract. + function getRoleTarget(uint64 roleId, uint256 index) external view returns (address); + + /// @notice Returns the number of target contracts for a specified role. + /// @dev Target contracts assigned to `ADMIN_ROLE` are not accessible. + /// @param roleId The identifier of the role. + /// @return The number of target contracts for the role. + function getRoleTargetCount(uint64 roleId) external view returns (uint256); + + /// @notice Returns the list of target contracts for a specified role between the specified indexes. + /// @dev Target contracts assigned to `ADMIN_ROLE` are not accessible. + /// @param roleId The identifier of the role. + /// @param start The starting index for the role target list. + /// @param end The ending index for the role target list. + /// @return The list of target contracts for the role. + function getRoleTargets( + uint64 roleId, + uint256 start, + uint256 end + ) external view returns (address[] memory); + /// @notice Returns the function selector assigned to a given role at the specified index. + /// @dev Target selectors assigned to `ADMIN_ROLE` are not accessible. /// @param roleId The identifier of the role. /// @param target The address of the target contract. /// @param index The index in the role member list. /// @return The selector at the index. - function getRoleTargetFunction( + function getRoleTargetSelector( uint64 roleId, address target, uint256 index ) external view returns (bytes4); /// @notice Returns the number of function selectors assigned to the given role. + /// @dev Target selectors assigned to `ADMIN_ROLE` are not accessible. /// @param roleId The identifier of the role. /// @param target The address of the target contract. /// @return The number of selectors assigned to the role. - function getRoleTargetFunctionCount( + function getRoleTargetSelectorCount( uint64 roleId, address target ) external view returns (uint256); /// @notice Returns the list of function selectors assigned to the given role between the specified indexes. + /// @dev Target selectors assigned to `ADMIN_ROLE` are not accessible. /// @param roleId The identifier of the role. /// @param target The address of the target contract. /// @param start The starting index for the selector list. /// @param end The ending index for the selector list. /// @return The list of selectors assigned to the role. - function getRoleTargetFunctions( + function getRoleTargetSelectors( uint64 roleId, address target, uint256 start, diff --git a/src/dependencies/openzeppelin-upgradeable/AccessManagedUpgradeable.sol b/src/dependencies/openzeppelin-upgradeable/AccessManagedUpgradeable.sol index dcf37f9bc..ceda3ced0 100644 --- a/src/dependencies/openzeppelin-upgradeable/AccessManagedUpgradeable.sol +++ b/src/dependencies/openzeppelin-upgradeable/AccessManagedUpgradeable.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.20; -import {AuthorityUtils} from 'src/dependencies/openzeppelin/AuthorityUtils.sol'; -import {IAccessManager} from 'src/dependencies/openzeppelin/IAccessManager.sol'; -import {IAccessManaged} from 'src/dependencies/openzeppelin/IAccessManaged.sol'; +import {AuthorityUtils} from '../openzeppelin/AuthorityUtils.sol'; +import {IAccessManager} from '../openzeppelin/IAccessManager.sol'; +import {IAccessManaged} from '../openzeppelin/IAccessManaged.sol'; import {ContextUpgradeable} from './ContextUpgradeable.sol'; import {Initializable} from './Initializable.sol'; diff --git a/src/dependencies/openzeppelin-upgradeable/ERC20Upgradeable.sol b/src/dependencies/openzeppelin-upgradeable/ERC20Upgradeable.sol new file mode 100644 index 000000000..ce7648856 --- /dev/null +++ b/src/dependencies/openzeppelin-upgradeable/ERC20Upgradeable.sol @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.5.0) (token/ERC20/ERC20.sol) + +pragma solidity ^0.8.20; + +import {IERC20} from '../openzeppelin/IERC20.sol'; +import {IERC20Metadata} from '../openzeppelin/IERC20Metadata.sol'; +import {ContextUpgradeable} from './ContextUpgradeable.sol'; +import {IERC20Errors} from '../openzeppelin/draft-IERC6093.sol'; +import {Initializable} from './Initializable.sol'; + +/** + * @dev Implementation of the {IERC20} interface. + * + * This implementation is agnostic to the way tokens are created. This means + * that a supply mechanism has to be added in a derived contract using {_mint}. + * + * TIP: For a detailed writeup see our guide + * https://forum.openzeppelin.com/t/how-to-implement-erc20-supply-mechanisms/226[How + * to implement supply mechanisms]. + * + * The default value of {decimals} is 18. To change this, you should override + * this function so it returns a different value. + * + * We have followed general OpenZeppelin Contracts guidelines: functions revert + * instead returning `false` on failure. This behavior is nonetheless + * conventional and does not conflict with the expectations of ERC-20 + * applications. + */ +abstract contract ERC20Upgradeable is + Initializable, + ContextUpgradeable, + IERC20, + IERC20Metadata, + IERC20Errors +{ + /// @custom:storage-location erc7201:openzeppelin.storage.ERC20 + struct ERC20Storage { + mapping(address account => uint256) _balances; + mapping(address account => mapping(address spender => uint256)) _allowances; + uint256 _totalSupply; + string _name; + string _symbol; + } + + // keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ERC20")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ERC20StorageLocation = + 0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00; + + function _getERC20Storage() private pure returns (ERC20Storage storage $) { + assembly { + $.slot := ERC20StorageLocation + } + } + + /** + * @dev Sets the values for {name} and {symbol}. + * + * Both values are immutable: they can only be set once during construction. + */ + function __ERC20_init(string memory name_, string memory symbol_) internal onlyInitializing { + __ERC20_init_unchained(name_, symbol_); + } + + function __ERC20_init_unchained( + string memory name_, + string memory symbol_ + ) internal onlyInitializing { + ERC20Storage storage $ = _getERC20Storage(); + $._name = name_; + $._symbol = symbol_; + } + + /** + * @dev Returns the name of the token. + */ + function name() public view virtual returns (string memory) { + ERC20Storage storage $ = _getERC20Storage(); + return $._name; + } + + /** + * @dev Returns the symbol of the token, usually a shorter version of the + * name. + */ + function symbol() public view virtual returns (string memory) { + ERC20Storage storage $ = _getERC20Storage(); + return $._symbol; + } + + /** + * @dev Returns the number of decimals used to get its user representation. + * For example, if `decimals` equals `2`, a balance of `505` tokens should + * be displayed to a user as `5.05` (`505 / 10 ** 2`). + * + * Tokens usually opt for a value of 18, imitating the relationship between + * Ether and Wei. This is the default value returned by this function, unless + * it's overridden. + * + * NOTE: This information is only used for _display_ purposes: it in + * no way affects any of the arithmetic of the contract, including + * {IERC20-balanceOf} and {IERC20-transfer}. + */ + function decimals() public view virtual returns (uint8) { + return 18; + } + + /// @inheritdoc IERC20 + function totalSupply() public view virtual returns (uint256) { + ERC20Storage storage $ = _getERC20Storage(); + return $._totalSupply; + } + + /// @inheritdoc IERC20 + function balanceOf(address account) public view virtual returns (uint256) { + ERC20Storage storage $ = _getERC20Storage(); + return $._balances[account]; + } + + /** + * @dev See {IERC20-transfer}. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - the caller must have a balance of at least `value`. + */ + function transfer(address to, uint256 value) public virtual returns (bool) { + address owner = _msgSender(); + _transfer(owner, to, value); + return true; + } + + /// @inheritdoc IERC20 + function allowance(address owner, address spender) public view virtual returns (uint256) { + ERC20Storage storage $ = _getERC20Storage(); + return $._allowances[owner][spender]; + } + + /** + * @dev See {IERC20-approve}. + * + * NOTE: If `value` is the maximum `uint256`, the allowance is not updated on + * `transferFrom`. This is semantically equivalent to an infinite approval. + * + * Requirements: + * + * - `spender` cannot be the zero address. + */ + function approve(address spender, uint256 value) public virtual returns (bool) { + address owner = _msgSender(); + _approve(owner, spender, value); + return true; + } + + /** + * @dev See {IERC20-transferFrom}. + * + * Skips emitting an {Approval} event indicating an allowance update. This is not + * required by the ERC. See {xref-ERC20-_approve-address-address-uint256-bool-}[_approve]. + * + * NOTE: Does not update the allowance if the current allowance + * is the maximum `uint256`. + * + * Requirements: + * + * - `from` and `to` cannot be the zero address. + * - `from` must have a balance of at least `value`. + * - the caller must have allowance for ``from``'s tokens of at least + * `value`. + */ + function transferFrom(address from, address to, uint256 value) public virtual returns (bool) { + address spender = _msgSender(); + _spendAllowance(from, spender, value); + _transfer(from, to, value); + return true; + } + + /** + * @dev Moves a `value` amount of tokens from `from` to `to`. + * + * This internal function is equivalent to {transfer}, and can be used to + * e.g. implement automatic token fees, slashing mechanisms, etc. + * + * Emits a {Transfer} event. + * + * NOTE: This function is not virtual, {_update} should be overridden instead. + */ + function _transfer(address from, address to, uint256 value) internal { + if (from == address(0)) { + revert ERC20InvalidSender(address(0)); + } + if (to == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + _update(from, to, value); + } + + /** + * @dev Transfers a `value` amount of tokens from `from` to `to`, or alternatively mints (or burns) if `from` + * (or `to`) is the zero address. All customizations to transfers, mints, and burns should be done by overriding + * this function. + * + * Emits a {Transfer} event. + */ + function _update(address from, address to, uint256 value) internal virtual { + ERC20Storage storage $ = _getERC20Storage(); + if (from == address(0)) { + // Overflow check required: The rest of the code assumes that totalSupply never overflows + $._totalSupply += value; + } else { + uint256 fromBalance = $._balances[from]; + if (fromBalance < value) { + revert ERC20InsufficientBalance(from, fromBalance, value); + } + unchecked { + // Overflow not possible: value <= fromBalance <= totalSupply. + $._balances[from] = fromBalance - value; + } + } + + if (to == address(0)) { + unchecked { + // Overflow not possible: value <= totalSupply or value <= fromBalance <= totalSupply. + $._totalSupply -= value; + } + } else { + unchecked { + // Overflow not possible: balance + value is at most totalSupply, which we know fits into a uint256. + $._balances[to] += value; + } + } + + emit Transfer(from, to, value); + } + + /** + * @dev Creates a `value` amount of tokens and assigns them to `account`, by transferring it from address(0). + * Relies on the `_update` mechanism + * + * Emits a {Transfer} event with `from` set to the zero address. + * + * NOTE: This function is not virtual, {_update} should be overridden instead. + */ + function _mint(address account, uint256 value) internal { + if (account == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + _update(address(0), account, value); + } + + /** + * @dev Destroys a `value` amount of tokens from `account`, lowering the total supply. + * Relies on the `_update` mechanism. + * + * Emits a {Transfer} event with `to` set to the zero address. + * + * NOTE: This function is not virtual, {_update} should be overridden instead + */ + function _burn(address account, uint256 value) internal { + if (account == address(0)) { + revert ERC20InvalidSender(address(0)); + } + _update(account, address(0), value); + } + + /** + * @dev Sets `value` as the allowance of `spender` over the `owner`'s tokens. + * + * This internal function is equivalent to `approve`, and can be used to + * e.g. set automatic allowances for certain subsystems, etc. + * + * Emits an {Approval} event. + * + * Requirements: + * + * - `owner` cannot be the zero address. + * - `spender` cannot be the zero address. + * + * Overrides to this logic should be done to the variant with an additional `bool emitEvent` argument. + */ + function _approve(address owner, address spender, uint256 value) internal { + _approve(owner, spender, value, true); + } + + /** + * @dev Variant of {_approve} with an optional flag to enable or disable the {Approval} event. + * + * By default (when calling {_approve}) the flag is set to true. On the other hand, approval changes made by + * `_spendAllowance` during the `transferFrom` operation sets the flag to false. This saves gas by not emitting any + * `Approval` event during `transferFrom` operations. + * + * Anyone who wishes to continue emitting `Approval` events on the `transferFrom` operation can force the flag to + * true using the following override: + * + * ```solidity + * function _approve(address owner, address spender, uint256 value, bool) internal virtual override { + * super._approve(owner, spender, value, true); + * } + * ``` + * + * Requirements are the same as {_approve}. + */ + function _approve( + address owner, + address spender, + uint256 value, + bool emitEvent + ) internal virtual { + ERC20Storage storage $ = _getERC20Storage(); + if (owner == address(0)) { + revert ERC20InvalidApprover(address(0)); + } + if (spender == address(0)) { + revert ERC20InvalidSpender(address(0)); + } + $._allowances[owner][spender] = value; + if (emitEvent) { + emit Approval(owner, spender, value); + } + } + + /** + * @dev Updates `owner`'s allowance for `spender` based on spent `value`. + * + * Does not update the allowance value in case of infinite allowance. + * Revert if not enough allowance is available. + * + * Does not emit an {Approval} event. + */ + function _spendAllowance(address owner, address spender, uint256 value) internal virtual { + uint256 currentAllowance = allowance(owner, spender); + if (currentAllowance < type(uint256).max) { + if (currentAllowance < value) { + revert ERC20InsufficientAllowance(spender, currentAllowance, value); + } + unchecked { + _approve(owner, spender, currentAllowance - value, false); + } + } + } +} diff --git a/src/dependencies/openzeppelin/AccessManager.sol b/src/dependencies/openzeppelin/AccessManager.sol index c3b40cba5..c3b32bb6d 100644 --- a/src/dependencies/openzeppelin/AccessManager.sol +++ b/src/dependencies/openzeppelin/AccessManager.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.1.0) (access/manager/AccessManager.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (access/manager/AccessManager.sol) pragma solidity ^0.8.20; @@ -10,6 +10,7 @@ import {Context} from './Context.sol'; import {Multicall} from './Multicall.sol'; import {Math} from './Math.sol'; import {Time} from './Time.sol'; +import {Hashes} from './Hashes.sol'; /** * @dev AccessManager is a central contract to store the permissions of a system. @@ -68,7 +69,7 @@ contract AccessManager is Context, Multicall, IAccessManager { bool closed; } - // Structure that stores the details for a role/account pair. This structures fit into a single slot. + // Structure that stores the details for a role/account pair. This structure fits into a single slot. struct Access { // Timepoint at which the user gets the permission. // If this is either 0 or in the future, then the role permission is not available. @@ -770,6 +771,6 @@ contract AccessManager is Context, Multicall, IAccessManager { * @dev Hashing function for execute protection */ function _hashExecutionId(address target, bytes4 selector) private pure returns (bytes32) { - return keccak256(abi.encode(target, selector)); + return Hashes.efficientKeccak256(bytes32(uint256(uint160(target))), selector); } } diff --git a/src/dependencies/openzeppelin/Address.sol b/src/dependencies/openzeppelin/Address.sol index 0091316fe..d3c322bef 100644 --- a/src/dependencies/openzeppelin/Address.sol +++ b/src/dependencies/openzeppelin/Address.sol @@ -1,9 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.4.0) (utils/Address.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (utils/Address.sol) pragma solidity ^0.8.20; import {Errors} from './Errors.sol'; +import {LowLevelCall} from './LowLevelCall.sol'; /** * @dev Collection of functions related to the address type @@ -34,10 +35,13 @@ library Address { if (address(this).balance < amount) { revert Errors.InsufficientBalance(address(this).balance, amount); } - - (bool success, bytes memory returndata) = recipient.call{value: amount}(''); - if (!success) { - _revert(returndata); + if (LowLevelCall.callNoReturn(recipient, amount, '')) { + // call successful, nothing to do + return; + } else if (LowLevelCall.returnDataSize() > 0) { + LowLevelCall.bubbleRevert(); + } else { + revert Errors.FailedCall(); } } @@ -80,8 +84,16 @@ library Address { if (address(this).balance < value) { revert Errors.InsufficientBalance(address(this).balance, value); } - (bool success, bytes memory returndata) = target.call{value: value}(data); - return verifyCallResultFromTarget(target, success, returndata); + bool success = LowLevelCall.callNoReturn(target, value, data); + if (success && (LowLevelCall.returnDataSize() > 0 || target.code.length > 0)) { + return LowLevelCall.returnData(); + } else if (success) { + revert AddressEmptyCode(target); + } else if (LowLevelCall.returnDataSize() > 0) { + LowLevelCall.bubbleRevert(); + } else { + revert Errors.FailedCall(); + } } /** @@ -92,8 +104,16 @@ library Address { address target, bytes memory data ) internal view returns (bytes memory) { - (bool success, bytes memory returndata) = target.staticcall(data); - return verifyCallResultFromTarget(target, success, returndata); + bool success = LowLevelCall.staticcallNoReturn(target, data); + if (success && (LowLevelCall.returnDataSize() > 0 || target.code.length > 0)) { + return LowLevelCall.returnData(); + } else if (success) { + revert AddressEmptyCode(target); + } else if (LowLevelCall.returnDataSize() > 0) { + LowLevelCall.bubbleRevert(); + } else { + revert Errors.FailedCall(); + } } /** @@ -101,29 +121,40 @@ library Address { * but performing a delegate call. */ function functionDelegateCall(address target, bytes memory data) internal returns (bytes memory) { - (bool success, bytes memory returndata) = target.delegatecall(data); - return verifyCallResultFromTarget(target, success, returndata); + bool success = LowLevelCall.delegatecallNoReturn(target, data); + if (success && (LowLevelCall.returnDataSize() > 0 || target.code.length > 0)) { + return LowLevelCall.returnData(); + } else if (success) { + revert AddressEmptyCode(target); + } else if (LowLevelCall.returnDataSize() > 0) { + LowLevelCall.bubbleRevert(); + } else { + revert Errors.FailedCall(); + } } /** * @dev Tool to verify that a low level call to smart-contract was successful, and reverts if the target * was not a contract or bubbling up the revert reason (falling back to {Errors.FailedCall}) in case * of an unsuccessful call. + * + * NOTE: This function is DEPRECATED and may be removed in the next major release. */ function verifyCallResultFromTarget( address target, bool success, bytes memory returndata ) internal view returns (bytes memory) { - if (!success) { - _revert(returndata); - } else { - // only check if target is a contract if the call was successful and the return data is empty - // otherwise we already know that it was a contract - if (returndata.length == 0 && target.code.length == 0) { - revert AddressEmptyCode(target); - } + // only check if target is a contract if the call was successful and the return data is empty + // otherwise we already know that it was a contract + if (success && (returndata.length > 0 || target.code.length > 0)) { return returndata; + } else if (success) { + revert AddressEmptyCode(target); + } else if (returndata.length > 0) { + LowLevelCall.bubbleRevert(returndata); + } else { + revert Errors.FailedCall(); } } @@ -135,23 +166,10 @@ library Address { bool success, bytes memory returndata ) internal pure returns (bytes memory) { - if (!success) { - _revert(returndata); - } else { + if (success) { return returndata; - } - } - - /** - * @dev Reverts with returndata if present. Otherwise reverts with {Errors.FailedCall}. - */ - function _revert(bytes memory returndata) private pure { - // Look for revert reason and bubble it up if present - if (returndata.length > 0) { - // The easiest way to bubble the revert reason is using memory via assembly - assembly ('memory-safe') { - revert(add(returndata, 0x20), mload(returndata)) - } + } else if (returndata.length > 0) { + LowLevelCall.bubbleRevert(returndata); } else { revert Errors.FailedCall(); } diff --git a/src/dependencies/openzeppelin/Arrays.sol b/src/dependencies/openzeppelin/Arrays.sol index 7eb386984..39aa0d68d 100644 --- a/src/dependencies/openzeppelin/Arrays.sol +++ b/src/dependencies/openzeppelin/Arrays.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.4.0) (utils/Arrays.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (utils/Arrays.sol) // This file was procedurally generated from scripts/generate/templates/Arrays.js. -pragma solidity ^0.8.20; +pragma solidity ^0.8.24; import {Comparators} from './Comparators.sol'; import {SlotDerivation} from './SlotDerivation.sol'; @@ -392,6 +392,213 @@ library Arrays { return low; } + /** + * @dev Copies the content of `array`, from `start` (included) to the end of `array` into a new address array in + * memory. + * + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice[Javascript's `Array.slice`] + */ + function slice(address[] memory array, uint256 start) internal pure returns (address[] memory) { + return slice(array, start, array.length); + } + + /** + * @dev Copies the content of `array`, from `start` (included) to `end` (excluded) into a new address array in + * memory. The `end` argument is truncated to the length of the `array`. + * + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice[Javascript's `Array.slice`] + */ + function slice( + address[] memory array, + uint256 start, + uint256 end + ) internal pure returns (address[] memory) { + // sanitize + end = Math.min(end, array.length); + start = Math.min(start, end); + + // allocate and copy + address[] memory result = new address[](end - start); + assembly ('memory-safe') { + mcopy(add(result, 0x20), add(add(array, 0x20), mul(start, 0x20)), mul(sub(end, start), 0x20)) + } + + return result; + } + + /** + * @dev Copies the content of `array`, from `start` (included) to the end of `array` into a new bytes32 array in + * memory. + * + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice[Javascript's `Array.slice`] + */ + function slice(bytes32[] memory array, uint256 start) internal pure returns (bytes32[] memory) { + return slice(array, start, array.length); + } + + /** + * @dev Copies the content of `array`, from `start` (included) to `end` (excluded) into a new bytes32 array in + * memory. The `end` argument is truncated to the length of the `array`. + * + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice[Javascript's `Array.slice`] + */ + function slice( + bytes32[] memory array, + uint256 start, + uint256 end + ) internal pure returns (bytes32[] memory) { + // sanitize + end = Math.min(end, array.length); + start = Math.min(start, end); + + // allocate and copy + bytes32[] memory result = new bytes32[](end - start); + assembly ('memory-safe') { + mcopy(add(result, 0x20), add(add(array, 0x20), mul(start, 0x20)), mul(sub(end, start), 0x20)) + } + + return result; + } + + /** + * @dev Copies the content of `array`, from `start` (included) to the end of `array` into a new uint256 array in + * memory. + * + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice[Javascript's `Array.slice`] + */ + function slice(uint256[] memory array, uint256 start) internal pure returns (uint256[] memory) { + return slice(array, start, array.length); + } + + /** + * @dev Copies the content of `array`, from `start` (included) to `end` (excluded) into a new uint256 array in + * memory. The `end` argument is truncated to the length of the `array`. + * + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice[Javascript's `Array.slice`] + */ + function slice( + uint256[] memory array, + uint256 start, + uint256 end + ) internal pure returns (uint256[] memory) { + // sanitize + end = Math.min(end, array.length); + start = Math.min(start, end); + + // allocate and copy + uint256[] memory result = new uint256[](end - start); + assembly ('memory-safe') { + mcopy(add(result, 0x20), add(add(array, 0x20), mul(start, 0x20)), mul(sub(end, start), 0x20)) + } + + return result; + } + + /** + * @dev Moves the content of `array`, from `start` (included) to the end of `array` to the start of that array. + * + * NOTE: This function modifies the provided array in place. If you need to preserve the original array, use {slice} instead. + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice[Javascript's `Array.splice`] + */ + function splice(address[] memory array, uint256 start) internal pure returns (address[] memory) { + return splice(array, start, array.length); + } + + /** + * @dev Moves the content of `array`, from `start` (included) to `end` (excluded) to the start of that array. The + * `end` argument is truncated to the length of the `array`. + * + * NOTE: This function modifies the provided array in place. If you need to preserve the original array, use {slice} instead. + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice[Javascript's `Array.splice`] + */ + function splice( + address[] memory array, + uint256 start, + uint256 end + ) internal pure returns (address[] memory) { + // sanitize + end = Math.min(end, array.length); + start = Math.min(start, end); + + // move and resize + assembly ('memory-safe') { + mcopy(add(array, 0x20), add(add(array, 0x20), mul(start, 0x20)), mul(sub(end, start), 0x20)) + mstore(array, sub(end, start)) + } + + return array; + } + + /** + * @dev Moves the content of `array`, from `start` (included) to the end of `array` to the start of that array. + * + * NOTE: This function modifies the provided array in place. If you need to preserve the original array, use {slice} instead. + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice[Javascript's `Array.splice`] + */ + function splice(bytes32[] memory array, uint256 start) internal pure returns (bytes32[] memory) { + return splice(array, start, array.length); + } + + /** + * @dev Moves the content of `array`, from `start` (included) to `end` (excluded) to the start of that array. The + * `end` argument is truncated to the length of the `array`. + * + * NOTE: This function modifies the provided array in place. If you need to preserve the original array, use {slice} instead. + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice[Javascript's `Array.splice`] + */ + function splice( + bytes32[] memory array, + uint256 start, + uint256 end + ) internal pure returns (bytes32[] memory) { + // sanitize + end = Math.min(end, array.length); + start = Math.min(start, end); + + // move and resize + assembly ('memory-safe') { + mcopy(add(array, 0x20), add(add(array, 0x20), mul(start, 0x20)), mul(sub(end, start), 0x20)) + mstore(array, sub(end, start)) + } + + return array; + } + + /** + * @dev Moves the content of `array`, from `start` (included) to the end of `array` to the start of that array. + * + * NOTE: This function modifies the provided array in place. If you need to preserve the original array, use {slice} instead. + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice[Javascript's `Array.splice`] + */ + function splice(uint256[] memory array, uint256 start) internal pure returns (uint256[] memory) { + return splice(array, start, array.length); + } + + /** + * @dev Moves the content of `array`, from `start` (included) to `end` (excluded) to the start of that array. The + * `end` argument is truncated to the length of the `array`. + * + * NOTE: This function modifies the provided array in place. If you need to preserve the original array, use {slice} instead. + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice[Javascript's `Array.splice`] + */ + function splice( + uint256[] memory array, + uint256 start, + uint256 end + ) internal pure returns (uint256[] memory) { + // sanitize + end = Math.min(end, array.length); + start = Math.min(start, end); + + // move and resize + assembly ('memory-safe') { + mcopy(add(array, 0x20), add(add(array, 0x20), mul(start, 0x20)), mul(sub(end, start), 0x20)) + mstore(array, sub(end, start)) + } + + return array; + } + /** * @dev Access an array in an "unsafe" way. Skips solidity "index-out-of-range" check. * diff --git a/src/dependencies/openzeppelin/Bytes.sol b/src/dependencies/openzeppelin/Bytes.sol index 66e0877dc..7fac90ec8 100644 --- a/src/dependencies/openzeppelin/Bytes.sol +++ b/src/dependencies/openzeppelin/Bytes.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.4.0) (utils/Bytes.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (utils/Bytes.sol) pragma solidity ^0.8.24; @@ -79,7 +79,7 @@ library Bytes { /** * @dev Copies the content of `buffer`, from `start` (included) to `end` (excluded) into a new bytes object in - * memory. + * memory. The `end` argument is truncated to the length of the `buffer`. * * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice[Javascript's `Array.slice`] */ @@ -89,8 +89,7 @@ library Bytes { uint256 end ) internal pure returns (bytes memory) { // sanitize - uint256 length = buffer.length; - end = Math.min(end, length); + end = Math.min(end, buffer.length); start = Math.min(start, end); // allocate and copy @@ -102,6 +101,145 @@ library Bytes { return result; } + /** + * @dev Moves the content of `buffer`, from `start` (included) to the end of `buffer` to the start of that buffer. + * + * NOTE: This function modifies the provided buffer in place. If you need to preserve the original buffer, use {slice} instead + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice[Javascript's `Array.splice`] + */ + function splice(bytes memory buffer, uint256 start) internal pure returns (bytes memory) { + return splice(buffer, start, buffer.length); + } + + /** + * @dev Moves the content of `buffer`, from `start` (included) to end (excluded) to the start of that buffer. The + * `end` argument is truncated to the length of the `buffer`. + * + * NOTE: This function modifies the provided buffer in place. If you need to preserve the original buffer, use {slice} instead + * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice[Javascript's `Array.splice`] + */ + function splice( + bytes memory buffer, + uint256 start, + uint256 end + ) internal pure returns (bytes memory) { + // sanitize + end = Math.min(end, buffer.length); + start = Math.min(start, end); + + // allocate and copy + assembly ('memory-safe') { + mcopy(add(buffer, 0x20), add(add(buffer, 0x20), start), sub(end, start)) + mstore(buffer, sub(end, start)) + } + + return buffer; + } + + /** + * @dev Concatenate an array of bytes into a single bytes object. + * + * For fixed bytes types, we recommend using the solidity built-in `bytes.concat` or (equivalent) + * `abi.encodePacked`. + * + * NOTE: this could be done in assembly with a single loop that expands starting at the FMP, but that would be + * significantly less readable. It might be worth benchmarking the savings of the full-assembly approach. + */ + function concat(bytes[] memory buffers) internal pure returns (bytes memory) { + uint256 length = 0; + for (uint256 i = 0; i < buffers.length; ++i) { + length += buffers[i].length; + } + + bytes memory result = new bytes(length); + + uint256 offset = 0x20; + for (uint256 i = 0; i < buffers.length; ++i) { + bytes memory input = buffers[i]; + assembly ('memory-safe') { + mcopy(add(result, offset), add(input, 0x20), mload(input)) + } + unchecked { + offset += input.length; + } + } + + return result; + } + + /** + * @dev Returns true if the two byte buffers are equal. + */ + function equal(bytes memory a, bytes memory b) internal pure returns (bool) { + return a.length == b.length && keccak256(a) == keccak256(b); + } + + /** + * @dev Reverses the byte order of a bytes32 value, converting between little-endian and big-endian. + * Inspired by https://graphics.stanford.edu/~seander/bithacks.html#ReverseParallel[Reverse Parallel] + */ + function reverseBytes32(bytes32 value) internal pure returns (bytes32) { + value = // swap bytes + ((value >> 8) & 0x00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF) | + ((value & 0x00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF) << 8); + value = // swap 2-byte long pairs + ((value >> 16) & 0x0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF) | + ((value & 0x0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF) << 16); + value = // swap 4-byte long pairs + ((value >> 32) & 0x00000000FFFFFFFF00000000FFFFFFFF00000000FFFFFFFF00000000FFFFFFFF) | + ((value & 0x00000000FFFFFFFF00000000FFFFFFFF00000000FFFFFFFF00000000FFFFFFFF) << 32); + value = // swap 8-byte long pairs + ((value >> 64) & 0x0000000000000000FFFFFFFFFFFFFFFF0000000000000000FFFFFFFFFFFFFFFF) | + ((value & 0x0000000000000000FFFFFFFFFFFFFFFF0000000000000000FFFFFFFFFFFFFFFF) << 64); + return (value >> 128) | (value << 128); // swap 16-byte long pairs + } + + /// @dev Same as {reverseBytes32} but optimized for 128-bit values. + function reverseBytes16(bytes16 value) internal pure returns (bytes16) { + value = // swap bytes + ((value & 0xFF00FF00FF00FF00FF00FF00FF00FF00) >> 8) | + ((value & 0x00FF00FF00FF00FF00FF00FF00FF00FF) << 8); + value = // swap 2-byte long pairs + ((value & 0xFFFF0000FFFF0000FFFF0000FFFF0000) >> 16) | + ((value & 0x0000FFFF0000FFFF0000FFFF0000FFFF) << 16); + value = // swap 4-byte long pairs + ((value & 0xFFFFFFFF00000000FFFFFFFF00000000) >> 32) | + ((value & 0x00000000FFFFFFFF00000000FFFFFFFF) << 32); + return (value >> 64) | (value << 64); // swap 8-byte long pairs + } + + /// @dev Same as {reverseBytes32} but optimized for 64-bit values. + function reverseBytes8(bytes8 value) internal pure returns (bytes8) { + value = ((value & 0xFF00FF00FF00FF00) >> 8) | ((value & 0x00FF00FF00FF00FF) << 8); // swap bytes + value = ((value & 0xFFFF0000FFFF0000) >> 16) | ((value & 0x0000FFFF0000FFFF) << 16); // swap 2-byte long pairs + return (value >> 32) | (value << 32); // swap 4-byte long pairs + } + + /// @dev Same as {reverseBytes32} but optimized for 32-bit values. + function reverseBytes4(bytes4 value) internal pure returns (bytes4) { + value = ((value & 0xFF00FF00) >> 8) | ((value & 0x00FF00FF) << 8); // swap bytes + return (value >> 16) | (value << 16); // swap 2-byte long pairs + } + + /// @dev Same as {reverseBytes32} but optimized for 16-bit values. + function reverseBytes2(bytes2 value) internal pure returns (bytes2) { + return (value >> 8) | (value << 8); + } + + /** + * @dev Counts the number of leading zero bits a bytes array. Returns `8 * buffer.length` + * if the buffer is all zeros. + */ + function clz(bytes memory buffer) internal pure returns (uint256) { + for (uint256 i = 0; i < buffer.length; i += 0x20) { + bytes32 chunk = _unsafeReadBytesOffset(buffer, i); + if (chunk != bytes32(0)) { + return Math.min(8 * i + Math.clz(uint256(chunk)), 8 * buffer.length); + } + } + return 8 * buffer.length; + } + /** * @dev Reads a bytes32 from a bytes array without bounds checking. * diff --git a/src/dependencies/openzeppelin/ECDSA.sol b/src/dependencies/openzeppelin/ECDSA.sol index 3e92485cc..aa1b97538 100644 --- a/src/dependencies/openzeppelin/ECDSA.sol +++ b/src/dependencies/openzeppelin/ECDSA.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.1.0) (utils/cryptography/ECDSA.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (utils/cryptography/ECDSA.sol) pragma solidity ^0.8.20; @@ -43,6 +43,10 @@ library ECDSA { * this function rejects them by requiring the `s` value to be in the lower * half order, and the `v` value to be either 27 or 28. * + * NOTE: This function only supports 65-byte signatures. ERC-2098 short signatures are rejected. This restriction + * is DEPRECATED and will be removed in v6.0. Developers SHOULD NOT use signatures as unique identifiers; use hash + * invalidation or nonces for replay protection. + * * IMPORTANT: `hash` _must_ be the result of a hash operation for the * verification to be secure: it is possible to craft signatures that * recover to arbitrary addresses for non-hashed data. A safe way to ensure @@ -50,6 +54,7 @@ library ECDSA { * be too long), and then calling {MessageHashUtils-toEthSignedMessageHash} on it. * * Documentation for signature generation: + * * - with https://web3js.readthedocs.io/en/v1.3.4/web3-eth-accounts.html#sign[Web3.js] * - with https://docs.ethers.io/v5/api/signer/#Signer-signMessage[ethers] */ @@ -74,6 +79,30 @@ library ECDSA { } } + /** + * @dev Variant of {tryRecover} that takes a signature in calldata + */ + function tryRecoverCalldata( + bytes32 hash, + bytes calldata signature + ) internal pure returns (address recovered, RecoverError err, bytes32 errArg) { + if (signature.length == 65) { + bytes32 r; + bytes32 s; + uint8 v; + // ecrecover takes the signature parameters, calldata slices would work here, but are + // significantly more expensive (length check) than using calldataload in assembly. + assembly ('memory-safe') { + r := calldataload(signature.offset) + s := calldataload(add(signature.offset, 0x20)) + v := byte(0, calldataload(add(signature.offset, 0x40))) + } + return tryRecover(hash, v, r, s); + } else { + return (address(0), RecoverError.InvalidSignatureLength, bytes32(signature.length)); + } + } + /** * @dev Returns the address that signed a hashed message (`hash`) with * `signature`. This address can then be used for verification purposes. @@ -82,6 +111,10 @@ library ECDSA { * this function rejects them by requiring the `s` value to be in the lower * half order, and the `v` value to be either 27 or 28. * + * NOTE: This function only supports 65-byte signatures. ERC-2098 short signatures are rejected. This restriction + * is DEPRECATED and will be removed in v6.0. Developers SHOULD NOT use signatures as unique identifiers; use hash + * invalidation or nonces for replay protection. + * * IMPORTANT: `hash` _must_ be the result of a hash operation for the * verification to be secure: it is possible to craft signatures that * recover to arbitrary addresses for non-hashed data. A safe way to ensure @@ -94,6 +127,15 @@ library ECDSA { return recovered; } + /** + * @dev Variant of {recover} that takes a signature in calldata + */ + function recoverCalldata(bytes32 hash, bytes calldata signature) internal pure returns (address) { + (address recovered, RecoverError error, bytes32 errorArg) = tryRecoverCalldata(hash, signature); + _throwError(error, errorArg); + return recovered; + } + /** * @dev Overload of {ECDSA-tryRecover} that receives the `r` and `vs` short-signature fields separately. * @@ -163,6 +205,70 @@ library ECDSA { return recovered; } + /** + * @dev Parse a signature into its `v`, `r` and `s` components. Supports 65-byte and 64-byte (ERC-2098) + * formats. Returns (0,0,0) for invalid signatures. + * + * For 64-byte signatures, `v` is automatically normalized to 27 or 28. + * For 65-byte signatures, `v` is returned as-is and MUST already be 27 or 28 for use with ecrecover. + * + * Consider validating the result before use, or use {tryRecover}/{recover} which perform full validation. + */ + function parse(bytes memory signature) internal pure returns (uint8 v, bytes32 r, bytes32 s) { + assembly ('memory-safe') { + // Check the signature length + switch mload(signature) + // - case 65: r,s,v signature (standard) + case 65 { + r := mload(add(signature, 0x20)) + s := mload(add(signature, 0x40)) + v := byte(0, mload(add(signature, 0x60))) + } + // - case 64: r,vs signature (cf https://eips.ethereum.org/EIPS/eip-2098) + case 64 { + let vs := mload(add(signature, 0x40)) + r := mload(add(signature, 0x20)) + s := and(vs, shr(1, not(0))) + v := add(shr(255, vs), 27) + } + default { + r := 0 + s := 0 + v := 0 + } + } + } + + /** + * @dev Variant of {parse} that takes a signature in calldata + */ + function parseCalldata( + bytes calldata signature + ) internal pure returns (uint8 v, bytes32 r, bytes32 s) { + assembly ('memory-safe') { + // Check the signature length + switch signature.length + // - case 65: r,s,v signature (standard) + case 65 { + r := calldataload(signature.offset) + s := calldataload(add(signature.offset, 0x20)) + v := byte(0, calldataload(add(signature.offset, 0x40))) + } + // - case 64: r,vs signature (cf https://eips.ethereum.org/EIPS/eip-2098) + case 64 { + let vs := calldataload(add(signature.offset, 0x20)) + r := calldataload(signature.offset) + s := and(vs, shr(1, not(0))) + v := add(shr(255, vs), 27) + } + default { + r := 0 + s := 0 + v := 0 + } + } + } + /** * @dev Optionally reverts with the corresponding custom error according to the `error` argument provided. */ diff --git a/src/dependencies/openzeppelin/ERC20.sol b/src/dependencies/openzeppelin/ERC20.sol index d37c687f4..524d3efbf 100644 --- a/src/dependencies/openzeppelin/ERC20.sol +++ b/src/dependencies/openzeppelin/ERC20.sol @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC20/ERC20.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (token/ERC20/ERC20.sol) pragma solidity ^0.8.20; import {IERC20} from './IERC20.sol'; import {IERC20Metadata} from './IERC20Metadata.sol'; import {Context} from './Context.sol'; -import {IERC20Errors} from './IERC20Errors.sol'; +import {IERC20Errors} from './draft-IERC6093.sol'; /** * @dev Implementation of the {IERC20} interface. @@ -256,10 +256,10 @@ abstract contract ERC20 is Context, IERC20, IERC20Metadata, IERC20Errors { * @dev Variant of {_approve} with an optional flag to enable or disable the {Approval} event. * * By default (when calling {_approve}) the flag is set to true. On the other hand, approval changes made by - * `_spendAllowance` during the `transferFrom` operation set the flag to false. This saves gas by not emitting any + * `_spendAllowance` during the `transferFrom` operation sets the flag to false. This saves gas by not emitting any * `Approval` event during `transferFrom` operations. * - * Anyone who wishes to continue emitting `Approval` events on the`transferFrom` operation can force the flag to + * Anyone who wishes to continue emitting `Approval` events on the `transferFrom` operation can force the flag to * true using the following override: * * ```solidity diff --git a/src/dependencies/openzeppelin/EnumerableSet.sol b/src/dependencies/openzeppelin/EnumerableSet.sol index 43e6bd622..14bc68cdc 100644 --- a/src/dependencies/openzeppelin/EnumerableSet.sol +++ b/src/dependencies/openzeppelin/EnumerableSet.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.4.0) (utils/structs/EnumerableSet.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (utils/structs/EnumerableSet.sol) // This file was procedurally generated from scripts/generate/templates/EnumerableSet.js. -pragma solidity ^0.8.20; +pragma solidity ^0.8.24; import {Arrays} from './Arrays.sol'; import {Math} from './Math.sol'; diff --git a/src/dependencies/openzeppelin/Hashes.sol b/src/dependencies/openzeppelin/Hashes.sol new file mode 100644 index 000000000..2c31b8348 --- /dev/null +++ b/src/dependencies/openzeppelin/Hashes.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.3.0) (utils/cryptography/Hashes.sol) + +pragma solidity ^0.8.20; + +/** + * @dev Library of standard hash functions. + * + * _Available since v5.1._ + */ +library Hashes { + /** + * @dev Commutative Keccak256 hash of a sorted pair of bytes32. Frequently used when working with merkle proofs. + * + * NOTE: Equivalent to the `standardNodeHash` in our https://github.com/OpenZeppelin/merkle-tree[JavaScript library]. + */ + function commutativeKeccak256(bytes32 a, bytes32 b) internal pure returns (bytes32) { + return a < b ? efficientKeccak256(a, b) : efficientKeccak256(b, a); + } + + /** + * @dev Implementation of keccak256(abi.encode(a, b)) that doesn't allocate or expand memory. + */ + function efficientKeccak256(bytes32 a, bytes32 b) internal pure returns (bytes32 value) { + assembly ('memory-safe') { + mstore(0x00, a) + mstore(0x20, b) + value := keccak256(0x00, 0x40) + } + } +} diff --git a/src/dependencies/openzeppelin/IAccessManager.sol b/src/dependencies/openzeppelin/IAccessManager.sol index d3d97e682..be3ce2a10 100644 --- a/src/dependencies/openzeppelin/IAccessManager.sol +++ b/src/dependencies/openzeppelin/IAccessManager.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.4.0) (access/manager/IAccessManager.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (access/manager/IAccessManager.sol) pragma solidity >=0.8.4; @@ -134,7 +134,7 @@ interface IAccessManager { /** * @dev Minimum setback for all delay updates, with the exception of execution delays. It - * can be increased without setback (and reset via {revokeRole} in the case event of an + * can be increased without setback (and reset via {revokeRole} in the event of an * accidental increase). Defaults to 5 days. */ function minSetback() external view returns (uint32); @@ -181,7 +181,7 @@ interface IAccessManager { /** * @dev Get the access details for a given account for a given role. These details include the timepoint at which - * membership becomes active, and the delay applied to all operation by this user that requires this permission + * membership becomes active, and the delay applied to all operations by this user that requires this permission * level. * * Returns: @@ -394,7 +394,7 @@ interface IAccessManager { * @dev Consume a scheduled operation targeting the caller. If such an operation exists, mark it as consumed * (emit an {OperationExecuted} event and clean the state). Otherwise, throw an error. * - * This is useful for contract that want to enforce that calls targeting them were scheduled on the manager, + * This is useful for contracts that want to enforce that calls targeting them were scheduled on the manager, * with all the verifications that it implies. * * Emit a {OperationExecuted} event. diff --git a/src/dependencies/openzeppelin/IERC2612.sol b/src/dependencies/openzeppelin/IERC2612.sol new file mode 100644 index 000000000..9c8194840 --- /dev/null +++ b/src/dependencies/openzeppelin/IERC2612.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.4.0) (interfaces/IERC2612.sol) + +pragma solidity >=0.6.2; + +import {IERC20Permit} from './IERC20Permit.sol'; + +interface IERC2612 is IERC20Permit {} diff --git a/src/dependencies/openzeppelin/IERC4626.sol b/src/dependencies/openzeppelin/IERC4626.sol new file mode 100644 index 000000000..4ad448577 --- /dev/null +++ b/src/dependencies/openzeppelin/IERC4626.sol @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.5.0) (interfaces/IERC4626.sol) + +pragma solidity >=0.6.2; + +import {IERC20} from './IERC20.sol'; +import {IERC20Metadata} from './IERC20Metadata.sol'; + +/** + * @dev Interface of the ERC-4626 "Tokenized Vault Standard", as defined in + * https://eips.ethereum.org/EIPS/eip-4626[ERC-4626]. + */ +interface IERC4626 is IERC20, IERC20Metadata { + event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares); + + event Withdraw( + address indexed sender, + address indexed receiver, + address indexed owner, + uint256 assets, + uint256 shares + ); + + /** + * @dev Returns the address of the underlying token used for the Vault for accounting, depositing, and withdrawing. + * + * - MUST be an ERC-20 token contract. + * - MUST NOT revert. + */ + function asset() external view returns (address assetTokenAddress); + + /** + * @dev Returns the total amount of the underlying asset that is “managed” by Vault. + * + * - SHOULD include any compounding that occurs from yield. + * - MUST be inclusive of any fees that are charged against assets in the Vault. + * - MUST NOT revert. + */ + function totalAssets() external view returns (uint256 totalManagedAssets); + + /** + * @dev Returns the amount of shares that the Vault would exchange for the amount of assets provided, in an ideal + * scenario where all the conditions are met. + * + * - MUST NOT be inclusive of any fees that are charged against assets in the Vault. + * - MUST NOT show any variations depending on the caller. + * - MUST NOT reflect slippage or other on-chain conditions, when performing the actual exchange. + * - MUST NOT revert. + * + * NOTE: This calculation MAY NOT reflect the “per-user” price-per-share, and instead should reflect the + * “average-user’s” price-per-share, meaning what the average user should expect to see when exchanging to and + * from. + */ + function convertToShares(uint256 assets) external view returns (uint256 shares); + + /** + * @dev Returns the amount of assets that the Vault would exchange for the amount of shares provided, in an ideal + * scenario where all the conditions are met. + * + * - MUST NOT be inclusive of any fees that are charged against assets in the Vault. + * - MUST NOT show any variations depending on the caller. + * - MUST NOT reflect slippage or other on-chain conditions, when performing the actual exchange. + * - MUST NOT revert. + * + * NOTE: This calculation MAY NOT reflect the “per-user” price-per-share, and instead should reflect the + * “average-user’s” price-per-share, meaning what the average user should expect to see when exchanging to and + * from. + */ + function convertToAssets(uint256 shares) external view returns (uint256 assets); + + /** + * @dev Returns the maximum amount of the underlying asset that can be deposited into the Vault for the receiver, + * through a deposit call. + * + * - MUST return a limited value if receiver is subject to some deposit limit. + * - MUST return 2 ** 256 - 1 if there is no limit on the maximum amount of assets that may be deposited. + * - MUST NOT revert. + */ + function maxDeposit(address receiver) external view returns (uint256 maxAssets); + + /** + * @dev Allows an on-chain or off-chain user to simulate the effects of their deposit at the current block, given + * current on-chain conditions. + * + * - MUST return as close to and no more than the exact amount of Vault shares that would be minted in a deposit + * call in the same transaction. I.e. deposit should return the same or more shares as previewDeposit if called + * in the same transaction. + * - MUST NOT account for deposit limits like those returned from maxDeposit and should always act as though the + * deposit would be accepted, regardless if the user has enough tokens approved, etc. + * - MUST be inclusive of deposit fees. Integrators should be aware of the existence of deposit fees. + * - MUST NOT revert. + * + * NOTE: any unfavorable discrepancy between convertToShares and previewDeposit SHOULD be considered slippage in + * share price or some other type of condition, meaning the depositor will lose assets by depositing. + */ + function previewDeposit(uint256 assets) external view returns (uint256 shares); + + /** + * @dev Deposit `assets` underlying tokens and send the corresponding number of vault shares (`shares`) to `receiver`. + * + * - MUST emit the Deposit event. + * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the + * deposit execution, and are accounted for during deposit. + * - MUST revert if all of assets cannot be deposited (due to deposit limit being reached, slippage, the user not + * approving enough underlying tokens to the Vault contract, etc). + * + * NOTE: most implementations will require pre-approval of the Vault with the Vault’s underlying asset token. + */ + function deposit(uint256 assets, address receiver) external returns (uint256 shares); + + /** + * @dev Returns the maximum amount of the Vault shares that can be minted for the receiver, through a mint call. + * - MUST return a limited value if receiver is subject to some mint limit. + * - MUST return 2 ** 256 - 1 if there is no limit on the maximum amount of shares that may be minted. + * - MUST NOT revert. + */ + function maxMint(address receiver) external view returns (uint256 maxShares); + + /** + * @dev Allows an on-chain or off-chain user to simulate the effects of their mint at the current block, given + * current on-chain conditions. + * + * - MUST return as close to and no fewer than the exact amount of assets that would be deposited in a mint call + * in the same transaction. I.e. mint should return the same or fewer assets as previewMint if called in the + * same transaction. + * - MUST NOT account for mint limits like those returned from maxMint and should always act as though the mint + * would be accepted, regardless if the user has enough tokens approved, etc. + * - MUST be inclusive of deposit fees. Integrators should be aware of the existence of deposit fees. + * - MUST NOT revert. + * + * NOTE: any unfavorable discrepancy between convertToAssets and previewMint SHOULD be considered slippage in + * share price or some other type of condition, meaning the depositor will lose assets by minting. + */ + function previewMint(uint256 shares) external view returns (uint256 assets); + + /** + * @dev Mints exactly `shares` vault shares to `receiver` in exchange for `assets` underlying tokens. + * + * - MUST emit the Deposit event. + * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the mint + * execution, and are accounted for during mint. + * - MUST revert if all of shares cannot be minted (due to deposit limit being reached, slippage, the user not + * approving enough underlying tokens to the Vault contract, etc). + * + * NOTE: most implementations will require pre-approval of the Vault with the Vault’s underlying asset token. + */ + function mint(uint256 shares, address receiver) external returns (uint256 assets); + + /** + * @dev Returns the maximum amount of the underlying asset that can be withdrawn from the owner balance in the + * Vault, through a withdraw call. + * + * - MUST return a limited value if owner is subject to some withdrawal limit or timelock. + * - MUST NOT revert. + */ + function maxWithdraw(address owner) external view returns (uint256 maxAssets); + + /** + * @dev Allows an on-chain or off-chain user to simulate the effects of their withdrawal at the current block, + * given current on-chain conditions. + * + * - MUST return as close to and no fewer than the exact amount of Vault shares that would be burned in a withdraw + * call in the same transaction. I.e. withdraw should return the same or fewer shares as previewWithdraw if + * called + * in the same transaction. + * - MUST NOT account for withdrawal limits like those returned from maxWithdraw and should always act as though + * the withdrawal would be accepted, regardless if the user has enough shares, etc. + * - MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of withdrawal fees. + * - MUST NOT revert. + * + * NOTE: any unfavorable discrepancy between convertToShares and previewWithdraw SHOULD be considered slippage in + * share price or some other type of condition, meaning the depositor will lose assets by depositing. + */ + function previewWithdraw(uint256 assets) external view returns (uint256 shares); + + /** + * @dev Burns shares from owner and sends exactly assets of underlying tokens to receiver. + * + * - MUST emit the Withdraw event. + * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the + * withdraw execution, and are accounted for during withdraw. + * - MUST revert if all of assets cannot be withdrawn (due to withdrawal limit being reached, slippage, the owner + * not having enough shares, etc). + * + * Note that some implementations will require pre-requesting to the Vault before a withdrawal may be performed. + * Those methods should be performed separately. + */ + function withdraw( + uint256 assets, + address receiver, + address owner + ) external returns (uint256 shares); + + /** + * @dev Returns the maximum amount of Vault shares that can be redeemed from the owner balance in the Vault, + * through a redeem call. + * + * - MUST return a limited value if owner is subject to some withdrawal limit or timelock. + * - MUST return balanceOf(owner) if owner is not subject to any withdrawal limit or timelock. + * - MUST NOT revert. + */ + function maxRedeem(address owner) external view returns (uint256 maxShares); + + /** + * @dev Allows an on-chain or off-chain user to simulate the effects of their redemption at the current block, + * given current on-chain conditions. + * + * - MUST return as close to and no more than the exact amount of assets that would be withdrawn in a redeem call + * in the same transaction. I.e. redeem should return the same or more assets as previewRedeem if called in the + * same transaction. + * - MUST NOT account for redemption limits like those returned from maxRedeem and should always act as though the + * redemption would be accepted, regardless if the user has enough shares, etc. + * - MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of withdrawal fees. + * - MUST NOT revert. + * + * NOTE: any unfavorable discrepancy between convertToAssets and previewRedeem SHOULD be considered slippage in + * share price or some other type of condition, meaning the depositor will lose assets by redeeming. + */ + function previewRedeem(uint256 shares) external view returns (uint256 assets); + + /** + * @dev Burns exactly shares from owner and sends assets of underlying tokens to receiver. + * + * - MUST emit the Withdraw event. + * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the + * redeem execution, and are accounted for during redeem. + * - MUST revert if all of shares cannot be redeemed (due to withdrawal limit being reached, slippage, the owner + * not having enough shares, etc). + * + * NOTE: some implementations will require pre-requesting to the Vault before a withdrawal may be performed. + * Those methods should be performed separately. + */ + function redeem( + uint256 shares, + address receiver, + address owner + ) external returns (uint256 assets); +} diff --git a/src/dependencies/openzeppelin/LowLevelCall.sol b/src/dependencies/openzeppelin/LowLevelCall.sol new file mode 100644 index 000000000..60e2d4b3f --- /dev/null +++ b/src/dependencies/openzeppelin/LowLevelCall.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.5.0) (utils/LowLevelCall.sol) + +pragma solidity ^0.8.20; + +/** + * @dev Library of low level call functions that implement different calling strategies to deal with the return data. + * + * WARNING: Using this library requires an advanced understanding of Solidity and how the EVM works. It is recommended + * to use the {Address} library instead. + */ +library LowLevelCall { + /// @dev Performs a Solidity function call using a low level `call` and ignoring the return data. + function callNoReturn(address target, bytes memory data) internal returns (bool success) { + return callNoReturn(target, 0, data); + } + + /// @dev Same as {callNoReturn}, but allows to specify the value to be sent in the call. + function callNoReturn( + address target, + uint256 value, + bytes memory data + ) internal returns (bool success) { + assembly ('memory-safe') { + success := call(gas(), target, value, add(data, 0x20), mload(data), 0x00, 0x00) + } + } + + /// @dev Performs a Solidity function call using a low level `call` and returns the first 64 bytes of the result + /// in the scratch space of memory. Useful for functions that return a tuple of single-word values. + /// + /// WARNING: Do not assume that the results are zero if `success` is false. Memory can be already allocated + /// and this function doesn't zero it out. + function callReturn64Bytes( + address target, + bytes memory data + ) internal returns (bool success, bytes32 result1, bytes32 result2) { + return callReturn64Bytes(target, 0, data); + } + + /// @dev Same as {callReturnBytes32Pair}, but allows to specify the value to be sent in the call. + function callReturn64Bytes( + address target, + uint256 value, + bytes memory data + ) internal returns (bool success, bytes32 result1, bytes32 result2) { + assembly ('memory-safe') { + success := call(gas(), target, value, add(data, 0x20), mload(data), 0x00, 0x40) + result1 := mload(0x00) + result2 := mload(0x20) + } + } + + /// @dev Performs a Solidity function call using a low level `staticcall` and ignoring the return data. + function staticcallNoReturn( + address target, + bytes memory data + ) internal view returns (bool success) { + assembly ('memory-safe') { + success := staticcall(gas(), target, add(data, 0x20), mload(data), 0x00, 0x00) + } + } + + /// @dev Performs a Solidity function call using a low level `staticcall` and returns the first 64 bytes of the result + /// in the scratch space of memory. Useful for functions that return a tuple of single-word values. + /// + /// WARNING: Do not assume that the results are zero if `success` is false. Memory can be already allocated + /// and this function doesn't zero it out. + function staticcallReturn64Bytes( + address target, + bytes memory data + ) internal view returns (bool success, bytes32 result1, bytes32 result2) { + assembly ('memory-safe') { + success := staticcall(gas(), target, add(data, 0x20), mload(data), 0x00, 0x40) + result1 := mload(0x00) + result2 := mload(0x20) + } + } + + /// @dev Performs a Solidity function call using a low level `delegatecall` and ignoring the return data. + function delegatecallNoReturn(address target, bytes memory data) internal returns (bool success) { + assembly ('memory-safe') { + success := delegatecall(gas(), target, add(data, 0x20), mload(data), 0x00, 0x00) + } + } + + /// @dev Performs a Solidity function call using a low level `delegatecall` and returns the first 64 bytes of the result + /// in the scratch space of memory. Useful for functions that return a tuple of single-word values. + /// + /// WARNING: Do not assume that the results are zero if `success` is false. Memory can be already allocated + /// and this function doesn't zero it out. + function delegatecallReturn64Bytes( + address target, + bytes memory data + ) internal returns (bool success, bytes32 result1, bytes32 result2) { + assembly ('memory-safe') { + success := delegatecall(gas(), target, add(data, 0x20), mload(data), 0x00, 0x40) + result1 := mload(0x00) + result2 := mload(0x20) + } + } + + /// @dev Returns the size of the return data buffer. + function returnDataSize() internal pure returns (uint256 size) { + assembly ('memory-safe') { + size := returndatasize() + } + } + + /// @dev Returns a buffer containing the return data from the last call. + function returnData() internal pure returns (bytes memory result) { + assembly ('memory-safe') { + result := mload(0x40) + mstore(result, returndatasize()) + returndatacopy(add(result, 0x20), 0x00, returndatasize()) + mstore(0x40, add(result, add(0x20, returndatasize()))) + } + } + + /// @dev Revert with the return data from the last call. + function bubbleRevert() internal pure { + assembly ('memory-safe') { + let fmp := mload(0x40) + returndatacopy(fmp, 0x00, returndatasize()) + revert(fmp, returndatasize()) + } + } + + function bubbleRevert(bytes memory returndata) internal pure { + assembly ('memory-safe') { + revert(add(returndata, 0x20), mload(returndata)) + } + } +} diff --git a/src/dependencies/openzeppelin/Math.sol b/src/dependencies/openzeppelin/Math.sol index f5bbcc05f..ba071c1aa 100644 --- a/src/dependencies/openzeppelin/Math.sol +++ b/src/dependencies/openzeppelin/Math.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.3.0) (utils/math/Math.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (utils/math/Math.sol) pragma solidity ^0.8.20; @@ -134,10 +134,10 @@ library Math { } /** - * @dev Branchless ternary evaluation for `a ? b : c`. Gas costs are constant. + * @dev Branchless ternary evaluation for `condition ? a : b`. Gas costs are constant. * * IMPORTANT: This function may reduce bytecode size and consume less gas when used standalone. - * However, the compiler may optimize Solidity ternary operations (i.e. `a ? b : c`) to only compute + * However, the compiler may optimize Solidity ternary operations (i.e. `condition ? a : b`) to only compute * one branch when needed, making this function more expensive. */ function ternary(bool condition, uint256 a, uint256 b) internal pure returns (uint256) { @@ -774,4 +774,11 @@ library Math { function unsignedRoundsUp(Rounding rounding) internal pure returns (bool) { return uint8(rounding) % 2 == 1; } + + /** + * @dev Counts the number of leading zero bits in a uint256. + */ + function clz(uint256 x) internal pure returns (uint256) { + return ternary(x == 0, 256, 255 - log2(x)); + } } diff --git a/src/dependencies/openzeppelin/Multicall.sol b/src/dependencies/openzeppelin/Multicall.sol index 24e84fdb8..a82b9843c 100644 --- a/src/dependencies/openzeppelin/Multicall.sol +++ b/src/dependencies/openzeppelin/Multicall.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.3.0) (utils/Multicall.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (utils/Multicall.sol) pragma solidity ^0.8.20; @@ -23,7 +23,7 @@ abstract contract Multicall is Context { * @dev Receives and executes a batch of function calls on this contract. * @custom:oz-upgrades-unsafe-allow-reachable delegatecall */ - function multicall(bytes[] calldata data) external virtual returns (bytes[] memory results) { + function multicall(bytes[] calldata data) public virtual returns (bytes[] memory results) { bytes memory context = msg.sender == _msgSender() ? new bytes(0) : msg.data[msg.data.length - _contextSuffixLength():]; diff --git a/src/dependencies/openzeppelin/Proxy.sol b/src/dependencies/openzeppelin/Proxy.sol index 0e736512c..3c129302d 100644 --- a/src/dependencies/openzeppelin/Proxy.sol +++ b/src/dependencies/openzeppelin/Proxy.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.0.0) (proxy/Proxy.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (proxy/Proxy.sol) pragma solidity ^0.8.20; @@ -14,56 +14,56 @@ pragma solidity ^0.8.20; * The success and return data of the delegated call will be returned back to the caller of the proxy. */ abstract contract Proxy { - /** - * @dev Delegates the current call to `implementation`. - * - * This function does not return to its internal call site, it will return directly to the external caller. - */ - function _delegate(address implementation) internal virtual { - assembly { - // Copy msg.data. We take full control of memory in this inline assembly - // block because it will not return to Solidity code. We overwrite the - // Solidity scratch pad at memory position 0. - calldatacopy(0, 0, calldatasize()) + /** + * @dev Delegates the current call to `implementation`. + * + * This function does not return to its internal call site, it will return directly to the external caller. + */ + function _delegate(address implementation) internal virtual { + assembly { + // Copy msg.data. We take full control of memory in this inline assembly + // block because it will not return to Solidity code. We overwrite the + // Solidity scratch pad at memory position 0. + calldatacopy(0x00, 0x00, calldatasize()) - // Call the implementation. - // out and outsize are 0 because we don't know the size yet. - let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0) + // Call the implementation. + // out and outsize are 0 because we don't know the size yet. + let result := delegatecall(gas(), implementation, 0x00, calldatasize(), 0x00, 0x00) - // Copy the returned data. - returndatacopy(0, 0, returndatasize()) + // Copy the returned data. + returndatacopy(0x00, 0x00, returndatasize()) - switch result - // delegatecall returns 0 on error. - case 0 { - revert(0, returndatasize()) - } - default { - return(0, returndatasize()) - } - } + switch result + // delegatecall returns 0 on error. + case 0 { + revert(0x00, returndatasize()) + } + default { + return(0x00, returndatasize()) + } } + } - /** - * @dev This is a virtual function that should be overridden so it returns the address to which the fallback - * function and {_fallback} should delegate. - */ - function _implementation() internal view virtual returns (address); + /** + * @dev This is a virtual function that should be overridden so it returns the address to which the fallback + * function and {_fallback} should delegate. + */ + function _implementation() internal view virtual returns (address); - /** - * @dev Delegates the current call to the address returned by `_implementation()`. - * - * This function does not return to its internal call site, it will return directly to the external caller. - */ - function _fallback() internal virtual { - _delegate(_implementation()); - } + /** + * @dev Delegates the current call to the address returned by `_implementation()`. + * + * This function does not return to its internal call site, it will return directly to the external caller. + */ + function _fallback() internal virtual { + _delegate(_implementation()); + } - /** - * @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if no other - * function in the contract matches the call data. - */ - fallback() external payable virtual { - _fallback(); - } + /** + * @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if no other + * function in the contract matches the call data. + */ + fallback() external payable virtual { + _fallback(); + } } diff --git a/src/dependencies/openzeppelin/ReentrancyGuardTransient.sol b/src/dependencies/openzeppelin/ReentrancyGuardTransient.sol index 780993cce..ca9f0ebb2 100644 --- a/src/dependencies/openzeppelin/ReentrancyGuardTransient.sol +++ b/src/dependencies/openzeppelin/ReentrancyGuardTransient.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.3.0) (utils/ReentrancyGuardTransient.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (utils/ReentrancyGuardTransient.sol) pragma solidity ^0.8.24; @@ -11,6 +11,8 @@ import {TransientSlot} from './TransientSlot.sol'; * NOTE: This variant only works on networks where EIP-1153 is available. * * _Available since v5.1._ + * + * @custom:stateless */ abstract contract ReentrancyGuardTransient { using TransientSlot for *; @@ -37,18 +39,35 @@ abstract contract ReentrancyGuardTransient { _nonReentrantAfter(); } - function _nonReentrantBefore() private { - // On the first call to nonReentrant, REENTRANCY_GUARD_STORAGE.asBoolean().tload() will be false + /** + * @dev A `view` only version of {nonReentrant}. Use to block view functions + * from being called, preventing reading from inconsistent contract state. + * + * CAUTION: This is a "view" modifier and does not change the reentrancy + * status. Use it only on view functions. For payable or non-payable functions, + * use the standard {nonReentrant} modifier instead. + */ + modifier nonReentrantView() { + _nonReentrantBeforeView(); + _; + } + + function _nonReentrantBeforeView() private view { if (_reentrancyGuardEntered()) { revert ReentrancyGuardReentrantCall(); } + } + + function _nonReentrantBefore() private { + // On the first call to nonReentrant, REENTRANCY_GUARD_STORAGE.asBoolean().tload() will be false + _nonReentrantBeforeView(); // Any calls to nonReentrant after this point will fail - REENTRANCY_GUARD_STORAGE.asBoolean().tstore(true); + _reentrancyGuardStorageSlot().asBoolean().tstore(true); } function _nonReentrantAfter() private { - REENTRANCY_GUARD_STORAGE.asBoolean().tstore(false); + _reentrancyGuardStorageSlot().asBoolean().tstore(false); } /** @@ -56,6 +75,10 @@ abstract contract ReentrancyGuardTransient { * `nonReentrant` function in the call stack. */ function _reentrancyGuardEntered() internal view returns (bool) { - return REENTRANCY_GUARD_STORAGE.asBoolean().tload(); + return _reentrancyGuardStorageSlot().asBoolean().tload(); + } + + function _reentrancyGuardStorageSlot() internal pure virtual returns (bytes32) { + return REENTRANCY_GUARD_STORAGE; } } diff --git a/src/dependencies/openzeppelin/SafeERC20.sol b/src/dependencies/openzeppelin/SafeERC20.sol index 67b828828..876329846 100644 --- a/src/dependencies/openzeppelin/SafeERC20.sol +++ b/src/dependencies/openzeppelin/SafeERC20.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.3.0) (token/ERC20/utils/SafeERC20.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (token/ERC20/utils/SafeERC20.sol) pragma solidity ^0.8.20; @@ -35,7 +35,9 @@ library SafeERC20 { * non-reverting calls are assumed to be successful. */ function safeTransfer(IERC20 token, address to, uint256 value) internal { - _callOptionalReturn(token, abi.encodeCall(token.transfer, (to, value))); + if (!_safeTransfer(token, to, value, true)) { + revert SafeERC20FailedOperation(address(token)); + } } /** @@ -43,14 +45,16 @@ library SafeERC20 { * calling contract. If `token` returns no value, non-reverting calls are assumed to be successful. */ function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal { - _callOptionalReturn(token, abi.encodeCall(token.transferFrom, (from, to, value))); + if (!_safeTransferFrom(token, from, to, value, true)) { + revert SafeERC20FailedOperation(address(token)); + } } /** * @dev Variant of {safeTransfer} that returns a bool instead of reverting if the operation is not successful. */ function trySafeTransfer(IERC20 token, address to, uint256 value) internal returns (bool) { - return _callOptionalReturnBool(token, abi.encodeCall(token.transfer, (to, value))); + return _safeTransfer(token, to, value, false); } /** @@ -62,7 +66,7 @@ library SafeERC20 { address to, uint256 value ) internal returns (bool) { - return _callOptionalReturnBool(token, abi.encodeCall(token.transferFrom, (from, to, value))); + return _safeTransferFrom(token, from, to, value, false); } /** @@ -112,17 +116,16 @@ library SafeERC20 { * set here. */ function forceApprove(IERC20 token, address spender, uint256 value) internal { - bytes memory approvalCall = abi.encodeCall(token.approve, (spender, value)); - - if (!_callOptionalReturnBool(token, approvalCall)) { - _callOptionalReturn(token, abi.encodeCall(token.approve, (spender, 0))); - _callOptionalReturn(token, approvalCall); + if (!_safeApprove(token, spender, value, false)) { + if (!_safeApprove(token, spender, 0, true)) revert SafeERC20FailedOperation(address(token)); + if (!_safeApprove(token, spender, value, true)) + revert SafeERC20FailedOperation(address(token)); } } /** * @dev Performs an {ERC1363} transferAndCall, with a fallback to the simple {ERC20} transfer if the target has no - * code. This can be used to implement an {ERC721}-like safe transfer that rely on {ERC1363} checks when + * code. This can be used to implement an {ERC721}-like safe transfer that relies on {ERC1363} checks when * targeting contracts. * * Reverts if the returned value is other than `true`. @@ -142,7 +145,7 @@ library SafeERC20 { /** * @dev Performs an {ERC1363} transferFromAndCall, with a fallback to the simple {ERC20} transferFrom if the target - * has no code. This can be used to implement an {ERC721}-like safe transfer that rely on {ERC1363} checks when + * has no code. This can be used to implement an {ERC721}-like safe transfer that relies on {ERC1363} checks when * targeting contracts. * * Reverts if the returned value is other than `true`. @@ -167,7 +170,7 @@ library SafeERC20 { * targeting contracts. * * NOTE: When the recipient address (`to`) has no code (i.e. is an EOA), this function behaves as {forceApprove}. - * Opposedly, when the recipient address (`to`) has code, this function only attempts to call {ERC1363-approveAndCall} + * Oppositely, when the recipient address (`to`) has code, this function only attempts to call {ERC1363-approveAndCall} * once without retrying, and relies on the returned value to be true. * * Reverts if the returned value is other than `true`. @@ -186,50 +189,126 @@ library SafeERC20 { } /** - * @dev Imitates a Solidity high-level call (i.e. a regular function call to a contract), relaxing the requirement - * on the return value: the return value is optional (but if data is returned, it must not be false). - * @param token The token targeted by the call. - * @param data The call data (encoded using abi.encode or one of its variants). + * @dev Imitates a Solidity `token.transfer(to, value)` call, relaxing the requirement on the return value: the + * return value is optional (but if data is returned, it must not be false). * - * This is a variant of {_callOptionalReturnBool} that reverts if call fails to meet the requirements. + * @param token The token targeted by the call. + * @param to The recipient of the tokens + * @param value The amount of token to transfer + * @param bubble Behavior switch if the transfer call reverts: bubble the revert reason or return a false boolean. */ - function _callOptionalReturn(IERC20 token, bytes memory data) private { - uint256 returnSize; - uint256 returnValue; + function _safeTransfer( + IERC20 token, + address to, + uint256 value, + bool bubble + ) private returns (bool success) { + bytes4 selector = IERC20.transfer.selector; + assembly ('memory-safe') { - let success := call(gas(), token, 0, add(data, 0x20), mload(data), 0, 0x20) - // bubble errors - if iszero(success) { - let ptr := mload(0x40) - returndatacopy(ptr, 0, returndatasize()) - revert(ptr, returndatasize()) + let fmp := mload(0x40) + mstore(0x00, selector) + mstore(0x04, and(to, shr(96, not(0)))) + mstore(0x24, value) + success := call(gas(), token, 0, 0x00, 0x44, 0x00, 0x20) + // if call success and return is true, all is good. + // otherwise (not success or return is not true), we need to perform further checks + if iszero(and(success, eq(mload(0x00), 1))) { + // if the call was a failure and bubble is enabled, bubble the error + if and(iszero(success), bubble) { + returndatacopy(fmp, 0x00, returndatasize()) + revert(fmp, returndatasize()) + } + // if the return value is not true, then the call is only successful if: + // - the token address has code + // - the returndata is empty + success := and(success, and(iszero(returndatasize()), gt(extcodesize(token), 0))) } - returnSize := returndatasize() - returnValue := mload(0) + mstore(0x40, fmp) } + } - if (returnSize == 0 ? address(token).code.length == 0 : returnValue != 1) { - revert SafeERC20FailedOperation(address(token)); + /** + * @dev Imitates a Solidity `token.transferFrom(from, to, value)` call, relaxing the requirement on the return + * value: the return value is optional (but if data is returned, it must not be false). + * + * @param token The token targeted by the call. + * @param from The sender of the tokens + * @param to The recipient of the tokens + * @param value The amount of token to transfer + * @param bubble Behavior switch if the transfer call reverts: bubble the revert reason or return a false boolean. + */ + function _safeTransferFrom( + IERC20 token, + address from, + address to, + uint256 value, + bool bubble + ) private returns (bool success) { + bytes4 selector = IERC20.transferFrom.selector; + + assembly ('memory-safe') { + let fmp := mload(0x40) + mstore(0x00, selector) + mstore(0x04, and(from, shr(96, not(0)))) + mstore(0x24, and(to, shr(96, not(0)))) + mstore(0x44, value) + success := call(gas(), token, 0, 0x00, 0x64, 0x00, 0x20) + // if call success and return is true, all is good. + // otherwise (not success or return is not true), we need to perform further checks + if iszero(and(success, eq(mload(0x00), 1))) { + // if the call was a failure and bubble is enabled, bubble the error + if and(iszero(success), bubble) { + returndatacopy(fmp, 0x00, returndatasize()) + revert(fmp, returndatasize()) + } + // if the return value is not true, then the call is only successful if: + // - the token address has code + // - the returndata is empty + success := and(success, and(iszero(returndatasize()), gt(extcodesize(token), 0))) + } + mstore(0x40, fmp) + mstore(0x60, 0) } } /** - * @dev Imitates a Solidity high-level call (i.e. a regular function call to a contract), relaxing the requirement - * on the return value: the return value is optional (but if data is returned, it must not be false). - * @param token The token targeted by the call. - * @param data The call data (encoded using abi.encode or one of its variants). + * @dev Imitates a Solidity `token.approve(spender, value)` call, relaxing the requirement on the return value: + * the return value is optional (but if data is returned, it must not be false). * - * This is a variant of {_callOptionalReturn} that silently catches all reverts and returns a bool instead. + * @param token The token targeted by the call. + * @param spender The spender of the tokens + * @param value The amount of token to transfer + * @param bubble Behavior switch if the transfer call reverts: bubble the revert reason or return a false boolean. */ - function _callOptionalReturnBool(IERC20 token, bytes memory data) private returns (bool) { - bool success; - uint256 returnSize; - uint256 returnValue; + function _safeApprove( + IERC20 token, + address spender, + uint256 value, + bool bubble + ) private returns (bool success) { + bytes4 selector = IERC20.approve.selector; + assembly ('memory-safe') { - success := call(gas(), token, 0, add(data, 0x20), mload(data), 0, 0x20) - returnSize := returndatasize() - returnValue := mload(0) + let fmp := mload(0x40) + mstore(0x00, selector) + mstore(0x04, and(spender, shr(96, not(0)))) + mstore(0x24, value) + success := call(gas(), token, 0, 0x00, 0x44, 0x00, 0x20) + // if call success and return is true, all is good. + // otherwise (not success or return is not true), we need to perform further checks + if iszero(and(success, eq(mload(0x00), 1))) { + // if the call was a failure and bubble is enabled, bubble the error + if and(iszero(success), bubble) { + returndatacopy(fmp, 0x00, returndatasize()) + revert(fmp, returndatasize()) + } + // if the return value is not true, then the call is only successful if: + // - the token address has code + // - the returndata is empty + success := and(success, and(iszero(returndatasize()), gt(extcodesize(token), 0))) + } + mstore(0x40, fmp) } - return success && (returnSize == 0 ? address(token).code.length > 0 : returnValue == 1); } } diff --git a/src/dependencies/openzeppelin/SignatureChecker.sol b/src/dependencies/openzeppelin/SignatureChecker.sol index fb2c570c9..451635b3c 100644 --- a/src/dependencies/openzeppelin/SignatureChecker.sol +++ b/src/dependencies/openzeppelin/SignatureChecker.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.4.0) (utils/cryptography/SignatureChecker.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (utils/cryptography/SignatureChecker.sol) pragma solidity ^0.8.24; @@ -42,6 +42,22 @@ library SignatureChecker { } } + /** + * @dev Variant of {isValidSignatureNow} that takes a signature in calldata + */ + function isValidSignatureNowCalldata( + address signer, + bytes32 hash, + bytes calldata signature + ) internal view returns (bool) { + if (signer.code.length == 0) { + (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecoverCalldata(hash, signature); + return err == ECDSA.RecoverError.NoError && recovered == signer; + } else { + return isValidERC1271SignatureNow(signer, hash, signature); + } + } + /** * @dev Checks if a signature is valid for a given signer and data hash. The signature is validated * against the signer smart contract using ERC-1271. @@ -53,13 +69,26 @@ library SignatureChecker { address signer, bytes32 hash, bytes memory signature - ) internal view returns (bool) { - (bool success, bytes memory result) = signer.staticcall( - abi.encodeCall(IERC1271.isValidSignature, (hash, signature)) - ); - return (success && - result.length >= 32 && - abi.decode(result, (bytes32)) == bytes32(IERC1271.isValidSignature.selector)); + ) internal view returns (bool result) { + bytes4 selector = IERC1271.isValidSignature.selector; + uint256 length = signature.length; + + assembly ('memory-safe') { + // Encoded calldata is : + // [ 0x00 - 0x03 ] + // [ 0x04 - 0x23 ] + // [ 0x24 - 0x44 ] (0x40) + // [ 0x44 - 0x64 ] + // [ 0x64 - ... ] + let ptr := mload(0x40) + mstore(ptr, selector) + mstore(add(ptr, 0x04), hash) + mstore(add(ptr, 0x24), 0x40) + mcopy(add(ptr, 0x44), signature, add(length, 0x20)) + + let success := staticcall(gas(), signer, ptr, add(length, 0x64), 0x00, 0x20) + result := and(success, and(gt(returndatasize(), 0x1f), eq(mload(0x00), selector))) + } } /** diff --git a/src/dependencies/openzeppelin/SlotDerivation.sol b/src/dependencies/openzeppelin/SlotDerivation.sol index 015fc211c..9a73e664b 100644 --- a/src/dependencies/openzeppelin/SlotDerivation.sol +++ b/src/dependencies/openzeppelin/SlotDerivation.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.3.0) (utils/SlotDerivation.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (utils/SlotDerivation.sol) // This file was procedurally generated from scripts/generate/templates/SlotDerivation.js. pragma solidity ^0.8.20; @@ -16,7 +16,7 @@ pragma solidity ^0.8.20; * contract Example { * // Add the library methods * using StorageSlot for bytes32; - * using SlotDerivation for bytes32; + * using SlotDerivation for *; * * // Declare a namespace * string private constant _NAMESPACE = ""; // eg. OpenZeppelin.Slot diff --git a/src/dependencies/openzeppelin/Time.sol b/src/dependencies/openzeppelin/Time.sol index 7ef90f1e3..2c6a23c63 100644 --- a/src/dependencies/openzeppelin/Time.sol +++ b/src/dependencies/openzeppelin/Time.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.1.0) (utils/types/Time.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (utils/types/Time.sol) pragma solidity ^0.8.20; @@ -37,7 +37,7 @@ library Time { // ==================================================== Delay ===================================================== /** * @dev A `Delay` is a uint32 duration that can be programmed to change value automatically at a given point in the - * future. The "effect" timepoint describes when the transitions happens from the "old" value to the "new" value. + * future. The "effect" timepoint describes when the transition happens from the "old" value to the "new" value. * This allows updating the delay applied to some operation while keeping some guarantees. * * In particular, the {update} function guarantees that if the delay is reduced, the old delay still applies for diff --git a/src/dependencies/openzeppelin/TransparentUpgradeableProxy.sol b/src/dependencies/openzeppelin/TransparentUpgradeableProxy.sol index d0bc570ce..afc87dbc0 100644 --- a/src/dependencies/openzeppelin/TransparentUpgradeableProxy.sol +++ b/src/dependencies/openzeppelin/TransparentUpgradeableProxy.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.2.0) (proxy/transparent/TransparentUpgradeableProxy.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (proxy/transparent/TransparentUpgradeableProxy.sol) pragma solidity ^0.8.22; @@ -29,7 +29,7 @@ interface ITransparentUpgradeableProxy is IERC1967 { * * 1. If any account other than the admin calls the proxy, the call will be forwarded to the implementation, even if * that call matches the {ITransparentUpgradeableProxy-upgradeToAndCall} function exposed by the proxy itself. - * 2. If the admin calls the proxy, it can call the `upgradeToAndCall` function but any other call won't be forwarded to + * 2. If the admin calls the proxy, it can call the `upgradeToAndCall` function, but any other call won't be forwarded to * the implementation. If the admin tries to call a function on the implementation it will fail with an error indicating * the proxy admin cannot fallback to the target implementation. * diff --git a/src/dependencies/openzeppelin/draft-IERC6093.sol b/src/dependencies/openzeppelin/draft-IERC6093.sol new file mode 100644 index 000000000..dac5fa1e7 --- /dev/null +++ b/src/dependencies/openzeppelin/draft-IERC6093.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.5.0) (interfaces/draft-IERC6093.sol) + +pragma solidity >=0.8.4; + +/** + * @dev Standard ERC-20 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-20 tokens. + */ +interface IERC20Errors { + /** + * @dev Indicates an error related to the current `balance` of a `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param balance Current balance for the interacting account. + * @param needed Minimum amount required to perform a transfer. + */ + error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC20InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC20InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `spender`’s `allowance`. Used in transfers. + * @param spender Address that may be allowed to operate on tokens without being their owner. + * @param allowance Amount of tokens a `spender` is allowed to operate with. + * @param needed Minimum amount required to perform a transfer. + */ + error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC20InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `spender` to be approved. Used in approvals. + * @param spender Address that may be allowed to operate on tokens without being their owner. + */ + error ERC20InvalidSpender(address spender); +} + +/** + * @dev Standard ERC-721 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-721 tokens. + */ +interface IERC721Errors { + /** + * @dev Indicates that an address can't be an owner. For example, `address(0)` is a forbidden owner in ERC-721. + * Used in balance queries. + * @param owner Address of the current owner of a token. + */ + error ERC721InvalidOwner(address owner); + + /** + * @dev Indicates a `tokenId` whose `owner` is the zero address. + * @param tokenId Identifier number of a token. + */ + error ERC721NonexistentToken(uint256 tokenId); + + /** + * @dev Indicates an error related to the ownership over a particular token. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param tokenId Identifier number of a token. + * @param owner Address of the current owner of a token. + */ + error ERC721IncorrectOwner(address sender, uint256 tokenId, address owner); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC721InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC721InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `operator`’s approval. Used in transfers. + * @param operator Address that may be allowed to operate on tokens without being their owner. + * @param tokenId Identifier number of a token. + */ + error ERC721InsufficientApproval(address operator, uint256 tokenId); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC721InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `operator` to be approved. Used in approvals. + * @param operator Address that may be allowed to operate on tokens without being their owner. + */ + error ERC721InvalidOperator(address operator); +} + +/** + * @dev Standard ERC-1155 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-1155 tokens. + */ +interface IERC1155Errors { + /** + * @dev Indicates an error related to the current `balance` of a `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param balance Current balance for the interacting account. + * @param needed Minimum amount required to perform a transfer. + * @param tokenId Identifier number of a token. + */ + error ERC1155InsufficientBalance( + address sender, + uint256 balance, + uint256 needed, + uint256 tokenId + ); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC1155InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC1155InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `operator`’s approval. Used in transfers. + * @param operator Address that may be allowed to operate on tokens without being their owner. + * @param owner Address of the current owner of a token. + */ + error ERC1155MissingApprovalForAll(address operator, address owner); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC1155InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `operator` to be approved. Used in approvals. + * @param operator Address that may be allowed to operate on tokens without being their owner. + */ + error ERC1155InvalidOperator(address operator); + + /** + * @dev Indicates an array length mismatch between ids and values in a safeBatchTransferFrom operation. + * Used in batch transfers. + * @param idsLength Length of the array of token identifiers + * @param valuesLength Length of the array of token amounts + */ + error ERC1155InvalidArrayLength(uint256 idsLength, uint256 valuesLength); +} diff --git a/src/hub/AssetInterestRateStrategy.sol b/src/hub/AssetInterestRateStrategy.sol index a29d30c74..0e1e4b7bb 100644 --- a/src/hub/AssetInterestRateStrategy.sol +++ b/src/hub/AssetInterestRateStrategy.sol @@ -3,7 +3,10 @@ pragma solidity 0.8.28; import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; -import {IAssetInterestRateStrategy, IBasicInterestRateStrategy} from 'src/hub/interfaces/IAssetInterestRateStrategy.sol'; +import { + IAssetInterestRateStrategy, + IBasicInterestRateStrategy +} from 'src/hub/interfaces/IAssetInterestRateStrategy.sol'; /// @title AssetInterestRateStrategy /// @author Aave Labs diff --git a/src/hub/Hub.sol b/src/hub/Hub.sol index 08ab45046..b52d04ef6 100644 --- a/src/hub/Hub.sol +++ b/src/hub/Hub.sol @@ -52,8 +52,8 @@ contract Hub is IHub, AccessManaged { /// @dev Map of asset identifiers to set of spoke addresses. mapping(uint256 assetId => EnumerableSet.AddressSet) internal _assetToSpokes; - /// @dev Set of underlying addresses listed as assets in the Hub. - EnumerableSet.AddressSet internal _underlyingAssets; + /// @dev Map of underlying addresses to asset identifiers. + mapping(address underlying => uint256 assetId) internal _underlyingToAssetId; /// @dev Constructor. /// @dev The authority contract must implement the `AccessManaged` interface for access control. @@ -78,9 +78,11 @@ contract Hub is IHub, AccessManaged { MIN_ALLOWED_UNDERLYING_DECIMALS <= decimals && decimals <= MAX_ALLOWED_UNDERLYING_DECIMALS, InvalidAssetDecimals() ); - require(!_underlyingAssets.contains(underlying), UnderlyingAlreadyListed()); + require(!isUnderlyingListed(underlying), UnderlyingAlreadyListed()); uint256 assetId = _assetCount++; + _underlyingToAssetId[underlying] = assetId; + IBasicInterestRateStrategy(irStrategy).setInterestRateData(assetId, irData); uint256 drawnRate = IBasicInterestRateStrategy(irStrategy).calculateInterestRate({ assetId: assetId, @@ -111,7 +113,6 @@ contract Hub is IHub, AccessManaged { feeReceiver: feeReceiver, liquidityFee: 0 }); - _underlyingAssets.add(underlying); _addFeeReceiver(assetId, feeReceiver); emit AddAsset(assetId, underlying, decimals); @@ -146,26 +147,26 @@ contract Hub is IHub, AccessManaged { InvalidReinvestmentController() ); - if (config.irStrategy != asset.irStrategy) { - asset.irStrategy = config.irStrategy; - IBasicInterestRateStrategy(config.irStrategy).setInterestRateData(assetId, irData); - } else { - require(irData.length == 0, InvalidInterestRateStrategy()); - } + asset.liquidityFee = config.liquidityFee; + asset.reinvestmentController = config.reinvestmentController; address oldFeeReceiver = asset.feeReceiver; if (oldFeeReceiver != config.feeReceiver) { _mintFeeShares(asset, assetId); IHub.SpokeConfig memory spokeConfig; spokeConfig.active = _spokes[assetId][oldFeeReceiver].active; - spokeConfig.paused = _spokes[assetId][oldFeeReceiver].paused; + spokeConfig.halted = _spokes[assetId][oldFeeReceiver].halted; _updateSpokeConfig(assetId, oldFeeReceiver, spokeConfig); asset.feeReceiver = config.feeReceiver; _addFeeReceiver(assetId, config.feeReceiver); } - asset.liquidityFee = config.liquidityFee; - asset.reinvestmentController = config.reinvestmentController; + if (config.irStrategy != asset.irStrategy) { + asset.irStrategy = config.irStrategy; + IBasicInterestRateStrategy(config.irStrategy).setInterestRateData(assetId, irData); + } else { + require(irData.length == 0, InvalidInterestRateStrategy()); + } asset.updateDrawnRate(assetId); @@ -206,6 +207,7 @@ contract Hub is IHub, AccessManaged { /// @inheritdoc IHub function mintFeeShares(uint256 assetId) external restricted returns (uint256) { + require(assetId < _assetCount, AssetNotListed()); Asset storage asset = _assets[assetId]; asset.accrue(); uint256 feeShares = _mintFeeShares(asset, assetId); @@ -334,8 +336,7 @@ contract Hub is IHub, AccessManaged { spoke.drawnShares -= drawnShares; _applyPremiumDelta(asset, spoke, premiumDelta); - uint256 deficitAmountRay = uint256(drawnShares) * - asset.drawnIndex + + uint256 deficitAmountRay = uint256(drawnShares) * asset.drawnIndex + premiumDelta.restoredPremiumRay; asset.deficitRay += deficitAmountRay.toUint200(); spoke.deficitRay += deficitAmountRay.toUint200(); @@ -352,22 +353,21 @@ contract Hub is IHub, AccessManaged { uint256 assetId, uint256 amount, address spoke - ) external returns (uint256) { + ) external restricted returns (uint256) { Asset storage asset = _assets[assetId]; SpokeData storage callerSpoke = _spokes[assetId][msg.sender]; SpokeData storage coveredSpoke = _spokes[assetId][spoke]; asset.accrue(); - _validateEliminateDeficit(callerSpoke, amount); - uint256 deficitRay = coveredSpoke.deficitRay; uint256 deficitAmountRay = (amount < deficitRay.fromRayUp()) ? amount.toRay() : deficitRay; + _validateEliminateDeficit(callerSpoke, deficitAmountRay); uint120 shares = asset.toAddedSharesUp(deficitAmountRay.fromRayUp()).toUint120(); asset.addedShares -= shares; callerSpoke.addedShares -= shares; - asset.deficitRay = asset.deficitRay.uncheckedSub(deficitAmountRay).toUint200(); - coveredSpoke.deficitRay = deficitRay.uncheckedSub(deficitAmountRay).toUint200(); + asset.deficitRay -= deficitAmountRay.toUint200(); + coveredSpoke.deficitRay -= deficitAmountRay.toUint200(); asset.updateDrawnRate(assetId); @@ -433,6 +433,7 @@ contract Hub is IHub, AccessManaged { asset.liquidity = liquidity.uncheckedSub(amount).toUint120(); asset.swept += amount.toUint120(); + asset.updateDrawnRate(assetId); IERC20(asset.underlying).safeTransfer(msg.sender, amount); @@ -448,18 +449,26 @@ contract Hub is IHub, AccessManaged { asset.accrue(); _validateReclaim(asset, msg.sender, amount); - asset.liquidity += amount.toUint120(); + uint256 liquidity = asset.liquidity + amount; + uint256 balance = IERC20(asset.underlying).balanceOf(address(this)); + require(balance >= liquidity, InsufficientTransferred(liquidity.uncheckedSub(balance))); + asset.liquidity = liquidity.toUint120(); asset.swept -= amount.toUint120(); - asset.updateDrawnRate(assetId); - IERC20(asset.underlying).safeTransferFrom(msg.sender, address(this), amount); + asset.updateDrawnRate(assetId); emit Reclaim(assetId, msg.sender, amount); } /// @inheritdoc IHub - function isUnderlyingListed(address underlying) external view returns (bool) { - return _underlyingAssets.contains(underlying); + function isUnderlyingListed(address underlying) public view returns (bool) { + return _assets[_underlyingToAssetId[underlying]].underlying == underlying; + } + + /// @inheritdoc IHub + function getAssetId(address underlying) external view returns (uint256) { + require(isUnderlyingListed(underlying), AssetNotListed()); + return _underlyingToAssetId[underlying]; } /// @inheritdoc IHub @@ -603,7 +612,8 @@ contract Hub is IHub, AccessManaged { /// @inheritdoc IHub function getAssetDrawnRate(uint256 assetId) external view returns (uint256) { - return _assets[assetId].drawnRate; + Asset storage asset = _assets[assetId]; + return asset.getDrawnRate(assetId, asset.getDrawnIndex()); } /// @inheritdoc IHub @@ -688,7 +698,7 @@ contract Hub is IHub, AccessManaged { drawCap: spokeData.drawCap, riskPremiumThreshold: spokeData.riskPremiumThreshold, active: spokeData.active, - paused: spokeData.paused + halted: spokeData.halted }); } @@ -703,7 +713,7 @@ contract Hub is IHub, AccessManaged { drawCap: 0, riskPremiumThreshold: 0, active: true, - paused: false + halted: false }) ); } @@ -721,7 +731,7 @@ contract Hub is IHub, AccessManaged { spokeData.drawCap = config.drawCap; spokeData.riskPremiumThreshold = config.riskPremiumThreshold; spokeData.active = config.active; - spokeData.paused = config.paused; + spokeData.halted = config.halted; emit UpdateSpokeConfig(assetId, spoke, config); } @@ -826,12 +836,12 @@ contract Hub is IHub, AccessManaged { ) internal view { require(amount > 0, InvalidAmount()); require(spoke.active, SpokeNotActive()); - require(!spoke.paused, SpokePaused()); + require(!spoke.halted, SpokeHalted()); uint256 addCap = spoke.addCap; require( addCap == MAX_ALLOWED_SPOKE_CAP || addCap * MathUtils.uncheckedExp(10, asset.decimals) >= - asset.toAddedAssetsUp(spoke.addedShares) + amount, + asset.toAddedAssetsUp(spoke.addedShares) + amount, AddCapExceeded(addCap) ); } @@ -840,9 +850,10 @@ contract Hub is IHub, AccessManaged { require(to != address(this), InvalidAddress()); require(amount > 0, InvalidAmount()); require(spoke.active, SpokeNotActive()); - require(!spoke.paused, SpokePaused()); + require(!spoke.halted, SpokeHalted()); } + /// @dev The draw cap is enforced against the spoke's total owed, including any reported deficit. /// @dev Spoke with maximum cap have unlimited draw capacity. function _validateDraw( Asset storage asset, @@ -853,13 +864,13 @@ contract Hub is IHub, AccessManaged { require(to != address(this), InvalidAddress()); require(amount > 0, InvalidAmount()); require(spoke.active, SpokeNotActive()); - require(!spoke.paused, SpokePaused()); + require(!spoke.halted, SpokeHalted()); uint256 drawCap = spoke.drawCap; uint256 owed = _getSpokeDrawn(asset, spoke) + _getSpokePremium(asset, spoke); require( drawCap == MAX_ALLOWED_SPOKE_CAP || drawCap * MathUtils.uncheckedExp(10, asset.decimals) >= - owed + amount + uint256(spoke.deficitRay).fromRayUp(), + owed + amount + uint256(spoke.deficitRay).fromRayUp(), DrawCapExceeded(drawCap) ); } @@ -872,7 +883,7 @@ contract Hub is IHub, AccessManaged { ) internal view { require(drawnAmount > 0 || premiumAmountRay > 0, InvalidAmount()); require(spoke.active, SpokeNotActive()); - require(!spoke.paused, SpokePaused()); + require(!spoke.halted, SpokeHalted()); uint256 drawn = _getSpokeDrawn(asset, spoke); uint256 premiumRay = _getSpokePremiumRay(asset, spoke); require(drawnAmount <= drawn, SurplusDrawnRestored(drawn)); @@ -885,23 +896,24 @@ contract Hub is IHub, AccessManaged { uint256 drawnAmount, uint256 premiumAmountRay ) internal view { - require(spoke.active, SpokeNotActive()); - require(!spoke.paused, SpokePaused()); require(drawnAmount > 0 || premiumAmountRay > 0, InvalidAmount()); + require(spoke.active, SpokeNotActive()); uint256 drawn = _getSpokeDrawn(asset, spoke); uint256 premiumRay = _getSpokePremiumRay(asset, spoke); require(drawnAmount <= drawn, SurplusDrawnDeficitReported(drawn)); require(premiumAmountRay <= premiumRay, SurplusPremiumRayDeficitReported(premiumRay)); } - function _validateEliminateDeficit(SpokeData storage spoke, uint256 amount) internal view { - require(spoke.active, SpokeNotActive()); - require(amount > 0, InvalidAmount()); + function _validateEliminateDeficit( + SpokeData storage callerSpoke, + uint256 deficitAmountRay + ) internal view { + require(callerSpoke.active, SpokeNotActive()); + require(deficitAmountRay > 0, InvalidAmount()); } - function _validatePayFeeShares(SpokeData storage senderSpoke, uint256 feeShares) internal view { - require(senderSpoke.active, SpokeNotActive()); - require(!senderSpoke.paused, SpokePaused()); + function _validatePayFeeShares(SpokeData storage spoke, uint256 feeShares) internal view { + require(spoke.active, SpokeNotActive()); require(feeShares > 0, InvalidShares()); } @@ -912,13 +924,13 @@ contract Hub is IHub, AccessManaged { uint256 shares ) internal view { require(sender.active && receiver.active, SpokeNotActive()); - require(!sender.paused && !receiver.paused, SpokePaused()); + require(!sender.halted && !receiver.halted, SpokeHalted()); require(shares > 0, InvalidShares()); uint256 addCap = receiver.addCap; require( addCap == MAX_ALLOWED_SPOKE_CAP || addCap * MathUtils.uncheckedExp(10, asset.decimals) >= - asset.toAddedAssetsUp(receiver.addedShares + shares), + asset.toAddedAssetsUp(receiver.addedShares + shares), AddCapExceeded(addCap) ); } diff --git a/src/hub/HubConfigurator.sol b/src/hub/HubConfigurator.sol index e0db02715..dfad5dfe0 100644 --- a/src/hub/HubConfigurator.sol +++ b/src/hub/HubConfigurator.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.28; import {IERC20Metadata} from 'src/dependencies/openzeppelin/IERC20Metadata.sol'; -import {Ownable2Step, Ownable} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; +import {AccessManaged} from 'src/dependencies/openzeppelin/AccessManaged.sol'; import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; import {IHub} from 'src/hub/interfaces/IHub.sol'; import {IHubConfigurator} from 'src/hub/interfaces/IHubConfigurator.sol'; @@ -12,12 +12,12 @@ import {IHubConfigurator} from 'src/hub/interfaces/IHubConfigurator.sol'; /// @author Aave Labs /// @notice Handles administrative functions on the Hub. /// @dev Must be granted permission by the Hub. -contract HubConfigurator is Ownable2Step, IHubConfigurator { +contract HubConfigurator is AccessManaged, IHubConfigurator { using SafeCast for uint256; /// @dev Constructor. - /// @param owner_ The address of the owner. - constructor(address owner_) Ownable(owner_) {} + /// @param authority_ The address of the authority contract which manages permissions. + constructor(address authority_) AccessManaged(authority_) {} /// @inheritdoc IHubConfigurator function addAsset( @@ -27,7 +27,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { uint256 liquidityFee, address irStrategy, bytes calldata irData - ) external onlyOwner returns (uint256) { + ) external restricted returns (uint256) { IHub targetHub = IHub(hub); uint256 assetId = targetHub.addAsset( underlying, @@ -41,7 +41,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { } /// @inheritdoc IHubConfigurator - function addAsset( + function addAssetWithDecimals( address hub, address underlying, uint8 decimals, @@ -49,7 +49,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { uint256 liquidityFee, address irStrategy, bytes calldata irData - ) external onlyOwner returns (uint256) { + ) external restricted returns (uint256) { IHub targetHub = IHub(hub); uint256 assetId = targetHub.addAsset(underlying, decimals, feeReceiver, irStrategy, irData); _updateLiquidityFee(targetHub, assetId, liquidityFee); @@ -61,12 +61,16 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { address hub, uint256 assetId, uint256 liquidityFee - ) external onlyOwner { + ) external restricted { _updateLiquidityFee(IHub(hub), assetId, liquidityFee); } /// @inheritdoc IHubConfigurator - function updateFeeReceiver(address hub, uint256 assetId, address feeReceiver) external onlyOwner { + function updateFeeReceiver( + address hub, + uint256 assetId, + address feeReceiver + ) external restricted { IHub targetHub = IHub(hub); IHub.AssetConfig memory config = targetHub.getAssetConfig(assetId); config.feeReceiver = feeReceiver; @@ -79,7 +83,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { uint256 assetId, uint256 liquidityFee, address feeReceiver - ) external onlyOwner { + ) external restricted { IHub targetHub = IHub(hub); IHub.AssetConfig memory config = targetHub.getAssetConfig(assetId); config.liquidityFee = liquidityFee.toUint16(); @@ -93,7 +97,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { uint256 assetId, address irStrategy, bytes calldata irData - ) external onlyOwner { + ) external restricted { IHub targetHub = IHub(hub); IHub.AssetConfig memory config = targetHub.getAssetConfig(assetId); config.irStrategy = irStrategy; @@ -105,7 +109,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { address hub, uint256 assetId, address reinvestmentController - ) external onlyOwner { + ) external restricted { IHub targetHub = IHub(hub); IHub.AssetConfig memory config = targetHub.getAssetConfig(assetId); config.reinvestmentController = reinvestmentController; @@ -113,7 +117,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { } /// @inheritdoc IHubConfigurator - function freezeAsset(address hub, uint256 assetId) external onlyOwner { + function resetAssetCaps(address hub, uint256 assetId) external restricted { IHub targetHub = IHub(hub); uint256 spokesCount = targetHub.getSpokeCount(assetId); @@ -127,7 +131,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { } /// @inheritdoc IHubConfigurator - function deactivateAsset(address hub, uint256 assetId) external onlyOwner { + function deactivateAsset(address hub, uint256 assetId) external restricted { IHub targetHub = IHub(hub); uint256 spokesCount = targetHub.getSpokeCount(assetId); for (uint256 i = 0; i < spokesCount; ++i) { @@ -139,13 +143,13 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { } /// @inheritdoc IHubConfigurator - function pauseAsset(address hub, uint256 assetId) external onlyOwner { + function haltAsset(address hub, uint256 assetId) external restricted { IHub targetHub = IHub(hub); uint256 spokesCount = targetHub.getSpokeCount(assetId); for (uint256 i = 0; i < spokesCount; ++i) { address spoke = targetHub.getSpokeAddress(assetId, i); IHub.SpokeConfig memory config = targetHub.getSpokeConfig(assetId, spoke); - config.paused = true; + config.halted = true; targetHub.updateSpokeConfig(assetId, spoke, config); } } @@ -156,7 +160,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { address spoke, uint256 assetId, IHub.SpokeConfig calldata config - ) external onlyOwner { + ) external restricted { IHub(hub).addSpoke(assetId, spoke, config); } @@ -166,7 +170,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { address spoke, uint256[] calldata assetIds, IHub.SpokeConfig[] calldata configs - ) external onlyOwner { + ) external restricted { uint256 assetCount = assetIds.length; require(assetCount == configs.length, MismatchedConfigs()); for (uint256 i = 0; i < assetCount; ++i) { @@ -180,7 +184,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { uint256 assetId, address spoke, bool active - ) external onlyOwner { + ) external restricted { IHub targetHub = IHub(hub); IHub.SpokeConfig memory config = targetHub.getSpokeConfig(assetId, spoke); config.active = active; @@ -188,15 +192,15 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { } /// @inheritdoc IHubConfigurator - function updateSpokePaused( + function updateSpokeHalted( address hub, uint256 assetId, address spoke, - bool paused - ) external onlyOwner { + bool halted + ) external restricted { IHub targetHub = IHub(hub); IHub.SpokeConfig memory config = targetHub.getSpokeConfig(assetId, spoke); - config.paused = paused; + config.halted = halted; targetHub.updateSpokeConfig(assetId, spoke, config); } @@ -206,7 +210,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { uint256 assetId, address spoke, uint256 addCap - ) external onlyOwner { + ) external restricted { IHub targetHub = IHub(hub); IHub.SpokeConfig memory config = targetHub.getSpokeConfig(assetId, spoke); config.addCap = addCap.toUint40(); @@ -219,7 +223,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { uint256 assetId, address spoke, uint256 drawCap - ) external onlyOwner { + ) external restricted { IHub targetHub = IHub(hub); IHub.SpokeConfig memory config = targetHub.getSpokeConfig(assetId, spoke); config.drawCap = drawCap.toUint40(); @@ -232,7 +236,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { uint256 assetId, address spoke, uint256 riskPremiumThreshold - ) external onlyOwner { + ) external restricted { IHub targetHub = IHub(hub); IHub.SpokeConfig memory config = targetHub.getSpokeConfig(assetId, spoke); config.riskPremiumThreshold = riskPremiumThreshold.toUint24(); @@ -246,12 +250,12 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { address spoke, uint256 addCap, uint256 drawCap - ) external onlyOwner { + ) external restricted { _updateSpokeCaps(IHub(hub), assetId, spoke, addCap, drawCap); } /// @inheritdoc IHubConfigurator - function deactivateSpoke(address hub, address spoke) external onlyOwner { + function deactivateSpoke(address hub, address spoke) external restricted { IHub targetHub = IHub(hub); uint256 assetCount = targetHub.getAssetCount(); for (uint256 assetId = 0; assetId < assetCount; ++assetId) { @@ -264,20 +268,20 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { } /// @inheritdoc IHubConfigurator - function pauseSpoke(address hub, address spoke) external onlyOwner { + function haltSpoke(address hub, address spoke) external restricted { IHub targetHub = IHub(hub); uint256 assetCount = targetHub.getAssetCount(); for (uint256 assetId = 0; assetId < assetCount; ++assetId) { if (targetHub.isSpokeListed(assetId, spoke)) { IHub.SpokeConfig memory config = targetHub.getSpokeConfig(assetId, spoke); - config.paused = true; + config.halted = true; targetHub.updateSpokeConfig(assetId, spoke, config); } } } /// @inheritdoc IHubConfigurator - function freezeSpoke(address hub, address spoke) external onlyOwner { + function resetSpokeCaps(address hub, address spoke) external restricted { IHub targetHub = IHub(hub); uint256 assetCount = targetHub.getAssetCount(); for (uint256 assetId = 0; assetId < assetCount; ++assetId) { @@ -295,7 +299,7 @@ contract HubConfigurator is Ownable2Step, IHubConfigurator { address hub, uint256 assetId, bytes calldata irData - ) external onlyOwner { + ) external restricted { IHub(hub).setInterestRateData(assetId, irData); } diff --git a/src/hub/interfaces/IHub.sol b/src/hub/interfaces/IHub.sol index b6fd46d2a..cd02a4538 100644 --- a/src/hub/interfaces/IHub.sol +++ b/src/hub/interfaces/IHub.sol @@ -20,7 +20,7 @@ interface IHub is IHubBase, IAccessManaged { /// @dev premiumShares The total premium shares across all spokes. /// @dev liquidityFee The protocol fee charged on drawn and premium liquidity growth, expressed in BPS. /// @dev drawnIndex The drawn index which monotonically increases according to the drawn rate, expressed in RAY. - /// @dev drawnRate The rate at which drawn assets grows, expressed in RAY. + /// @dev drawnRate The rate at which drawn assets grow, expressed in RAY. /// @dev lastUpdateTimestamp The timestamp of the last accrual. /// @dev underlying The address of the underlying asset. /// @dev irStrategy The address of the interest rate strategy. @@ -72,8 +72,8 @@ interface IHub is IHubBase, IAccessManaged { /// @dev addCap The maximum amount that can be added by a spoke, expressed in whole assets (not scaled by decimals). A value of `MAX_ALLOWED_SPOKE_CAP` indicates no cap. /// @dev drawCap The maximum amount that can be drawn by a spoke, expressed in whole assets (not scaled by decimals). A value of `MAX_ALLOWED_SPOKE_CAP` indicates no cap. /// @dev riskPremiumThreshold The maximum ratio of premium to drawn shares a spoke can have, expressed in BPS. A value of `MAX_RISK_PREMIUM_THRESHOLD` indicates no threshold. - /// @dev active True if the spoke is prevented from performing any actions. - /// @dev paused True if the spoke is prevented from performing actions that instantly update the liquidity. + /// @dev active False if the spoke is prevented from performing any actions. + /// @dev halted True if the spoke is prevented from performing any user-facing actions. /// @dev deficitRay The deficit reported by a spoke for a given asset, expressed in asset units and scaled by RAY. struct SpokeData { uint120 drawnShares; @@ -86,7 +86,7 @@ interface IHub is IHubBase, IAccessManaged { uint40 drawCap; uint24 riskPremiumThreshold; bool active; - bool paused; + bool halted; // uint200 deficitRay; } @@ -97,7 +97,7 @@ interface IHub is IHubBase, IAccessManaged { uint40 drawCap; uint24 riskPremiumThreshold; bool active; - bool paused; + bool halted; } /// @notice Emitted when an asset is added. @@ -146,19 +146,19 @@ interface IHub is IHubBase, IAccessManaged { uint256 assets ); - /// @notice Emitted when an amount of liquidity is invested by the reinvestment controller. + /// @notice Emitted when liquidity is invested by the reinvestment controller. /// @param assetId The identifier of the asset. /// @param reinvestmentController The active asset controller. /// @param amount The amount invested. event Sweep(uint256 indexed assetId, address indexed reinvestmentController, uint256 amount); - /// @notice Emitted when an amount of liquidity is reclaimed (from swept liquidity) by the reinvestment controller. + /// @notice Emitted when liquidity is reclaimed (from swept liquidity) by the reinvestment controller. /// @param assetId The identifier of the asset. /// @param reinvestmentController The active asset controller. /// @param amount The amount reclaimed. event Reclaim(uint256 indexed assetId, address indexed reinvestmentController, uint256 amount); - /// @notice Emitted when deficit is eliminated. + /// @notice Emitted when a deficit is eliminated. /// @param assetId The identifier of the asset. /// @param callerSpoke The spoke that eliminated the deficit using its supplied shares. /// @param coveredSpoke The spoke for which the deficit was eliminated. @@ -216,8 +216,8 @@ interface IHub is IHubBase, IAccessManaged { /// @notice Thrown when a spoke is not active. error SpokeNotActive(); - /// @notice Thrown when a spoke is paused. - error SpokePaused(); + /// @notice Thrown when a spoke is halted. + error SpokeHalted(); /// @notice Thrown when a new reinvestment controller is the zero address and the asset has existing swept liquidity. error InvalidReinvestmentController(); @@ -305,7 +305,7 @@ interface IHub is IHubBase, IAccessManaged { function mintFeeShares(uint256 assetId) external returns (uint256); /// @notice Eliminates deficit by removing supplied shares of caller spoke. - /// @dev Only callable by active spokes. + /// @dev Only callable by active and authorized spokes. /// @param assetId The identifier of the asset. /// @param amount The amount of deficit to eliminate. /// @param spoke The spoke for which the deficit is eliminated. @@ -331,6 +331,8 @@ interface IHub is IHubBase, IAccessManaged { /// @notice Reclaims an amount of liquidity of the corresponding asset from the configured reinvestment controller. /// @dev The controller can only reclaim up to swept amount. All accrued interest is distributed offchain. + /// @dev Underlying assets must be transferred to the Hub before invocation. + /// @dev Extra underlying liquidity retained in the Hub can be skimmed by the investment controller through this action. /// @param assetId The identifier of the asset. /// @param amount The amount to reclaim. function reclaim(uint256 assetId, uint256 amount) external; @@ -340,6 +342,12 @@ interface IHub is IHubBase, IAccessManaged { /// @return True if the underlying asset is listed. function isUnderlyingListed(address underlying) external view returns (bool); + /// @notice Returns the asset identifier for the specified underlying asset. + /// @dev Reverts with `AssetNotListed` if the underlying is not listed. + /// @param underlying The address of the underlying asset. + /// @return The `assetId` of the underlying asset. + function getAssetId(address underlying) external view returns (uint256); + /// @notice Returns the number of listed assets. /// @return The number of listed assets. function getAssetCount() external view returns (uint256); @@ -366,7 +374,7 @@ interface IHub is IHubBase, IAccessManaged { /// @return The amount of liquidity swept. function getAssetSwept(uint256 assetId) external view returns (uint256); - /// @notice Returns the current drawn rate for the specified asset. + /// @notice Calculates the current drawn rate for the specified asset. /// @param assetId The identifier of the asset. /// @return The current drawn rate of the asset. function getAssetDrawnRate(uint256 assetId) external view returns (uint256); diff --git a/src/hub/interfaces/IHubConfigurator.sol b/src/hub/interfaces/IHubConfigurator.sol index de2673081..eae776d64 100644 --- a/src/hub/interfaces/IHubConfigurator.sol +++ b/src/hub/interfaces/IHubConfigurator.sol @@ -11,10 +11,10 @@ interface IHubConfigurator { /// @notice Thrown when the list of assets and spoke configs are not the same length in `addSpokeToAssets`. error MismatchedConfigs(); - /// @notice Adds a new asset to the Hub. + /// @notice Adds a new asset to a specified hub. /// @dev Retrieves the decimals of the underlying asset from its ERC20 contract. /// @dev The fee receiver is automatically added as a spoke with maximum caps. - /// @param hub The address of the Hub contract. + /// @param hub The address of the Hub. /// @param underlying The address of the underlying asset. /// @param feeReceiver The address of the fee receiver spoke. /// @param liquidityFee The liquidity fee of the asset, in BPS. @@ -30,10 +30,9 @@ interface IHubConfigurator { bytes calldata irData ) external returns (uint256); - /// @notice Adds a new asset to the Hub. - /// @dev Retrieves the decimals of the underlying asset from its ERC20 contract. + /// @notice Adds a new asset to a specified hub with explicit decimals. /// @dev The fee receiver is automatically added as a spoke with maximum caps. - /// @param hub The address of the Hub contract. + /// @param hub The address of the Hub. /// @param underlying The address of the underlying asset. /// @param decimals The number of decimals of the asset. /// @param feeReceiver The address of the fee receiver spoke. @@ -41,7 +40,7 @@ interface IHubConfigurator { /// @param irStrategy The address of the interest rate strategy contract. /// @param irData The interest rate data to apply to the given asset, encoded in bytes. /// @return The unique identifier of the added asset. - function addAsset( + function addAssetWithDecimals( address hub, address underlying, uint8 decimals, @@ -51,22 +50,22 @@ interface IHubConfigurator { bytes calldata irData ) external returns (uint256); - /// @notice Updates the liquidity fee of an asset. - /// @param hub The address of the Hub contract. + /// @notice Updates the liquidity fee of an asset on a specified hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. /// @param liquidityFee The new liquidity fee. function updateLiquidityFee(address hub, uint256 assetId, uint256 liquidityFee) external; - /// @notice Updates the fee receiver of an asset. + /// @notice Updates the fee receiver of an asset on a specified hub. /// @dev The fee receiver cannot be zero. - /// @param hub The address of the Hub contract. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. /// @param feeReceiver The new fee receiver. function updateFeeReceiver(address hub, uint256 assetId, address feeReceiver) external; - /// @notice Updates the liquidity fee and fee receiver of an asset. + /// @notice Updates the liquidity fee and fee receiver of an asset on a specified hub. /// @dev The fee receiver cannot be zero. - /// @param hub The address of the Hub contract. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. /// @param liquidityFee The new liquidity fee. /// @param feeReceiver The new fee receiver. @@ -77,8 +76,8 @@ interface IHubConfigurator { address feeReceiver ) external; - /// @notice Updates the interest rate strategy of an asset. - /// @param hub The address of the Hub contract. + /// @notice Updates the interest rate strategy of an asset on a specified hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. /// @param irStrategy The new interest rate strategy. /// @param irData The interest rate data to apply to the given asset, encoded in bytes. @@ -89,8 +88,8 @@ interface IHubConfigurator { bytes calldata irData ) external; - /// @notice Updates the reinvestment controller of an asset. - /// @param hub The address of the Hub contract. + /// @notice Updates the reinvestment controller of an asset on a specified hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. /// @param reinvestmentController The new reinvestment controller. function updateReinvestmentController( @@ -99,25 +98,25 @@ interface IHubConfigurator { address reinvestmentController ) external; - /// @notice Freezes an asset. - /// @param hub The address of the Hub contract. + /// @notice Resets all spokes' add and draw caps to zero for an asset on a specified hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. - function freezeAsset(address hub, uint256 assetId) external; + function resetAssetCaps(address hub, uint256 assetId) external; - /// @notice Deactivates an asset. - /// @param hub The address of the Hub contract. + /// @notice Deactivates an asset on a specified hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. function deactivateAsset(address hub, uint256 assetId) external; - /// @notice Pauses an asset. - /// @param hub The address of the Hub contract. + /// @notice Halts an asset on a specified hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. - function pauseAsset(address hub, uint256 assetId) external; + function haltAsset(address hub, uint256 assetId) external; - /// @notice Register the spoke for the specified asset in the Hub. - /// @param hub The address of the Hub contract. + /// @notice Register the spoke for the specified asset on a hub. + /// @param hub The address of the Hub. + /// @param spoke The address of the Spoke. /// @param assetId The identifier of the asset to register the spoke for. - /// @param spoke The address of the Spoke contract. /// @param config The Spoke configuration to register. function addSpoke( address hub, @@ -126,10 +125,10 @@ interface IHubConfigurator { IHub.SpokeConfig calldata config ) external; - /// @notice Registers the same spoke for multiple assets with the Hub, each with their own configuration. + /// @notice Registers the same spoke for multiple assets on a specified hub, each with their own configuration. /// @dev The i-th asset identifier in `assetIds` corresponds to the i-th configuration in `configs`. - /// @param hub The address of the Hub contract. - /// @param spoke The address of the Spoke contract. + /// @param hub The address of the Hub. + /// @param spoke The address of the Spoke. /// @param assetIds The list of asset identifiers to register the spoke for. /// @param configs The list of Spoke configurations to register. function addSpokeToAssets( @@ -139,24 +138,24 @@ interface IHubConfigurator { IHub.SpokeConfig[] calldata configs ) external; - /// @notice Updates the active flag of an asset's spoke. - /// @param hub The address of the Hub contract. + /// @notice Updates the active flag of an asset's spoke on a specified hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param active The new active flag. function updateSpokeActive(address hub, uint256 assetId, address spoke, bool active) external; - /// @notice Updates the paused flag of an asset's spoke. - /// @param hub The address of the Hub contract. + /// @notice Updates the halted flag of an asset's spoke on a specified hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. - /// @param spoke The address of the spoke. - /// @param paused The new paused flag. - function updateSpokePaused(address hub, uint256 assetId, address spoke, bool paused) external; + /// @param spoke The address of the Spoke. + /// @param halted The new halted flag. + function updateSpokeHalted(address hub, uint256 assetId, address spoke, bool halted) external; - /// @notice Updates the supply cap of an asset's spoke. - /// @param hub The address of the Hub contract. + /// @notice Updates the supply cap of an asset's spoke on a specified hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param addCap The new supply cap. function updateSpokeSupplyCap( address hub, @@ -165,10 +164,10 @@ interface IHubConfigurator { uint256 addCap ) external; - /// @notice Updates the draw cap of an asset's spoke. - /// @param hub The address of the Hub contract. + /// @notice Updates the draw cap of an asset's spoke on a specified hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param drawCap The new draw cap. function updateSpokeDrawCap( address hub, @@ -177,10 +176,10 @@ interface IHubConfigurator { uint256 drawCap ) external; - /// @notice Updates the risk premium threshold of an asset's spoke. - /// @param hub The address of the Hub contract. + /// @notice Updates the risk premium threshold of an asset's spoke on a specified hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param riskPremiumThreshold The new risk premium threshold. function updateSpokeRiskPremiumThreshold( address hub, @@ -189,10 +188,10 @@ interface IHubConfigurator { uint256 riskPremiumThreshold ) external; - /// @notice Updates the caps of an asset's spoke. - /// @param hub The address of the Hub contract. + /// @notice Updates the caps of an asset's spoke on a specified hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param addCap The new supply cap. /// @param drawCap The new draw cap. function updateSpokeCaps( @@ -204,22 +203,22 @@ interface IHubConfigurator { ) external; /// @notice Deactivates all assets of a spoke on a specified hub by setting the active flag to false. - /// @param hub The address of the Hub contract. - /// @param spoke The address of the spoke. + /// @param hub The address of the Hub. + /// @param spoke The address of the Spoke. function deactivateSpoke(address hub, address spoke) external; - /// @notice Pauses all assets of a spoke on a specified hub by setting the paused flag to true. - /// @param hub The address of the Hub contract. - /// @param spoke The address of the spoke. - function pauseSpoke(address hub, address spoke) external; + /// @notice Halts all assets of a spoke on a specified hub by setting the halted flag to true. + /// @param hub The address of the Hub. + /// @param spoke The address of the Spoke. + function haltSpoke(address hub, address spoke) external; - /// @notice Freezes all assets of a spoke on a specified hub by setting the add and draw caps to zero. - /// @param hub The address of the Hub contract. - /// @param spoke The address of the spoke. - function freezeSpoke(address hub, address spoke) external; + /// @notice Resets draw cap and add cap to zero for a spoke on a specified hub. + /// @param hub The address of the Hub. + /// @param spoke The address of the Spoke. + function resetSpokeCaps(address hub, address spoke) external; /// @notice Updates the interest rate data for an asset. - /// @param hub The address of the Hub contract. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. /// @param irData The interest rate data to apply to the given asset, encoded in bytes. function updateInterestRateData(address hub, uint256 assetId, bytes calldata irData) external; diff --git a/src/hub/libraries/AssetLogic.sol b/src/hub/libraries/AssetLogic.sol index 2b4f44cb4..1883bd8c3 100644 --- a/src/hub/libraries/AssetLogic.sol +++ b/src/hub/libraries/AssetLogic.sol @@ -129,18 +129,10 @@ library AssetLogic { } /// @notice Updates the drawn rate of a specified asset. - /// @dev Premium debt is not used in the interest rate calculation. /// @dev Uses last stored index; asset accrual should have already occurred. - /// @dev Imprecision from downscaling `deficitRay` does not accumulate. function updateDrawnRate(IHub.Asset storage asset, uint256 assetId) internal { uint256 drawnIndex = asset.drawnIndex; - uint256 newDrawnRate = IBasicInterestRateStrategy(asset.irStrategy).calculateInterestRate({ - assetId: assetId, - liquidity: asset.liquidity, - drawn: asset.drawn(drawnIndex), - deficit: asset.deficitRay.fromRayUp(), - swept: asset.swept - }); + uint256 newDrawnRate = asset.getDrawnRate(assetId, drawnIndex); asset.drawnRate = newDrawnRate.toUint96(); emit IHub.UpdateAsset(assetId, drawnIndex, newDrawnRate, asset.realizedFees); @@ -173,6 +165,24 @@ library AssetLogic { ); } + /// @notice Calculates the drawn rate of a specified asset using the specified drawn index. + /// @dev Premium debt is not used in the interest rate calculation. + /// @dev Imprecision from downscaling `deficitRay` does not accumulate. + function getDrawnRate( + IHub.Asset storage asset, + uint256 assetId, + uint256 drawnIndex + ) internal view returns (uint256) { + return + IBasicInterestRateStrategy(asset.irStrategy).calculateInterestRate({ + assetId: assetId, + liquidity: asset.liquidity, + drawn: asset.drawn(drawnIndex), + deficit: asset.deficitRay.fromRayUp(), + swept: asset.swept + }); + } + /// @notice Calculates the amount of fees derived from the index growth due to interest accrual. /// @param drawnIndex The current drawn index. function getUnrealizedFees( diff --git a/src/hub/libraries/Premium.sol b/src/hub/libraries/Premium.sol index 126fcf3fe..213148d07 100644 --- a/src/hub/libraries/Premium.sol +++ b/src/hub/libraries/Premium.sol @@ -13,7 +13,7 @@ library Premium { /// @notice Calculates the premium debt with full precision. /// @param premiumShares The number of premium shares. /// @param premiumOffsetRay The premium offset, expressed in asset units and scaled by RAY. - /// @param drawnIndex The current drawn index. + /// @param drawnIndex The drawn index at which premium debt is calculated. /// @return The premium debt, expressed in asset units and scaled by RAY. function calculatePremiumRay( uint256 premiumShares, diff --git a/src/hub/libraries/SharesMath.sol b/src/hub/libraries/SharesMath.sol index ff828174a..0cd422aa2 100644 --- a/src/hub/libraries/SharesMath.sol +++ b/src/hub/libraries/SharesMath.sol @@ -2,14 +2,14 @@ // Copyright (c) 2025 Aave Labs pragma solidity ^0.8.20; -import {MathUtils} from 'src/libraries/math/MathUtils.sol'; +import {Math} from 'src/dependencies/openzeppelin/Math.sol'; /// @title SharesMath library /// @author Aave Labs /// @notice Implements the logic to convert between assets and shares. /// @dev Utilizes virtual assets and shares to mitigate share manipulation attacks. library SharesMath { - using MathUtils for uint256; + using Math for uint256; uint256 internal constant VIRTUAL_ASSETS = 1e6; uint256 internal constant VIRTUAL_SHARES = 1e6; @@ -20,7 +20,12 @@ library SharesMath { uint256 totalAssets, uint256 totalShares ) internal pure returns (uint256) { - return assets.mulDivDown(totalShares + VIRTUAL_SHARES, totalAssets + VIRTUAL_ASSETS); + return + assets.mulDiv( + totalShares + VIRTUAL_SHARES, + totalAssets + VIRTUAL_ASSETS, + Math.Rounding.Floor + ); } /// @notice Converts an amount of shares to the equivalent amount of assets, rounding down. @@ -29,7 +34,12 @@ library SharesMath { uint256 totalAssets, uint256 totalShares ) internal pure returns (uint256) { - return shares.mulDivDown(totalAssets + VIRTUAL_ASSETS, totalShares + VIRTUAL_SHARES); + return + shares.mulDiv( + totalAssets + VIRTUAL_ASSETS, + totalShares + VIRTUAL_SHARES, + Math.Rounding.Floor + ); } /// @notice Converts an amount of assets to the equivalent amount of shares, rounding up. @@ -38,7 +48,8 @@ library SharesMath { uint256 totalAssets, uint256 totalShares ) internal pure returns (uint256) { - return assets.mulDivUp(totalShares + VIRTUAL_SHARES, totalAssets + VIRTUAL_ASSETS); + return + assets.mulDiv(totalShares + VIRTUAL_SHARES, totalAssets + VIRTUAL_ASSETS, Math.Rounding.Ceil); } /// @notice Converts an amount of shares to the equivalent amount of assets, rounding up. @@ -47,6 +58,7 @@ library SharesMath { uint256 totalAssets, uint256 totalShares ) internal pure returns (uint256) { - return shares.mulDivUp(totalAssets + VIRTUAL_ASSETS, totalShares + VIRTUAL_SHARES); + return + shares.mulDiv(totalAssets + VIRTUAL_ASSETS, totalShares + VIRTUAL_SHARES, Math.Rounding.Ceil); } } diff --git a/src/interfaces/IExtSload.sol b/src/interfaces/IExtSload.sol new file mode 100644 index 000000000..6c5dc5a8c --- /dev/null +++ b/src/interfaces/IExtSload.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title IExtSload +/// @author Aave Labs +/// @notice Minimal interface to easily access storage of source contract externally. See https://eips.ethereum.org/EIPS/eip-2330#rationale. +interface IExtSload { + /// @notice Returns the storage `value` of this contract at a given `slot`. + /// @param slot Slot to SLOAD from. + function extSload(bytes32 slot) external view returns (bytes32 value); + + /// @notice Returns the storage `values` of this contract at the given `slots`. + /// @param slots Array of slots to SLOAD from. + function extSloads(bytes32[] calldata slots) external view returns (bytes32[] memory values); +} diff --git a/src/interfaces/IIntentConsumer.sol b/src/interfaces/IIntentConsumer.sol new file mode 100644 index 000000000..779f25c7d --- /dev/null +++ b/src/interfaces/IIntentConsumer.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {INoncesKeyed} from 'src/interfaces/INoncesKeyed.sol'; + +/// @title IIntentConsumer +/// @author Aave Labs +/// @notice Minimal interface for IntentConsumer. +interface IIntentConsumer is INoncesKeyed { + /// @notice Thrown when given signature is invalid. + error InvalidSignature(); + + /// @notice Returns the EIP-712 domain separator. + function DOMAIN_SEPARATOR() external view returns (bytes32); +} diff --git a/src/interfaces/INoncesKeyed.sol b/src/interfaces/INoncesKeyed.sol index d8a1be447..5a25a268a 100644 --- a/src/interfaces/INoncesKeyed.sol +++ b/src/interfaces/INoncesKeyed.sol @@ -13,7 +13,7 @@ interface INoncesKeyed { function useNonce(uint192 key) external returns (uint256 keyNonce); /// @notice Returns the next unused nonce for an address and key. Result contains the key prefix. - /// @param owner The address of the nonce over. + /// @param owner The address of the nonce owner. /// @param key The key which specifies namespace of the nonce. /// @return keyNonce The first 24 bytes are for the key, & the last 8 bytes for the nonce. function nonces(address owner, uint192 key) external view returns (uint256 keyNonce); diff --git a/src/libraries/math/MathUtils.sol b/src/libraries/math/MathUtils.sol index 1d99a1d76..f44dbe4e2 100644 --- a/src/libraries/math/MathUtils.sol +++ b/src/libraries/math/MathUtils.sol @@ -2,9 +2,13 @@ // Copyright (c) 2025 Aave Labs pragma solidity ^0.8.20; +import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; + /// @title MathUtils library /// @author Aave Labs library MathUtils { + using SafeCast for uint256; + uint256 internal constant RAY = 1e27; /// @dev Ignoring leap years uint256 internal constant SECONDS_PER_YEAR = 365 days; @@ -34,6 +38,13 @@ library MathUtils { } } + /// @notice Returns the saturating subtraction at zero. + function zeroFloorSub(uint256 a, uint256 b) internal pure returns (uint256 c) { + assembly ('memory-safe') { + c := mul(sub(a, b), gt(a, b)) + } + } + /// @notice Returns the sum of an unsigned and signed integer. /// @dev Reverts on underflow. function add(uint256 a, int256 b) internal pure returns (uint256) { @@ -50,9 +61,8 @@ library MathUtils { } /// @notice Returns the difference of two unsigned integers as a signed integer. - /// @dev Does not ensure the `a` and `b` values are within the range of a signed integer. function signedSub(uint256 a, uint256 b) internal pure returns (int256) { - return int256(a) - int256(b); + return a.toInt256() - b.toInt256(); } /// @notice Returns the difference of two unsigned integers. @@ -71,6 +81,18 @@ library MathUtils { } } + /// @notice Divides `a` by `b`, rounding up. + /// @dev Reverts if division by zero. + /// @return c = ceil(a / b). + function divUp(uint256 a, uint256 b) internal pure returns (uint256 c) { + assembly ('memory-safe') { + if iszero(b) { + revert(0, 0) + } + c := add(div(a, b), gt(mod(a, b), 0)) + } + } + /// @notice Multiplies `a` and `b` in 256 bits and divides the result by `c`, rounding down. /// @dev Reverts if division by zero or overflow occurs on intermediate multiplication. /// @return d = floor(a * b / c). diff --git a/src/libraries/math/PercentageMath.sol b/src/libraries/math/PercentageMath.sol index a19bb8d48..f09261c43 100644 --- a/src/libraries/math/PercentageMath.sol +++ b/src/libraries/math/PercentageMath.sol @@ -7,7 +7,7 @@ pragma solidity ^0.8.20; /// @notice Provides functions to perform percentage calculations with explicit rounding. /// @dev Percentages are defined by default with 2 decimals of precision (100.00). The precision is indicated by `PERCENTAGE_FACTOR`. library PercentageMath { - // Maximum percentage factor in BPS (100.00%) + // Percentage factor in BPS (100.00%) uint256 internal constant PERCENTAGE_FACTOR = 1e4; /// @notice Executes a percentage multiplication, rounded down. diff --git a/src/libraries/math/WadRayMath.sol b/src/libraries/math/WadRayMath.sol index 3ffaf8299..f204dd844 100644 --- a/src/libraries/math/WadRayMath.sol +++ b/src/libraries/math/WadRayMath.sol @@ -6,6 +6,7 @@ pragma solidity ^0.8.20; /// @author Aave Labs /// @notice Provides utility functions to work with WAD and RAY units with explicit rounding. library WadRayMath { + uint256 internal constant WAD_DECIMALS = 18; uint256 internal constant WAD = 1e18; uint256 internal constant RAY = 1e27; uint256 internal constant PERCENTAGE_FACTOR = 1e4; @@ -171,35 +172,46 @@ library WadRayMath { } } - /// @notice Converts value from basis points to WAD, rounding down. - /// @dev Reverts if intermediate multiplication overflows. - /// @return b = floor(a * WAD / PERCENTAGE_FACTOR) in WAD units. + /// @notice Converts value from basis points to WAD. + /// @dev Reverts if result overflows. + /// @return b = a * (WAD / PERCENTAGE_FACTOR), expressed in WAD units. function bpsToWad(uint256 a) internal pure returns (uint256 b) { assembly ('memory-safe') { - b := mul(a, WAD) - - // to avoid overflow, b/WAD == a - if iszero(eq(div(b, WAD), a)) { + let factor := div(WAD, PERCENTAGE_FACTOR) + b := mul(a, factor) + // to avoid overflow, b/factor == a + if iszero(eq(div(b, factor), a)) { revert(0, 0) } - - b := div(b, PERCENTAGE_FACTOR) } } - /// @notice Converts value from basis points to RAY, rounding down. - /// @dev Reverts if intermediate multiplication overflows. - /// @return b = a * RAY / PERCENTAGE_FACTOR in RAY units. + /// @notice Converts value from basis points to RAY. + /// @dev Reverts if result overflows. + /// @return b = a * (RAY / PERCENTAGE_FACTOR), expressed in RAY units. function bpsToRay(uint256 a) internal pure returns (uint256 b) { assembly ('memory-safe') { - b := mul(a, RAY) - - // to avoid overflow, b/RAY == a - if iszero(eq(div(b, RAY), a)) { + let factor := div(RAY, PERCENTAGE_FACTOR) + b := mul(a, factor) + // to avoid overflow, b/factor == a + if iszero(eq(div(b, factor), a)) { revert(0, 0) } + } + } - b := div(b, PERCENTAGE_FACTOR) + /// @notice Rounds up a RAY value to the nearest RAY. + /// @dev Reverts if result overflows. + /// @return b = ceil(a / RAY) * RAY. + function roundRayUp(uint256 a) internal pure returns (uint256 b) { + assembly ('memory-safe') { + // add 1 if (a % RAY) > 0 to round up the division of a by RAY + let c := add(div(a, RAY), gt(mod(a, RAY), 0)) + b := mul(c, RAY) + // to avoid overflow, b/RAY == c + if iszero(eq(div(b, RAY), c)) { + revert(0, 0) + } } } } diff --git a/src/libraries/types/Roles.sol b/src/libraries/types/Roles.sol index ed3436165..7dcf13f6e 100644 --- a/src/libraries/types/Roles.sol +++ b/src/libraries/types/Roles.sol @@ -10,4 +10,7 @@ library Roles { uint64 public constant HUB_ADMIN_ROLE = 1; uint64 public constant SPOKE_ADMIN_ROLE = 2; uint64 public constant USER_POSITION_UPDATER_ROLE = 3; + uint64 public constant HUB_CONFIGURATOR_ROLE = 4; + uint64 public constant SPOKE_CONFIGURATOR_ROLE = 5; + uint64 public constant DEFICIT_ELIMINATOR_ROLE = 6; } diff --git a/src/misc/UnitPriceFeed.sol b/src/misc/UnitPriceFeed.sol index 24ce26196..86955bec2 100644 --- a/src/misc/UnitPriceFeed.sol +++ b/src/misc/UnitPriceFeed.sol @@ -3,27 +3,28 @@ pragma solidity 0.8.28; import {AggregatorV3Interface} from 'src/dependencies/chainlink/AggregatorV3Interface.sol'; +import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; /// @title UnitPriceFeed contract /// @author Aave Labs /// @notice Price feed that returns the unit price (1), with decimals precision. /// @dev This price feed can be set for reserves that use the base currency as collateral. contract UnitPriceFeed is AggregatorV3Interface { - /// @inheritdoc AggregatorV3Interface - uint8 public immutable decimals; + using SafeCast for uint256; /// @inheritdoc AggregatorV3Interface string public description; - int256 private immutable _units; + uint8 private immutable DECIMALS; + int256 private immutable UNITS; /// @dev Constructor. /// @param decimals_ The number of decimals used to represent the unit price. /// @param description_ The description of the unit price feed. constructor(uint8 decimals_, string memory description_) { - decimals = decimals_; + UNITS = (10 ** decimals_).toInt256(); + DECIMALS = decimals_; description = description_; - _units = int256(10 ** decimals_); } /// @inheritdoc AggregatorV3Interface @@ -47,7 +48,7 @@ contract UnitPriceFeed is AggregatorV3Interface { { if (_roundId <= uint80(block.timestamp)) { roundId = _roundId; - answer = _units; + answer = UNITS; startedAt = _roundId; updatedAt = _roundId; answeredInRound = _roundId; @@ -67,9 +68,14 @@ contract UnitPriceFeed is AggregatorV3Interface { ) { roundId = uint80(block.timestamp); - answer = _units; + answer = UNITS; startedAt = block.timestamp; updatedAt = block.timestamp; answeredInRound = roundId; } + + /// @inheritdoc AggregatorV3Interface + function decimals() external view returns (uint8) { + return DECIMALS; + } } diff --git a/src/position-manager/NativeTokenGateway.sol b/src/position-manager/NativeTokenGateway.sol index 343c24855..8e3c047b3 100644 --- a/src/position-manager/NativeTokenGateway.sol +++ b/src/position-manager/NativeTokenGateway.sol @@ -15,21 +15,21 @@ import {INativeTokenGateway} from 'src/position-manager/interfaces/INativeTokenG /// @notice Gateway to interact with a spoke using the native coin of a chain. /// @dev Contract must be an active & approved user position manager in order to execute spoke actions on a user's behalf. contract NativeTokenGateway is INativeTokenGateway, GatewayBase, ReentrancyGuardTransient { - using SafeERC20 for *; + using SafeERC20 for IERC20; - INativeWrapper internal immutable _nativeWrapper; + address public immutable NATIVE_WRAPPER; /// @dev Constructor. /// @param nativeWrapper_ The address of the native wrapper contract. /// @param initialOwner_ The address of the initial owner. constructor(address nativeWrapper_, address initialOwner_) GatewayBase(initialOwner_) { require(nativeWrapper_ != address(0), InvalidAddress()); - _nativeWrapper = INativeWrapper(payable(nativeWrapper_)); + NATIVE_WRAPPER = nativeWrapper_; } /// @dev Checks only 'nativeWrapper' can transfer native tokens. receive() external payable { - require(msg.sender == address(_nativeWrapper), UnsupportedAction()); + require(msg.sender == NATIVE_WRAPPER, UnsupportedAction()); } /// @dev Unsupported fallback function. @@ -70,7 +70,7 @@ contract NativeTokenGateway is INativeTokenGateway, GatewayBase, ReentrancyGuard address spoke, uint256 reserveId, uint256 amount - ) external onlyRegisteredSpoke(spoke) returns (uint256, uint256) { + ) external nonReentrant onlyRegisteredSpoke(spoke) returns (uint256, uint256) { address underlying = _getReserveUnderlying(spoke, reserveId); _validateParams(underlying, amount); @@ -79,7 +79,7 @@ contract NativeTokenGateway is INativeTokenGateway, GatewayBase, ReentrancyGuard amount, msg.sender ); - _nativeWrapper.withdraw(withdrawnAmount); + INativeWrapper(NATIVE_WRAPPER).withdraw(withdrawnAmount); Address.sendValue(payable(msg.sender), withdrawnAmount); return (withdrawnShares, withdrawnAmount); @@ -90,7 +90,7 @@ contract NativeTokenGateway is INativeTokenGateway, GatewayBase, ReentrancyGuard address spoke, uint256 reserveId, uint256 amount - ) external onlyRegisteredSpoke(spoke) returns (uint256, uint256) { + ) external nonReentrant onlyRegisteredSpoke(spoke) returns (uint256, uint256) { address underlying = _getReserveUnderlying(spoke, reserveId); _validateParams(underlying, amount); @@ -99,7 +99,7 @@ contract NativeTokenGateway is INativeTokenGateway, GatewayBase, ReentrancyGuard amount, msg.sender ); - _nativeWrapper.withdraw(borrowedAmount); + INativeWrapper(NATIVE_WRAPPER).withdraw(borrowedAmount); Address.sendValue(payable(msg.sender), borrowedAmount); return (borrowedShares, borrowedAmount); @@ -123,8 +123,8 @@ contract NativeTokenGateway is INativeTokenGateway, GatewayBase, ReentrancyGuard repayAmount = userTotalDebt; } - _nativeWrapper.deposit{value: repayAmount}(); - _nativeWrapper.forceApprove(spoke, repayAmount); + INativeWrapper(NATIVE_WRAPPER).deposit{value: repayAmount}(); + IERC20(NATIVE_WRAPPER).forceApprove(spoke, repayAmount); (uint256 repaidShares, uint256 repaidAmount) = ISpoke(spoke).repay( reserveId, repayAmount, @@ -138,11 +138,6 @@ contract NativeTokenGateway is INativeTokenGateway, GatewayBase, ReentrancyGuard return (repaidShares, repaidAmount); } - /// @inheritdoc INativeTokenGateway - function NATIVE_WRAPPER() external view returns (address) { - return address(_nativeWrapper); - } - /// @dev `msg.value` verification must be done before calling this. function _supplyNative( address spoke, @@ -153,13 +148,13 @@ contract NativeTokenGateway is INativeTokenGateway, GatewayBase, ReentrancyGuard address underlying = _getReserveUnderlying(spoke, reserveId); _validateParams(underlying, amount); - _nativeWrapper.deposit{value: amount}(); - _nativeWrapper.forceApprove(spoke, amount); + INativeWrapper(NATIVE_WRAPPER).deposit{value: amount}(); + IERC20(NATIVE_WRAPPER).forceApprove(spoke, amount); return ISpoke(spoke).supply(reserveId, amount, user); } function _validateParams(address underlying, uint256 amount) internal view { - require(address(_nativeWrapper) == underlying, NotNativeWrappedAsset()); + require(NATIVE_WRAPPER == underlying, NotNativeWrappedAsset()); require(amount > 0, InvalidAmount()); } } diff --git a/src/position-manager/SignatureGateway.sol b/src/position-manager/SignatureGateway.sol index de2194201..216ec28f8 100644 --- a/src/position-manager/SignatureGateway.sol +++ b/src/position-manager/SignatureGateway.sol @@ -2,15 +2,13 @@ // Copyright (c) 2025 Aave Labs pragma solidity 0.8.28; -import {SignatureChecker} from 'src/dependencies/openzeppelin/SignatureChecker.sol'; import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; -import {EIP712} from 'src/dependencies/solady/EIP712.sol'; +import {EIP712Hash} from 'src/position-manager/libraries/EIP712Hash.sol'; import {MathUtils} from 'src/libraries/math/MathUtils.sol'; -import {NoncesKeyed} from 'src/utils/NoncesKeyed.sol'; -import {Multicall} from 'src/utils/Multicall.sol'; -import {EIP712Hash, EIP712Types} from 'src/position-manager/libraries/EIP712Hash.sol'; import {GatewayBase} from 'src/position-manager/GatewayBase.sol'; +import {IntentConsumer} from 'src/utils/IntentConsumer.sol'; +import {Multicall} from 'src/utils/Multicall.sol'; import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; import {ISignatureGateway} from 'src/position-manager/interfaces/ISignatureGateway.sol'; @@ -20,166 +18,211 @@ import {ISignatureGateway} from 'src/position-manager/interfaces/ISignatureGatew /// @dev Contract must be an active & approved user position manager to execute spoke actions on user's behalf. /// @dev Uses keyed-nonces where each key's namespace nonce is consumed sequentially. Intents bundled through /// multicall can be executed independently in order of signed nonce & deadline; does not guarantee batch atomicity. -contract SignatureGateway is ISignatureGateway, GatewayBase, NoncesKeyed, Multicall, EIP712 { +contract SignatureGateway is ISignatureGateway, GatewayBase, IntentConsumer, Multicall { using SafeERC20 for IERC20; using EIP712Hash for *; + /// @inheritdoc ISignatureGateway + bytes32 public constant SUPPLY_TYPEHASH = EIP712Hash.SUPPLY_TYPEHASH; + + /// @inheritdoc ISignatureGateway + bytes32 public constant WITHDRAW_TYPEHASH = EIP712Hash.WITHDRAW_TYPEHASH; + + /// @inheritdoc ISignatureGateway + bytes32 public constant BORROW_TYPEHASH = EIP712Hash.BORROW_TYPEHASH; + + /// @inheritdoc ISignatureGateway + bytes32 public constant REPAY_TYPEHASH = EIP712Hash.REPAY_TYPEHASH; + + /// @inheritdoc ISignatureGateway + bytes32 public constant SET_USING_AS_COLLATERAL_TYPEHASH = + EIP712Hash.SET_USING_AS_COLLATERAL_TYPEHASH; + + /// @inheritdoc ISignatureGateway + bytes32 public constant UPDATE_USER_RISK_PREMIUM_TYPEHASH = + EIP712Hash.UPDATE_USER_RISK_PREMIUM_TYPEHASH; + + /// @inheritdoc ISignatureGateway + bytes32 public constant UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH = + EIP712Hash.UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH; + /// @dev Constructor. /// @param initialOwner_ The address of the initial owner. constructor(address initialOwner_) GatewayBase(initialOwner_) {} /// @inheritdoc ISignatureGateway function supplyWithSig( - EIP712Types.Supply calldata params, + Supply calldata params, bytes calldata signature ) external onlyRegisteredSpoke(params.spoke) returns (uint256, uint256) { - require(block.timestamp <= params.deadline, InvalidSignature()); address spoke = params.spoke; uint256 reserveId = params.reserveId; - address user = params.onBehalfOf; - bytes32 digest = _hashTypedData(params.hash()); - require(SignatureChecker.isValidSignatureNow(user, digest, signature), InvalidSignature()); - _useCheckedNonce(user, params.nonce); + address onBehalfOf = params.onBehalfOf; + _verifyAndConsumeIntent({ + signer: onBehalfOf, + intentHash: params.hash(), + nonce: params.nonce, + deadline: params.deadline, + signature: signature + }); IERC20 underlying = IERC20(_getReserveUnderlying(spoke, reserveId)); - underlying.safeTransferFrom(user, address(this), params.amount); + underlying.safeTransferFrom(onBehalfOf, address(this), params.amount); underlying.forceApprove(spoke, params.amount); - return ISpoke(spoke).supply(reserveId, params.amount, user); + return ISpoke(spoke).supply(reserveId, params.amount, onBehalfOf); } /// @inheritdoc ISignatureGateway function withdrawWithSig( - EIP712Types.Withdraw calldata params, + Withdraw calldata params, bytes calldata signature ) external onlyRegisteredSpoke(params.spoke) returns (uint256, uint256) { - require(block.timestamp <= params.deadline, InvalidSignature()); address spoke = params.spoke; uint256 reserveId = params.reserveId; - address user = params.onBehalfOf; - bytes32 digest = _hashTypedData(params.hash()); - require(SignatureChecker.isValidSignatureNow(user, digest, signature), InvalidSignature()); - _useCheckedNonce(user, params.nonce); + address onBehalfOf = params.onBehalfOf; + _verifyAndConsumeIntent({ + signer: onBehalfOf, + intentHash: params.hash(), + nonce: params.nonce, + deadline: params.deadline, + signature: signature + }); IERC20 underlying = IERC20(_getReserveUnderlying(spoke, reserveId)); (uint256 withdrawnShares, uint256 withdrawnAmount) = ISpoke(spoke).withdraw( reserveId, params.amount, - user + onBehalfOf ); - underlying.safeTransfer(user, withdrawnAmount); + underlying.safeTransfer(onBehalfOf, withdrawnAmount); return (withdrawnShares, withdrawnAmount); } /// @inheritdoc ISignatureGateway function borrowWithSig( - EIP712Types.Borrow calldata params, + Borrow calldata params, bytes calldata signature ) external onlyRegisteredSpoke(params.spoke) returns (uint256, uint256) { - require(block.timestamp <= params.deadline, InvalidSignature()); address spoke = params.spoke; uint256 reserveId = params.reserveId; - address user = params.onBehalfOf; - bytes32 digest = _hashTypedData(params.hash()); - require(SignatureChecker.isValidSignatureNow(user, digest, signature), InvalidSignature()); - _useCheckedNonce(user, params.nonce); + address onBehalfOf = params.onBehalfOf; + _verifyAndConsumeIntent({ + signer: onBehalfOf, + intentHash: params.hash(), + nonce: params.nonce, + deadline: params.deadline, + signature: signature + }); IERC20 underlying = IERC20(_getReserveUnderlying(spoke, reserveId)); (uint256 borrowedShares, uint256 borrowedAmount) = ISpoke(spoke).borrow( reserveId, params.amount, - user + onBehalfOf ); - underlying.safeTransfer(user, borrowedAmount); + underlying.safeTransfer(onBehalfOf, borrowedAmount); return (borrowedShares, borrowedAmount); } /// @inheritdoc ISignatureGateway function repayWithSig( - EIP712Types.Repay calldata params, + Repay calldata params, bytes calldata signature ) external onlyRegisteredSpoke(params.spoke) returns (uint256, uint256) { - require(block.timestamp <= params.deadline, InvalidSignature()); address spoke = params.spoke; uint256 reserveId = params.reserveId; - address user = params.onBehalfOf; - bytes32 digest = _hashTypedData(params.hash()); - require(SignatureChecker.isValidSignatureNow(user, digest, signature), InvalidSignature()); - _useCheckedNonce(user, params.nonce); + address onBehalfOf = params.onBehalfOf; + _verifyAndConsumeIntent({ + signer: onBehalfOf, + intentHash: params.hash(), + nonce: params.nonce, + deadline: params.deadline, + signature: signature + }); IERC20 underlying = IERC20(_getReserveUnderlying(spoke, reserveId)); uint256 repayAmount = MathUtils.min( params.amount, - ISpoke(spoke).getUserTotalDebt(reserveId, user) + ISpoke(spoke).getUserTotalDebt(reserveId, onBehalfOf) ); - underlying.safeTransferFrom(user, address(this), repayAmount); + underlying.safeTransferFrom(onBehalfOf, address(this), repayAmount); underlying.forceApprove(spoke, repayAmount); - return ISpoke(spoke).repay(reserveId, repayAmount, user); + return ISpoke(spoke).repay(reserveId, repayAmount, onBehalfOf); } /// @inheritdoc ISignatureGateway function setUsingAsCollateralWithSig( - EIP712Types.SetUsingAsCollateral calldata params, + SetUsingAsCollateral calldata params, bytes calldata signature ) external onlyRegisteredSpoke(params.spoke) { - require(block.timestamp <= params.deadline, InvalidSignature()); - address user = params.onBehalfOf; - bytes32 digest = _hashTypedData(params.hash()); - require(SignatureChecker.isValidSignatureNow(user, digest, signature), InvalidSignature()); - _useCheckedNonce(user, params.nonce); - - ISpoke(params.spoke).setUsingAsCollateral(params.reserveId, params.useAsCollateral, user); + address onBehalfOf = params.onBehalfOf; + _verifyAndConsumeIntent({ + signer: onBehalfOf, + intentHash: params.hash(), + nonce: params.nonce, + deadline: params.deadline, + signature: signature + }); + + ISpoke(params.spoke).setUsingAsCollateral(params.reserveId, params.useAsCollateral, onBehalfOf); } /// @inheritdoc ISignatureGateway function updateUserRiskPremiumWithSig( - EIP712Types.UpdateUserRiskPremium calldata params, + UpdateUserRiskPremium calldata params, bytes calldata signature ) external onlyRegisteredSpoke(params.spoke) { - require(block.timestamp <= params.deadline, InvalidSignature()); - bytes32 digest = _hashTypedData(params.hash()); - require( - SignatureChecker.isValidSignatureNow(params.user, digest, signature), - InvalidSignature() - ); - _useCheckedNonce(params.user, params.nonce); - - ISpoke(params.spoke).updateUserRiskPremium(params.user); + _verifyAndConsumeIntent({ + signer: params.onBehalfOf, + intentHash: params.hash(), + nonce: params.nonce, + deadline: params.deadline, + signature: signature + }); + + ISpoke(params.spoke).updateUserRiskPremium(params.onBehalfOf); } /// @inheritdoc ISignatureGateway function updateUserDynamicConfigWithSig( - EIP712Types.UpdateUserDynamicConfig calldata params, + UpdateUserDynamicConfig calldata params, bytes calldata signature ) external onlyRegisteredSpoke(params.spoke) { - require(block.timestamp <= params.deadline, InvalidSignature()); - bytes32 digest = _hashTypedData(params.hash()); - require( - SignatureChecker.isValidSignatureNow(params.user, digest, signature), - InvalidSignature() - ); - _useCheckedNonce(params.user, params.nonce); - - ISpoke(params.spoke).updateUserDynamicConfig(params.user); + _verifyAndConsumeIntent({ + signer: params.onBehalfOf, + intentHash: params.hash(), + nonce: params.nonce, + deadline: params.deadline, + signature: signature + }); + + ISpoke(params.spoke).updateUserDynamicConfig(params.onBehalfOf); } /// @inheritdoc ISignatureGateway function setSelfAsUserPositionManagerWithSig( address spoke, - EIP712Types.SetUserPositionManager calldata params, + address onBehalfOf, + bool approve, + uint256 nonce, + uint256 deadline, bytes calldata signature ) external onlyRegisteredSpoke(spoke) { + ISpoke.PositionManagerUpdate[] memory updates = new ISpoke.PositionManagerUpdate[](1); + updates[0] = ISpoke.PositionManagerUpdate({positionManager: address(this), approve: approve}); try - ISpoke(spoke).setUserPositionManagerWithSig( - address(this), - params.user, - params.approve, - params.nonce, - params.deadline, + ISpoke(spoke).setUserPositionManagersWithSig( + ISpoke.SetUserPositionManagers({ + onBehalfOf: onBehalfOf, + updates: updates, + nonce: nonce, + deadline: deadline + }), signature ) {} catch {} @@ -210,46 +253,6 @@ contract SignatureGateway is ISignatureGateway, GatewayBase, NoncesKeyed, Multic {} catch {} } - /// @inheritdoc ISignatureGateway - function DOMAIN_SEPARATOR() external view returns (bytes32) { - return _domainSeparator(); - } - - /// @inheritdoc ISignatureGateway - function SUPPLY_TYPEHASH() external pure returns (bytes32) { - return EIP712Hash.SUPPLY_TYPEHASH; - } - - /// @inheritdoc ISignatureGateway - function WITHDRAW_TYPEHASH() external pure returns (bytes32) { - return EIP712Hash.WITHDRAW_TYPEHASH; - } - - /// @inheritdoc ISignatureGateway - function BORROW_TYPEHASH() external pure returns (bytes32) { - return EIP712Hash.BORROW_TYPEHASH; - } - - /// @inheritdoc ISignatureGateway - function REPAY_TYPEHASH() external pure returns (bytes32) { - return EIP712Hash.REPAY_TYPEHASH; - } - - /// @inheritdoc ISignatureGateway - function SET_USING_AS_COLLATERAL_TYPEHASH() external pure returns (bytes32) { - return EIP712Hash.SET_USING_AS_COLLATERAL_TYPEHASH; - } - - /// @inheritdoc ISignatureGateway - function UPDATE_USER_RISK_PREMIUM_TYPEHASH() external pure returns (bytes32) { - return EIP712Hash.UPDATE_USER_RISK_PREMIUM_TYPEHASH; - } - - /// @inheritdoc ISignatureGateway - function UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH() external pure returns (bytes32) { - return EIP712Hash.UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH; - } - function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ('SignatureGateway', '1'); } diff --git a/src/position-manager/interfaces/INativeTokenGateway.sol b/src/position-manager/interfaces/INativeTokenGateway.sol index 69386874f..18abc75bf 100644 --- a/src/position-manager/interfaces/INativeTokenGateway.sol +++ b/src/position-manager/interfaces/INativeTokenGateway.sol @@ -84,6 +84,6 @@ interface INativeTokenGateway is IGatewayBase { uint256 amount ) external payable returns (uint256, uint256); - /// @notice Returns the address of Native Wrapper. + /// @notice Returns the address of the Native Wrapper. function NATIVE_WRAPPER() external view returns (address); } diff --git a/src/position-manager/interfaces/ISignatureGateway.sol b/src/position-manager/interfaces/ISignatureGateway.sol index d7ae8d6cd..b13c09e05 100644 --- a/src/position-manager/interfaces/ISignatureGateway.sol +++ b/src/position-manager/interfaces/ISignatureGateway.sol @@ -3,26 +3,126 @@ pragma solidity ^0.8.0; import {IMulticall} from 'src/interfaces/IMulticall.sol'; -import {INoncesKeyed} from 'src/interfaces/INoncesKeyed.sol'; -import {EIP712Types} from 'src/libraries/types/EIP712Types.sol'; +import {IIntentConsumer} from 'src/interfaces/IIntentConsumer.sol'; import {IGatewayBase} from 'src/position-manager/interfaces/IGatewayBase.sol'; /// @title ISignatureGateway /// @author Aave Labs /// @notice Minimal interface for protocol actions involving signed intents. -interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { - /// @notice Thrown when signature deadline has passed or signer is not `onBehalfOf`. - error InvalidSignature(); +interface ISignatureGateway is IGatewayBase, IIntentConsumer, IMulticall { + /// @notice Intent data to supply assets to a reserve. + /// @param spoke The address of the registered spoke. + /// @param reserveId The identifier of the reserve. + /// @param amount The amount of assets to supply. + /// @param onBehalfOf The address of the user on whose behalf the supply is performed. + /// @param nonce The key-prefixed nonce for the signature. + /// @param deadline The deadline for the intent. + struct Supply { + address spoke; + uint256 reserveId; + uint256 amount; + address onBehalfOf; + uint256 nonce; + uint256 deadline; + } + + /// @notice Intent data to withdraw assets from a reserve. + /// @param spoke The address of the registered spoke. + /// @param reserveId The identifier of the reserve. + /// @param amount The amount of assets to withdraw. + /// @param onBehalfOf The address of the user on whose behalf the withdraw is performed. + /// @param nonce The key-prefixed nonce for the signature. + /// @param deadline The deadline for the intent. + struct Withdraw { + address spoke; + uint256 reserveId; + uint256 amount; + address onBehalfOf; + uint256 nonce; + uint256 deadline; + } + + /// @notice Intent data to borrow assets from a reserve. + /// @param spoke The address of the registered spoke. + /// @param reserveId The identifier of the reserve. + /// @param amount The amount of assets to borrow. + /// @param onBehalfOf The address of the user on whose behalf the borrow is performed. + /// @param nonce The key-prefixed nonce for the signature. + /// @param deadline The deadline for the intent. + struct Borrow { + address spoke; + uint256 reserveId; + uint256 amount; + address onBehalfOf; + uint256 nonce; + uint256 deadline; + } + + /// @notice Intent data to repay assets to a reserve. + /// @param spoke The address of the registered spoke. + /// @param reserveId The identifier of the reserve. + /// @param amount The amount of assets to repay. + /// @param onBehalfOf The address of the user on whose behalf the repay is performed. + /// @param nonce The key-prefixed nonce for the signature. + /// @param deadline The deadline for the intent. + struct Repay { + address spoke; + uint256 reserveId; + uint256 amount; + address onBehalfOf; + uint256 nonce; + uint256 deadline; + } + + /// @notice Intent data to enable or disable a reserve as collateral. + /// @param spoke The address of the registered spoke. + /// @param reserveId The identifier of the reserve. + /// @param useAsCollateral True to enable the reserve as collateral, false to disable it. + /// @param onBehalfOf The address of the user on whose behalf the action is performed. + /// @param nonce The key-prefixed nonce for the signature. + /// @param deadline The deadline for the intent. + struct SetUsingAsCollateral { + address spoke; + uint256 reserveId; + bool useAsCollateral; + address onBehalfOf; + uint256 nonce; + uint256 deadline; + } + + /// @notice Intent data to update the risk premium of a user position. + /// @param spoke The address of the registered spoke. + /// @param onBehalfOf The address of the user whose risk premium is being updated. + /// @param nonce The key-prefixed nonce for the signature. + /// @param deadline The deadline for the intent. + struct UpdateUserRiskPremium { + address spoke; + address onBehalfOf; + uint256 nonce; + uint256 deadline; + } + + /// @notice Intent data to update the dynamic configuration of a user position. + /// @param spoke The address of the registered spoke. + /// @param onBehalfOf The address of the user whose dynamic config is being updated. + /// @param nonce The key-prefixed nonce for the signature. + /// @param deadline The deadline for the intent. + struct UpdateUserDynamicConfig { + address spoke; + address onBehalfOf; + uint256 nonce; + uint256 deadline; + } /// @notice Facilitates `supply` action on the specified registered `spoke` with a typed signature from `onBehalfOf`. /// @dev Supplied assets are pulled from `onBehalfOf`, prior approval to this gateway is required. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured supply parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. /// @return The amount of shares supplied. /// @return The amount of assets supplied. function supplyWithSig( - EIP712Types.Supply calldata params, + Supply calldata params, bytes calldata signature ) external returns (uint256, uint256); @@ -31,11 +131,11 @@ interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { /// @dev Withdrawn assets are pushed to `onBehalfOf`. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured withdraw parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. /// @return The amount of shares withdrawn. /// @return The amount of assets withdrawn. function withdrawWithSig( - EIP712Types.Withdraw calldata params, + Withdraw calldata params, bytes calldata signature ) external returns (uint256, uint256); @@ -43,11 +143,11 @@ interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { /// @dev Borrowed assets are pushed to `onBehalfOf`. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured borrow parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. /// @return The amount of shares borrowed. /// @return The amount of assets borrowed. function borrowWithSig( - EIP712Types.Borrow calldata params, + Borrow calldata params, bytes calldata signature ) external returns (uint256, uint256); @@ -56,62 +156,71 @@ interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { /// @dev Providing an amount greater than the user's current debt indicates a request to repay the maximum possible amount. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured repay parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. /// @return The amount of shares repaid. /// @return The amount of assets repaid. function repayWithSig( - EIP712Types.Repay calldata params, + Repay calldata params, bytes calldata signature ) external returns (uint256, uint256); /// @notice Facilitates `setUsingAsCollateral` action on the specified registered `spoke` with a typed signature from `onBehalfOf`. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured setUsingAsCollateral parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. function setUsingAsCollateralWithSig( - EIP712Types.SetUsingAsCollateral calldata params, + SetUsingAsCollateral calldata params, bytes calldata signature ) external; - /// @notice Facilitates `updateUserRiskPremium` action on the specified registered `spoke` with a typed signature from `user`. + /// @notice Facilitates `updateUserRiskPremium` action on the specified registered `spoke` with a typed signature from `onBehalfOf`. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured updateUserRiskPremium parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. function updateUserRiskPremiumWithSig( - EIP712Types.UpdateUserRiskPremium calldata params, + UpdateUserRiskPremium calldata params, bytes calldata signature ) external; - /// @notice Facilitates `updateUserDynamicConfig` action on the specified registered `spoke` with a typed signature from `user`. + /// @notice Facilitates `updateUserDynamicConfig` action on the specified registered `spoke` with a typed signature from `onBehalfOf`. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured updateUserDynamicConfig parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. function updateUserDynamicConfigWithSig( - EIP712Types.UpdateUserDynamicConfig calldata params, + UpdateUserDynamicConfig calldata params, bytes calldata signature ) external; /// @notice Facilitates setting this gateway as user position manager on the specified registered `spoke` - /// with a typed signature from `user`. + /// with a typed signature from `onBehalfOf`. /// @dev The signature is consumed on the the specified registered `spoke`. /// @dev The given data is passed to the `spoke` for the signature to be verified. - /// @param spoke The address of the spoke. - /// @param params The structured setSelfAsUserPositionManager parameters. - /// @param signature The signed bytes for the intent. + /// @param spoke The address of the registered spoke. + /// @param onBehalfOf The address of the user on whose behalf this gateway can act. + /// @param approve True to approve the gateway, false to revoke approval. + /// @param nonce The key-prefixed nonce for the signature. + /// @param deadline The deadline for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. function setSelfAsUserPositionManagerWithSig( address spoke, - EIP712Types.SetUserPositionManager calldata params, + address onBehalfOf, + bool approve, + uint256 nonce, + uint256 deadline, bytes calldata signature ) external; /// @notice Facilitates consuming a permit for the given reserve's underlying asset on the specified registered `spoke`. /// @dev The given data is passed to the underlying asset for the signature to be verified. - /// @dev Spender is this gateway contract. + /// @dev The SignatureGateway must be configured as the spender. /// @param spoke The address of the spoke. /// @param reserveId The identifier of the reserve. /// @param onBehalfOf The address of the user on whose behalf the permit is being used. /// @param value The amount of the underlying asset to permit. /// @param deadline The deadline for the permit. + /// @param permitV The V component of the permit signature. + /// @param permitR The R component of the permit signature. + /// @param permitS The S component of the permit signature. function permitReserve( address spoke, uint256 reserveId, @@ -123,9 +232,6 @@ interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { bytes32 permitS ) external; - /// @notice Returns the EIP712 domain separator. - function DOMAIN_SEPARATOR() external view returns (bytes32); - /// @notice Returns the type hash for the Supply intent. function SUPPLY_TYPEHASH() external view returns (bytes32); diff --git a/src/position-manager/libraries/EIP712Hash.sol b/src/position-manager/libraries/EIP712Hash.sol index 060bd4575..d114eafb8 100644 --- a/src/position-manager/libraries/EIP712Hash.sol +++ b/src/position-manager/libraries/EIP712Hash.sol @@ -2,7 +2,7 @@ // Copyright (c) 2025 Aave Labs pragma solidity ^0.8.20; -import {EIP712Types} from 'src/libraries/types/EIP712Types.sol'; +import {ISignatureGateway} from 'src/position-manager/interfaces/ISignatureGateway.sol'; /// @title EIP712Hash library /// @author Aave Labs @@ -29,14 +29,14 @@ library EIP712Hash { 0xd4350e1f25ecd62a35b50e8cd1e00bc34331ae8c728ee4dbb69ecf1023daecf7; bytes32 public constant UPDATE_USER_RISK_PREMIUM_TYPEHASH = - // keccak256('UpdateUserRiskPremium(address spoke,address user,uint256 nonce,uint256 deadline)') - 0xb41e132023782c9b02febf1b9b7fe98c4a73f57ebc63ba44cd71f6365ea09eaf; + // keccak256('UpdateUserRiskPremium(address spoke,address onBehalfOf,uint256 nonce,uint256 deadline)') + 0x915106098e3eee1fbe90aebcbfd68e931c539495af63e24066ebeebb638d3023; bytes32 public constant UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH = - // keccak256('UpdateUserDynamicConfig(address spoke,address user,uint256 nonce,uint256 deadline)') - 0xba177b1f5b5e1e709f62c19f03c97988c57752ba561de58f383ebee4e8d0a71c; + // keccak256('UpdateUserDynamicConfig(address spoke,address onBehalfOf,uint256 nonce,uint256 deadline)') + 0x4a168dd8b32d260d07d6f0be832e23035a65a47f788675b0b02270c68b987886; - function hash(EIP712Types.Supply calldata params) internal pure returns (bytes32) { + function hash(ISignatureGateway.Supply calldata params) internal pure returns (bytes32) { return keccak256( abi.encode( @@ -51,7 +51,7 @@ library EIP712Hash { ); } - function hash(EIP712Types.Withdraw calldata params) internal pure returns (bytes32) { + function hash(ISignatureGateway.Withdraw calldata params) internal pure returns (bytes32) { return keccak256( abi.encode( @@ -66,7 +66,7 @@ library EIP712Hash { ); } - function hash(EIP712Types.Borrow calldata params) internal pure returns (bytes32) { + function hash(ISignatureGateway.Borrow calldata params) internal pure returns (bytes32) { return keccak256( abi.encode( @@ -81,7 +81,7 @@ library EIP712Hash { ); } - function hash(EIP712Types.Repay calldata params) internal pure returns (bytes32) { + function hash(ISignatureGateway.Repay calldata params) internal pure returns (bytes32) { return keccak256( abi.encode( @@ -96,7 +96,9 @@ library EIP712Hash { ); } - function hash(EIP712Types.SetUsingAsCollateral calldata params) internal pure returns (bytes32) { + function hash( + ISignatureGateway.SetUsingAsCollateral calldata params + ) internal pure returns (bytes32) { return keccak256( abi.encode( @@ -111,13 +113,15 @@ library EIP712Hash { ); } - function hash(EIP712Types.UpdateUserRiskPremium calldata params) internal pure returns (bytes32) { + function hash( + ISignatureGateway.UpdateUserRiskPremium calldata params + ) internal pure returns (bytes32) { return keccak256( abi.encode( UPDATE_USER_RISK_PREMIUM_TYPEHASH, params.spoke, - params.user, + params.onBehalfOf, params.nonce, params.deadline ) @@ -125,14 +129,14 @@ library EIP712Hash { } function hash( - EIP712Types.UpdateUserDynamicConfig calldata params + ISignatureGateway.UpdateUserDynamicConfig calldata params ) internal pure returns (bytes32) { return keccak256( abi.encode( UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH, params.spoke, - params.user, + params.onBehalfOf, params.nonce, params.deadline ) diff --git a/src/spoke/AaveOracle.sol b/src/spoke/AaveOracle.sol index f89c3df0d..dccbac196 100644 --- a/src/spoke/AaveOracle.sol +++ b/src/spoke/AaveOracle.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.28; import {AggregatorV3Interface} from 'src/dependencies/chainlink/AggregatorV3Interface.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; import {IAaveOracle, IPriceOracle} from 'src/spoke/interfaces/IAaveOracle.sol'; /// @title AaveOracle @@ -10,29 +11,40 @@ import {IAaveOracle, IPriceOracle} from 'src/spoke/interfaces/IAaveOracle.sol'; /// @notice Provides reserve prices. /// @dev Oracles are spoke-specific, due to the usage of reserve id as index of the `_sources` mapping. contract AaveOracle is IAaveOracle { - /// @inheritdoc IPriceOracle - address public immutable SPOKE; - /// @inheritdoc IPriceOracle uint8 public immutable DECIMALS; /// @inheritdoc IAaveOracle string public DESCRIPTION; + /// @inheritdoc IPriceOracle + address public SPOKE; + + /// @dev The address of the deployer. + address private immutable DEPLOYER; + mapping(uint256 reserveId => AggregatorV3Interface) internal _sources; /// @dev Constructor. /// @dev `decimals` must match the spoke's decimals for compatibility. - /// @param spoke_ The address of the spoke contract. /// @param decimals_ The number of decimals for the oracle. /// @param description_ The description of the oracle. - constructor(address spoke_, uint8 decimals_, string memory description_) { - require(spoke_ != address(0), InvalidAddress()); - SPOKE = spoke_; + constructor(uint8 decimals_, string memory description_) { + DEPLOYER = msg.sender; DECIMALS = decimals_; DESCRIPTION = description_; } + /// @inheritdoc IAaveOracle + function setSpoke(address spoke) external { + require(msg.sender == DEPLOYER, OnlyDeployer()); + require(spoke != address(0), InvalidAddress()); + require(SPOKE == address(0), SpokeAlreadySet()); + require(ISpoke(spoke).ORACLE() == address(this), OracleMismatch()); + SPOKE = spoke; + emit SetSpoke(spoke); + } + /// @inheritdoc IAaveOracle function setReserveSource(uint256 reserveId, address source) external { require(msg.sender == SPOKE, OnlySpoke()); diff --git a/src/spoke/Spoke.sol b/src/spoke/Spoke.sol index d71faaa03..998b98924 100644 --- a/src/spoke/Spoke.sol +++ b/src/spoke/Spoke.sol @@ -4,48 +4,67 @@ pragma solidity 0.8.28; import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; +import {Math} from 'src/dependencies/openzeppelin/Math.sol'; import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; -import {SignatureChecker} from 'src/dependencies/openzeppelin/SignatureChecker.sol'; +import {ReentrancyGuardTransient} from 'src/dependencies/openzeppelin/ReentrancyGuardTransient.sol'; +import {Math} from 'src/dependencies/openzeppelin/Math.sol'; import {AccessManagedUpgradeable} from 'src/dependencies/openzeppelin-upgradeable/AccessManagedUpgradeable.sol'; -import {EIP712} from 'src/dependencies/solady/EIP712.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'; +import {SpokeUtils} from 'src/spoke/libraries/SpokeUtils.sol'; +import {EIP712Hash} from 'src/spoke/libraries/EIP712Hash.sol'; import {KeyValueList} from 'src/spoke/libraries/KeyValueList.sol'; import {LiquidationLogic} from 'src/spoke/libraries/LiquidationLogic.sol'; import {PositionStatusMap} from 'src/spoke/libraries/PositionStatusMap.sol'; import {ReserveFlags, ReserveFlagsMap} from 'src/spoke/libraries/ReserveFlagsMap.sol'; -import {UserPositionDebt} from 'src/spoke/libraries/UserPositionDebt.sol'; -import {NoncesKeyed} from 'src/utils/NoncesKeyed.sol'; +import {UserPositionUtils} from 'src/spoke/libraries/UserPositionUtils.sol'; +import {IntentConsumer} from 'src/utils/IntentConsumer.sol'; import {Multicall} from 'src/utils/Multicall.sol'; +import {ExtSload} from 'src/utils/ExtSload.sol'; import {IAaveOracle} from 'src/spoke/interfaces/IAaveOracle.sol'; import {IHubBase} from 'src/hub/interfaces/IHubBase.sol'; import {ISpokeBase, ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {SpokeStorage} from 'src/spoke/SpokeStorage.sol'; /// @title Spoke /// @author Aave Labs /// @notice Handles risk configuration & borrowing strategy for reserves and user positions. /// @dev Each reserve can be associated with a separate Hub. -abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradeable, EIP712 { +abstract contract Spoke is + ISpoke, + SpokeStorage, + AccessManagedUpgradeable, + IntentConsumer, + ExtSload, + Multicall, + ReentrancyGuardTransient +{ using SafeCast for *; using SafeERC20 for IERC20; using MathUtils for *; using PercentageMath for *; using WadRayMath for *; + using SpokeUtils for *; + using EIP712Hash for *; using KeyValueList for KeyValueList.List; - using LiquidationLogic for *; using PositionStatusMap for *; using ReserveFlagsMap for ReserveFlags; - using UserPositionDebt for ISpoke.UserPosition; + using UserPositionUtils for ISpoke.UserPosition; /// @inheritdoc ISpoke - bytes32 public constant SET_USER_POSITION_MANAGER_TYPEHASH = - // keccak256('SetUserPositionManager(address positionManager,address user,bool approve,uint256 nonce,uint256 deadline)') - 0x758d23a3c07218b7ea0b4f7f63903c4e9d5cbde72d3bcfe3e9896639025a0214; + bytes32 public constant SET_USER_POSITION_MANAGERS_TYPEHASH = + EIP712Hash.SET_USER_POSITION_MANAGERS_TYPEHASH; + + /// @inheritdoc ISpoke + uint16 public immutable MAX_USER_RESERVES_LIMIT; /// @inheritdoc ISpoke address public immutable ORACLE; + /// @dev The number of decimals used by the oracle. + uint8 internal constant ORACLE_DECIMALS = SpokeUtils.ORACLE_DECIMALS; + /// @dev The maximum allowed value for an asset identifier (inclusive). uint256 internal constant MAX_ALLOWED_ASSET_ID = type(uint16).max; @@ -53,7 +72,10 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea uint24 internal constant MAX_ALLOWED_COLLATERAL_RISK = 1000_00; /// @dev The maximum allowed value for a dynamic configuration key (inclusive). - uint256 internal constant MAX_ALLOWED_DYNAMIC_CONFIG_KEY = type(uint24).max; + uint256 internal constant MAX_ALLOWED_DYNAMIC_CONFIG_KEY = type(uint32).max; + + /// @dev The maximum allowed value for the maximum number of reserves a user can have (collateral or borrowed) (inclusive). + uint16 internal constant MAX_ALLOWED_USER_RESERVES_LIMIT = type(uint16).max; /// @dev The minimum health factor below which a position is considered unhealthy and subject to liquidation. /// @dev Expressed in WAD (18 decimals) (e.g. 1e18 is 1.00). @@ -61,38 +83,10 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea LiquidationLogic.HEALTH_FACTOR_LIQUIDATION_THRESHOLD; /// @dev The maximum amount considered as dust for a user's collateral and debt balances after a liquidation. - /// @dev Expressed in USD with 26 decimals. + /// @dev Worth 1000 USD, expressed in units of Value. 1e26 represents 1 USD. uint256 internal constant DUST_LIQUIDATION_THRESHOLD = LiquidationLogic.DUST_LIQUIDATION_THRESHOLD; - /// @dev The number of decimals used by the oracle. - uint8 internal constant ORACLE_DECIMALS = 8; - - /// @dev Number of reserves listed in the Spoke. - uint256 internal _reserveCount; - - /// @dev Map of user addresses and reserve identifiers to user positions. - mapping(address user => mapping(uint256 reserveId => UserPosition)) internal _userPositions; - - /// @dev Map of user addresses to their position status. - mapping(address user => PositionStatus) internal _positionStatus; - - /// @dev Map of reserve identifiers to their Reserve data. - mapping(uint256 reserveId => Reserve) internal _reserves; - - /// @dev Map of position manager addresses to their configuration data. - mapping(address positionManager => PositionManagerConfig) internal _positionManager; - - /// @dev Map of reserve identifiers and dynamic configuration keys to the dynamic configuration data. - mapping(uint256 reserveId => mapping(uint24 dynamicConfigKey => DynamicReserveConfig)) - internal _dynamicConfig; - - /// @dev Liquidation configuration for the Spoke. - LiquidationConfig internal _liquidationConfig; - - /// @dev Map of hub addresses and asset identifiers to whether the reserve exists. - mapping(address hub => mapping(uint256 assetId => bool)) internal _reserveExists; - /// @notice Modifier that checks if the caller is an approved positionManager for `onBehalfOf`. modifier onlyPositionManager(address onBehalfOf) { require(_isPositionManager({user: onBehalfOf, manager: msg.sender}), Unauthorized()); @@ -101,9 +95,12 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea /// @dev Constructor. /// @param oracle_ The address of the AaveOracle contract. - constructor(address oracle_) { + /// @param maxUserReservesLimit_ The maximum number of collateral and borrow reserves a user can have. + constructor(address oracle_, uint16 maxUserReservesLimit_) { require(IAaveOracle(oracle_).DECIMALS() == ORACLE_DECIMALS, InvalidOracleDecimals()); + require(maxUserReservesLimit_ > 0, InvalidMaxUserReservesLimit()); ORACLE = oracle_; + MAX_USER_RESERVES_LIMIT = maxUserReservesLimit_; } /// @dev To be overridden by the inheriting Spoke instance contract. @@ -131,35 +128,35 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea ) external restricted returns (uint256) { require(hub != address(0), InvalidAddress()); require(assetId <= MAX_ALLOWED_ASSET_ID, InvalidAssetId()); - require(!_reserveExists[hub][assetId], ReserveExists()); + require(!_isAssetIdListed(hub, assetId, _hubAssetIdToReserveId[hub][assetId]), ReserveExists()); _validateReserveConfig(config); _validateDynamicReserveConfig(dynamicConfig); uint256 reserveId = _reserveCount++; - uint24 dynamicConfigKey; // 0 as first key to use + _hubAssetIdToReserveId[hub][assetId] = reserveId; (address underlying, uint8 decimals) = IHubBase(hub).getAssetUnderlyingAndDecimals(assetId); require(underlying != address(0), AssetNotListed()); + require(decimals <= WadRayMath.WAD_DECIMALS, InvalidAssetDecimals()); _updateReservePriceSource(reserveId, priceSource); + uint32 dynamicConfigKey; // 0 as first key to use _reserves[reserveId] = Reserve({ underlying: underlying, hub: IHubBase(hub), assetId: assetId.toUint16(), decimals: decimals, - dynamicConfigKey: dynamicConfigKey, collateralRisk: config.collateralRisk, flags: ReserveFlagsMap.create({ initPaused: config.paused, initFrozen: config.frozen, initBorrowable: config.borrowable, - initLiquidatable: config.liquidatable, initReceiveSharesEnabled: config.receiveSharesEnabled - }) + }), + dynamicConfigKey: dynamicConfigKey }); _dynamicConfig[reserveId][dynamicConfigKey] = dynamicConfig; - _reserveExists[hub][assetId] = true; emit AddReserve(reserveId, assetId, hub); emit UpdateReserveConfig(reserveId, config); @@ -173,14 +170,13 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea uint256 reserveId, ReserveConfig calldata config ) external restricted { - Reserve storage reserve = _getReserve(reserveId); + Reserve storage reserve = _reserves.get(reserveId); _validateReserveConfig(config); reserve.collateralRisk = config.collateralRisk; reserve.flags = ReserveFlagsMap.create({ initPaused: config.paused, initFrozen: config.frozen, initBorrowable: config.borrowable, - initLiquidatable: config.liquidatable, initReceiveSharesEnabled: config.receiveSharesEnabled }); emit UpdateReserveConfig(reserveId, config); @@ -196,12 +192,12 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea function addDynamicReserveConfig( uint256 reserveId, DynamicReserveConfig calldata dynamicConfig - ) external restricted returns (uint24) { + ) external restricted returns (uint32) { require(reserveId < _reserveCount, ReserveNotListed()); - uint24 dynamicConfigKey = _reserves[reserveId].dynamicConfigKey; + uint32 dynamicConfigKey = _reserves[reserveId].dynamicConfigKey; require(dynamicConfigKey < MAX_ALLOWED_DYNAMIC_CONFIG_KEY, MaximumDynamicConfigKeyReached()); _validateDynamicReserveConfig(dynamicConfig); - dynamicConfigKey = dynamicConfigKey.uncheckedAdd(1).toUint24(); + dynamicConfigKey = dynamicConfigKey.uncheckedAdd(1).toUint32(); _reserves[reserveId].dynamicConfigKey = dynamicConfigKey; _dynamicConfig[reserveId][dynamicConfigKey] = dynamicConfig; emit AddDynamicReserveConfig(reserveId, dynamicConfigKey, dynamicConfig); @@ -211,7 +207,7 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea /// @inheritdoc ISpoke function updateDynamicReserveConfig( uint256 reserveId, - uint24 dynamicConfigKey, + uint32 dynamicConfigKey, DynamicReserveConfig calldata dynamicConfig ) external restricted { require(reserveId < _reserveCount, ReserveNotListed()); @@ -231,8 +227,8 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea uint256 reserveId, uint256 amount, address onBehalfOf - ) external onlyPositionManager(onBehalfOf) returns (uint256, uint256) { - Reserve storage reserve = _getReserve(reserveId); + ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; _validateSupply(reserve.flags); @@ -250,8 +246,8 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea uint256 reserveId, uint256 amount, address onBehalfOf - ) external onlyPositionManager(onBehalfOf) returns (uint256, uint256) { - Reserve storage reserve = _getReserve(reserveId); + ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; _validateWithdraw(reserve.flags); IHubBase hub = reserve.hub; @@ -280,8 +276,8 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea uint256 reserveId, uint256 amount, address onBehalfOf - ) external onlyPositionManager(onBehalfOf) returns (uint256, uint256) { - Reserve storage reserve = _getReserve(reserveId); + ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; _validateBorrow(reserve.flags); @@ -290,6 +286,11 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea uint256 drawnShares = hub.draw(reserve.assetId, amount, msg.sender); userPosition.drawnShares += drawnShares.toUint120(); if (!positionStatus.isBorrowing(reserveId)) { + require( + MAX_USER_RESERVES_LIMIT == MAX_ALLOWED_USER_RESERVES_LIMIT || + positionStatus.borrowCount(_reserveCount) < MAX_USER_RESERVES_LIMIT, + MaximumUserReservesExceeded() + ); positionStatus.setBorrowing(reserveId, true); } @@ -306,8 +307,8 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea uint256 reserveId, uint256 amount, address onBehalfOf - ) external onlyPositionManager(onBehalfOf) returns (uint256, uint256) { - Reserve storage reserve = _getReserve(reserveId); + ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; _validateRepay(reserve.flags); @@ -316,7 +317,7 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea .calculateRestoreAmount(drawnIndex, amount); uint256 restoredShares = drawnDebtRestored.rayDivDown(drawnIndex); - IHubBase.PremiumDelta memory premiumDelta = userPosition.getPremiumDelta({ + IHubBase.PremiumDelta memory premiumDelta = userPosition.calculatePremiumDelta({ drawnSharesTaken: restoredShares, drawnIndex: drawnIndex, riskPremium: _positionStatus[onBehalfOf].riskPremium, @@ -350,53 +351,41 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea address user, uint256 debtToCover, bool receiveShares - ) external { - Reserve storage collateralReserve = _getReserve(collateralReserveId); - Reserve storage debtReserve = _getReserve(debtReserveId); - DynamicReserveConfig storage collateralDynConfig = _dynamicConfig[collateralReserveId][ - _userPositions[user][collateralReserveId].dynamicConfigKey - ]; + ) external nonReentrant { UserAccountData memory userAccountData = _calculateUserAccountData(user); - - uint256 drawnIndex = debtReserve.hub.getAssetDrawnIndex(debtReserve.assetId); - (uint256 drawnDebt, uint256 premiumDebtRay) = _userPositions[user][debtReserveId].getDebt( - drawnIndex - ); - LiquidationLogic.LiquidateUserParams memory params = LiquidationLogic.LiquidateUserParams({ collateralReserveId: collateralReserveId, debtReserveId: debtReserveId, + liquidationConfig: _liquidationConfig, oracle: ORACLE, user: user, debtToCover: debtToCover, - healthFactor: userAccountData.healthFactor, - drawnDebt: drawnDebt, - premiumDebtRay: premiumDebtRay, - drawnIndex: drawnIndex, - totalDebtValue: userAccountData.totalDebtValue, - activeCollateralCount: userAccountData.activeCollateralCount, - borrowedCount: userAccountData.borrowedCount, + userAccountData: userAccountData, liquidator: msg.sender, receiveShares: receiveShares }); - bool isUserInDeficit = LiquidationLogic.liquidateUser( - collateralReserve, - debtReserve, - _userPositions, - _positionStatus, - _liquidationConfig, - collateralDynConfig, - params - ); + bool isUserInDeficit = LiquidationLogic.liquidateUser({ + reserves: _reserves, + userPositions: _userPositions, + positionStatus: _positionStatus, + dynamicConfig: _dynamicConfig, + params: params + }); - uint256 newRiskPremium = 0; if (isUserInDeficit) { - _reportDeficit(user); + // report deficit for all debt reserves, including the reserve being repaid + LiquidationLogic.notifyReportDeficit( + _reserves, + _userPositions, + _positionStatus, + _reserveCount, + user + ); } else { - newRiskPremium = _calculateUserAccountData(user).riskPremium; + uint256 newRiskPremium = _calculateUserAccountData(user).riskPremium; + _notifyRiskPremiumUpdate(user, newRiskPremium); } - _notifyRiskPremiumUpdate(user, newRiskPremium); } /// @inheritdoc ISpoke @@ -404,13 +393,13 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea uint256 reserveId, bool usingAsCollateral, address onBehalfOf - ) external onlyPositionManager(onBehalfOf) { - _validateSetUsingAsCollateral(_getReserve(reserveId).flags, usingAsCollateral); + ) external nonReentrant onlyPositionManager(onBehalfOf) { + Reserve storage reserve = _reserves.get(reserveId); PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; - if (positionStatus.isUsingAsCollateral(reserveId) == usingAsCollateral) { return; } + _validateSetUsingAsCollateral(positionStatus, reserve.flags, usingAsCollateral); positionStatus.setUsingAsCollateral(reserveId, usingAsCollateral); if (usingAsCollateral) { @@ -424,7 +413,7 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea } /// @inheritdoc ISpoke - function updateUserRiskPremium(address onBehalfOf) external { + function updateUserRiskPremium(address onBehalfOf) external nonReentrant { if (!_isPositionManager({user: onBehalfOf, manager: msg.sender})) { _checkCanCall(msg.sender, msg.data); } @@ -433,7 +422,7 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea } /// @inheritdoc ISpoke - function updateUserDynamicConfig(address onBehalfOf) external { + function updateUserDynamicConfig(address onBehalfOf) external nonReentrant { if (!_isPositionManager({user: onBehalfOf, manager: msg.sender})) { _checkCanCall(msg.sender, msg.data); } @@ -447,30 +436,25 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea } /// @inheritdoc ISpoke - function setUserPositionManagerWithSig( - address positionManager, - address user, - bool approve, - uint256 nonce, - uint256 deadline, + function setUserPositionManagersWithSig( + SetUserPositionManagers calldata params, bytes calldata signature ) external { - require(block.timestamp <= deadline, InvalidSignature()); - bytes32 digest = _hashTypedData( - keccak256( - abi.encode( - SET_USER_POSITION_MANAGER_TYPEHASH, - positionManager, - user, - approve, - nonce, - deadline - ) - ) - ); - require(SignatureChecker.isValidSignatureNow(user, digest, signature), InvalidSignature()); - _useCheckedNonce(user, nonce); - _setUserPositionManager({positionManager: positionManager, user: user, approve: approve}); + _verifyAndConsumeIntent({ + signer: params.onBehalfOf, + intentHash: params.hash(), + nonce: params.nonce, + deadline: params.deadline, + signature: signature + }); + + for (uint256 i = 0; i < params.updates.length; ++i) { + _setUserPositionManager({ + positionManager: params.updates[i].positionManager, + user: params.onBehalfOf, + approve: params.updates[i].approve + }); + } } /// @inheritdoc ISpoke @@ -520,43 +504,49 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea /// @inheritdoc ISpokeBase function getReserveSuppliedAssets(uint256 reserveId) external view returns (uint256) { - Reserve storage reserve = _getReserve(reserveId); + Reserve storage reserve = _reserves.get(reserveId); return reserve.hub.getSpokeAddedAssets(reserve.assetId, address(this)); } /// @inheritdoc ISpokeBase function getReserveSuppliedShares(uint256 reserveId) external view returns (uint256) { - Reserve storage reserve = _getReserve(reserveId); + Reserve storage reserve = _reserves.get(reserveId); return reserve.hub.getSpokeAddedShares(reserve.assetId, address(this)); } /// @inheritdoc ISpokeBase function getReserveDebt(uint256 reserveId) external view returns (uint256, uint256) { - Reserve storage reserve = _getReserve(reserveId); + Reserve storage reserve = _reserves.get(reserveId); return reserve.hub.getSpokeOwed(reserve.assetId, address(this)); } /// @inheritdoc ISpokeBase function getReserveTotalDebt(uint256 reserveId) external view returns (uint256) { - Reserve storage reserve = _getReserve(reserveId); + Reserve storage reserve = _reserves.get(reserveId); return reserve.hub.getSpokeTotalOwed(reserve.assetId, address(this)); } + /// @inheritdoc ISpoke + function getReserveId(address hub, uint256 assetId) external view returns (uint256) { + uint256 reserveId = _hubAssetIdToReserveId[hub][assetId]; + require(_isAssetIdListed(hub, assetId, reserveId), ReserveNotListed()); + return reserveId; + } + /// @inheritdoc ISpoke function getReserve(uint256 reserveId) external view returns (Reserve memory) { - return _getReserve(reserveId); + return _reserves.get(reserveId); } /// @inheritdoc ISpoke function getReserveConfig(uint256 reserveId) external view returns (ReserveConfig memory) { - Reserve storage reserve = _getReserve(reserveId); + Reserve storage reserve = _reserves.get(reserveId); return ReserveConfig({ collateralRisk: reserve.collateralRisk, paused: reserve.flags.paused(), frozen: reserve.flags.frozen(), borrowable: reserve.flags.borrowable(), - liquidatable: reserve.flags.liquidatable(), receiveSharesEnabled: reserve.flags.receiveSharesEnabled() }); } @@ -564,9 +554,9 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea /// @inheritdoc ISpoke function getDynamicReserveConfig( uint256 reserveId, - uint24 dynamicConfigKey + uint32 dynamicConfigKey ) external view returns (DynamicReserveConfig memory) { - _getReserve(reserveId); + _reserves.get(reserveId); return _dynamicConfig[reserveId][dynamicConfigKey]; } @@ -575,14 +565,14 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea uint256 reserveId, address user ) external view returns (bool, bool) { - _getReserve(reserveId); + _reserves.get(reserveId); PositionStatus storage positionStatus = _positionStatus[user]; return (positionStatus.isUsingAsCollateral(reserveId), positionStatus.isBorrowing(reserveId)); } /// @inheritdoc ISpokeBase function getUserSuppliedAssets(uint256 reserveId, address user) external view returns (uint256) { - Reserve storage reserve = _getReserve(reserveId); + Reserve storage reserve = _reserves.get(reserveId); return reserve.hub.previewRemoveByShares( reserve.assetId, @@ -592,13 +582,13 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea /// @inheritdoc ISpokeBase function getUserSuppliedShares(uint256 reserveId, address user) external view returns (uint256) { - _getReserve(reserveId); + _reserves.get(reserveId); return _userPositions[user][reserveId].suppliedShares; } /// @inheritdoc ISpokeBase function getUserDebt(uint256 reserveId, address user) external view returns (uint256, uint256) { - Reserve storage reserve = _getReserve(reserveId); + Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[user][reserveId]; (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt( reserve.hub, @@ -609,7 +599,7 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea /// @inheritdoc ISpokeBase function getUserTotalDebt(uint256 reserveId, address user) external view returns (uint256) { - Reserve storage reserve = _getReserve(reserveId); + Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[user][reserveId]; (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt( reserve.hub, @@ -620,7 +610,7 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea /// @inheritdoc ISpokeBase function getUserPremiumDebtRay(uint256 reserveId, address user) external view returns (uint256) { - Reserve storage reserve = _getReserve(reserveId); + Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[user][reserveId]; (, uint256 premiumDebtRay) = userPosition.getDebt(reserve.hub, reserve.assetId); return premiumDebtRay; @@ -631,7 +621,7 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea uint256 reserveId, address user ) external view returns (UserPosition memory) { - _getReserve(reserveId); + _reserves.get(reserveId); return _userPositions[user][reserveId]; } @@ -652,7 +642,7 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea address user, uint256 healthFactor ) external view returns (uint256) { - _getReserve(reserveId); + _reserves.get(reserveId); return LiquidationLogic.calculateLiquidationBonus({ healthFactorForMaxBonus: _liquidationConfig.healthFactorForMaxBonus, @@ -674,11 +664,6 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea return _isPositionManager(user, positionManager); } - /// @inheritdoc ISpoke - function DOMAIN_SEPARATOR() external view returns (bytes32) { - return _domainSeparator(); - } - /// @inheritdoc ISpoke function getLiquidationLogic() external pure returns (address) { return address(LiquidationLogic); @@ -692,8 +677,6 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea function _setUserPositionManager(address positionManager, address user, bool approve) internal { PositionManagerConfig storage config = _positionManager[positionManager]; - // only allow approval when position manager is active for improved UX - require(!approve || config.active, InactivePositionManager()); config.approval[user] = approve; emit SetUserPositionManager(user, positionManager, approve); } @@ -719,6 +702,8 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea } /// @notice Process the user account data and updates dynamic config of the user if `refreshConfig` is true. + /// @dev Collateral is rounded against the user, while debt is calculated with full precision. + /// @dev If user has no debt, it returns health factor of `type(uint256).max` and risk premium of 0. function _processUserAccountData( address user, bool refreshConfig @@ -739,7 +724,7 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea Reserve storage reserve = _reserves[reserveId]; uint256 assetPrice = IAaveOracle(ORACLE).getReservePrice(reserveId); - uint256 assetUnit = MathUtils.uncheckedExp(10, reserve.decimals); + uint256 assetDecimals = reserve.decimals; if (collateral) { uint256 collateralFactor = _dynamicConfig[reserveId][ @@ -751,10 +736,10 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea uint256 suppliedShares = userPosition.suppliedShares; if (suppliedShares > 0) { // cannot round down to zero - uint256 userCollateralValue = (reserve.hub.previewRemoveByShares( - reserve.assetId, - suppliedShares - ) * assetPrice).wadDivDown(assetUnit); + uint256 userCollateralValue = reserve + .hub + .previewRemoveByShares(reserve.assetId, suppliedShares) + .toValue({decimals: assetDecimals, price: assetPrice}); accountData.totalCollateralValue += userCollateralValue; collateralInfo.add( accountData.activeCollateralCount, @@ -768,55 +753,61 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea } if (borrowing) { - (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt( + UserPositionUtils.DebtComponents memory debtComponents = userPosition.getDebtComponents( reserve.hub, reserve.assetId ); - // we can simplify since there is no precision loss due to the division here - accountData.totalDebtValue += ((drawnDebt + premiumDebtRay.fromRayUp()) * assetPrice) - .wadDivUp(assetUnit); - accountData.borrowedCount = accountData.borrowedCount.uncheckedAdd(1); + uint256 debtRay = debtComponents.drawnShares * debtComponents.drawnIndex + + debtComponents.premiumDebtRay; + accountData.totalDebtValueRay += debtRay.toValue({ + decimals: assetDecimals, + price: assetPrice + }); + accountData.borrowCount = accountData.borrowCount.uncheckedAdd(1); } } - if (accountData.totalDebtValue > 0) { - // at this point, `avgCollateralFactor` is the collateral-weighted sum (scaled by `collateralFactor` in BPS) - // health factor uses this directly for simplicity - // the division by `totalCollateralValue` to compute the weighted average is done later - accountData.healthFactor = accountData - .avgCollateralFactor - .wadDivDown(accountData.totalDebtValue) - .fromBpsDown(); + if (accountData.totalDebtValueRay > 0) { + // at this point, `avgCollateralFactor` is the total collateral value weighted by collateral factors, + // expressed in units of Value and scaled by BPS. We convert it from BPS to WAD, since this will + // ultimately define the scaling factor of the health factor. + accountData.healthFactor = Math.mulDiv( + accountData.avgCollateralFactor.bpsToWad(), + WadRayMath.RAY, + accountData.totalDebtValueRay, + Math.Rounding.Floor + ); } else { accountData.healthFactor = type(uint256).max; } if (accountData.totalCollateralValue > 0) { - accountData.avgCollateralFactor = accountData - .avgCollateralFactor - .wadDivDown(accountData.totalCollateralValue) - .fromBpsDown(); + accountData.avgCollateralFactor = + accountData.avgCollateralFactor.bpsToWad() / accountData.totalCollateralValue; } // sort by collateral risk in ASC, collateral value in DESC collateralInfo.sortByKey(); // runs until either the collateral or debt is exhausted - uint256 debtValueLeftToCover = accountData.totalDebtValue; + uint256 totalDebtValue = accountData.totalDebtValueRay.fromRayUp(); + uint256 debtValueLeftToCover = totalDebtValue; for (uint256 index = 0; index < collateralInfo.length(); ++index) { if (debtValueLeftToCover == 0) { break; } - (uint256 collateralRisk, uint256 userCollateralValue) = collateralInfo.get(index); + (uint256 collateralRisk, uint256 userCollateralValue) = collateralInfo.uncheckedAt(index); userCollateralValue = userCollateralValue.min(debtValueLeftToCover); accountData.riskPremium += userCollateralValue * collateralRisk; debtValueLeftToCover = debtValueLeftToCover.uncheckedSub(userCollateralValue); } - if (debtValueLeftToCover < accountData.totalDebtValue) { - accountData.riskPremium /= accountData.totalDebtValue.uncheckedSub(debtValueLeftToCover); + if (debtValueLeftToCover < totalDebtValue) { + accountData.riskPremium = accountData.riskPremium.divUp( + totalDebtValue.uncheckedSub(debtValueLeftToCover) + ); } return accountData; @@ -843,7 +834,7 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea uint256 assetId = reserve.assetId; IHubBase hub = reserve.hub; - IHubBase.PremiumDelta memory premiumDelta = userPosition.getPremiumDelta({ + IHubBase.PremiumDelta memory premiumDelta = userPosition.calculatePremiumDelta({ drawnSharesTaken: 0, drawnIndex: hub.getAssetDrawnIndex(assetId), riskPremium: newRiskPremium, @@ -854,46 +845,8 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea userPosition.applyPremiumDelta(premiumDelta); emit RefreshPremiumDebt(reserveId, user, premiumDelta); } - emit UpdateUserRiskPremium(user, newRiskPremium); - } - - /// @notice Reports deficits for all debt reserves of the user, including the reserve being repaid during liquidation. - /// @dev Deficit validation should already have occurred during liquidation. - /// @dev It clears the user position, setting drawn debt, premium debt, and risk premium to zero. - function _reportDeficit(address user) internal { - PositionStatus storage positionStatus = _positionStatus[user]; - - uint256 reserveId = _reserveCount; - while ((reserveId = positionStatus.nextBorrowing(reserveId)) != PositionStatusMap.NOT_FOUND) { - UserPosition storage userPosition = _userPositions[user][reserveId]; - Reserve storage reserve = _reserves[reserveId]; - IHubBase hub = reserve.hub; - uint256 assetId = reserve.assetId; - - uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); - (uint256 drawnDebtReported, uint256 premiumDebtRay) = userPosition.getDebt(drawnIndex); - uint256 deficitShares = drawnDebtReported.rayDivDown(drawnIndex); - - IHubBase.PremiumDelta memory premiumDelta = userPosition.getPremiumDelta({ - drawnSharesTaken: deficitShares, - drawnIndex: drawnIndex, - riskPremium: 0, - restoredPremiumRay: premiumDebtRay - }); - - hub.reportDeficit(assetId, drawnDebtReported, premiumDelta); - userPosition.applyPremiumDelta(premiumDelta); - userPosition.drawnShares -= deficitShares.toUint120(); - positionStatus.setBorrowing(reserveId, false); - - emit ReportDeficit(reserveId, user, deficitShares, premiumDelta); - } - } - function _getReserve(uint256 reserveId) internal view returns (Reserve storage) { - Reserve storage reserve = _reserves[reserveId]; - require(address(reserve.hub) != address(0), ReserveNotListed()); - return reserve; + emit UpdateUserRiskPremium(user, newRiskPremium); } /// @dev CollateralFactor of historical config keys cannot be 0, which allows liquidations to proceed. @@ -902,7 +855,7 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea DynamicReserveConfig calldata newConfig ) internal view { // sufficient check since maxLiquidationBonus is always >= 100_00 - require(currentConfig.maxLiquidationBonus > 0, ConfigKeyUninitialized()); + require(currentConfig.maxLiquidationBonus > 0, DynamicConfigKeyUninitialized()); require(newConfig.collateralFactor > 0, InvalidCollateralFactor()); _validateDynamicReserveConfig(newConfig); } @@ -927,10 +880,30 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea require(!flags.paused(), ReservePaused()); } - function _validateSetUsingAsCollateral(ReserveFlags flags, bool usingAsCollateral) internal pure { + function _validateSetUsingAsCollateral( + PositionStatus storage positionStatus, + ReserveFlags flags, + bool usingAsCollateral + ) internal view { require(!flags.paused(), ReservePaused()); - // can disable as collateral if the reserve is frozen - require(!usingAsCollateral || !flags.frozen(), ReserveFrozen()); + if (usingAsCollateral) { + // disabling as collateral is allowed when reserve is frozen + require(!flags.frozen(), ReserveFrozen()); + // this must be a new collateral, otherwise would have short-circuited + require( + MAX_USER_RESERVES_LIMIT == MAX_ALLOWED_USER_RESERVES_LIMIT || + positionStatus.collateralCount(_reserveCount) < MAX_USER_RESERVES_LIMIT, + MaximumUserReservesExceeded() + ); + } + } + + function _isAssetIdListed( + address hub, + uint256 assetId, + uint256 reserveId + ) internal view returns (bool) { + return _reserves[reserveId].assetId == assetId && address(_reserves[reserveId].hub) == hub; } /// @notice Returns whether `manager` is active & approved positionManager for `user`. @@ -951,7 +924,7 @@ abstract contract Spoke is ISpoke, Multicall, NoncesKeyed, AccessManagedUpgradea config.collateralFactor < PercentageMath.PERCENTAGE_FACTOR && config.maxLiquidationBonus >= PercentageMath.PERCENTAGE_FACTOR && config.maxLiquidationBonus.percentMulUp(config.collateralFactor) < - PercentageMath.PERCENTAGE_FACTOR, + PercentageMath.PERCENTAGE_FACTOR, InvalidCollateralFactorAndMaxLiquidationBonus() ); require(config.liquidationFee <= PercentageMath.PERCENTAGE_FACTOR, InvalidLiquidationFee()); diff --git a/src/spoke/SpokeConfigurator.sol b/src/spoke/SpokeConfigurator.sol index 15784fe82..52ee2e4ae 100644 --- a/src/spoke/SpokeConfigurator.sol +++ b/src/spoke/SpokeConfigurator.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.28; import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; -import {Ownable2Step, Ownable} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; +import {AccessManaged} from 'src/dependencies/openzeppelin/AccessManaged.sol'; import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; import {ISpokeConfigurator} from 'src/spoke/interfaces/ISpokeConfigurator.sol'; @@ -11,21 +11,21 @@ import {ISpokeConfigurator} from 'src/spoke/interfaces/ISpokeConfigurator.sol'; /// @author Aave Labs /// @notice Handles administrative functions on the spoke. /// @dev Must be granted permission by the spoke. -contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { +contract SpokeConfigurator is AccessManaged, ISpokeConfigurator { using SafeCast for uint256; mapping(address spoke => uint256) internal _maxReserves; /// @dev Constructor. - /// @param owner_ The address of the owner. - constructor(address owner_) Ownable(owner_) {} + /// @param authority_ The address of the authority contract which manages permissions. + constructor(address authority_) AccessManaged(authority_) {} /// @inheritdoc ISpokeConfigurator function updateReservePriceSource( address spoke, uint256 reserveId, address priceSource - ) external onlyOwner { + ) external restricted { ISpoke(spoke).updateReservePriceSource(reserveId, priceSource); } @@ -33,7 +33,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { function updateLiquidationTargetHealthFactor( address spoke, uint256 targetHealthFactor - ) external onlyOwner { + ) external restricted { ISpoke targetSpoke = ISpoke(spoke); ISpoke.LiquidationConfig memory liquidationConfig = targetSpoke.getLiquidationConfig(); liquidationConfig.targetHealthFactor = targetHealthFactor.toUint128(); @@ -44,7 +44,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { function updateHealthFactorForMaxBonus( address spoke, uint256 healthFactorForMaxBonus - ) external onlyOwner { + ) external restricted { ISpoke targetSpoke = ISpoke(spoke); ISpoke.LiquidationConfig memory liquidationConfig = targetSpoke.getLiquidationConfig(); liquidationConfig.healthFactorForMaxBonus = healthFactorForMaxBonus.toUint64(); @@ -55,7 +55,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { function updateLiquidationBonusFactor( address spoke, uint256 liquidationBonusFactor - ) external onlyOwner { + ) external restricted { ISpoke targetSpoke = ISpoke(spoke); ISpoke.LiquidationConfig memory liquidationConfig = targetSpoke.getLiquidationConfig(); liquidationConfig.liquidationBonusFactor = liquidationBonusFactor.toUint16(); @@ -66,12 +66,12 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { function updateLiquidationConfig( address spoke, ISpoke.LiquidationConfig calldata liquidationConfig - ) external onlyOwner { + ) external restricted { ISpoke(spoke).updateLiquidationConfig(liquidationConfig); } /// @inheritdoc ISpokeConfigurator - function updateMaxReserves(address spoke, uint256 maxReserves) external onlyOwner { + function updateMaxReserves(address spoke, uint256 maxReserves) external restricted { _maxReserves[spoke] = maxReserves; emit UpdateMaxReserves(spoke, maxReserves); } @@ -84,7 +84,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { address priceSource, ISpoke.ReserveConfig calldata config, ISpoke.DynamicReserveConfig calldata dynamicConfig - ) external onlyOwner returns (uint256) { + ) external restricted returns (uint256) { require( ISpoke(spoke).getReserveCount() < _maxReserves[spoke], MaximumReservesReached(spoke, _maxReserves[spoke]) @@ -93,7 +93,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { } /// @inheritdoc ISpokeConfigurator - function updatePaused(address spoke, uint256 reserveId, bool paused) external onlyOwner { + function updatePaused(address spoke, uint256 reserveId, bool paused) external restricted { ISpoke targetSpoke = ISpoke(spoke); ISpoke.ReserveConfig memory reserveConfig = targetSpoke.getReserveConfig(reserveId); reserveConfig.paused = paused; @@ -101,7 +101,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { } /// @inheritdoc ISpokeConfigurator - function updateFrozen(address spoke, uint256 reserveId, bool frozen) external onlyOwner { + function updateFrozen(address spoke, uint256 reserveId, bool frozen) external restricted { ISpoke targetSpoke = ISpoke(spoke); ISpoke.ReserveConfig memory reserveConfig = targetSpoke.getReserveConfig(reserveId); reserveConfig.frozen = frozen; @@ -109,31 +109,19 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { } /// @inheritdoc ISpokeConfigurator - function updateBorrowable(address spoke, uint256 reserveId, bool borrowable) external onlyOwner { + function updateBorrowable(address spoke, uint256 reserveId, bool borrowable) external restricted { ISpoke targetSpoke = ISpoke(spoke); ISpoke.ReserveConfig memory reserveConfig = targetSpoke.getReserveConfig(reserveId); reserveConfig.borrowable = borrowable; targetSpoke.updateReserveConfig(reserveId, reserveConfig); } - /// @inheritdoc ISpokeConfigurator - function updateLiquidatable( - address spoke, - uint256 reserveId, - bool liquidatable - ) external onlyOwner { - ISpoke targetSpoke = ISpoke(spoke); - ISpoke.ReserveConfig memory reserveConfig = targetSpoke.getReserveConfig(reserveId); - reserveConfig.liquidatable = liquidatable; - targetSpoke.updateReserveConfig(reserveId, reserveConfig); - } - /// @inheritdoc ISpokeConfigurator function updateReceiveSharesEnabled( address spoke, uint256 reserveId, bool receiveSharesEnabled - ) external onlyOwner { + ) external restricted { ISpoke targetSpoke = ISpoke(spoke); ISpoke.ReserveConfig memory reserveConfig = targetSpoke.getReserveConfig(reserveId); reserveConfig.receiveSharesEnabled = receiveSharesEnabled; @@ -145,7 +133,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { address spoke, uint256 reserveId, uint256 collateralRisk - ) external onlyOwner { + ) external restricted { ISpoke targetSpoke = ISpoke(spoke); ISpoke.ReserveConfig memory reserveConfig = targetSpoke.getReserveConfig(reserveId); reserveConfig.collateralRisk = collateralRisk.toUint24(); @@ -157,7 +145,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { address spoke, uint256 reserveId, uint16 collateralFactor - ) external onlyOwner returns (uint24) { + ) external restricted returns (uint32) { ISpoke targetSpoke = ISpoke(spoke); ISpoke.DynamicReserveConfig memory dynamicReserveConfig = targetSpoke.getDynamicReserveConfig( reserveId, @@ -171,9 +159,9 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { function updateCollateralFactor( address spoke, uint256 reserveId, - uint24 dynamicConfigKey, + uint32 dynamicConfigKey, uint16 collateralFactor - ) external onlyOwner { + ) external restricted { ISpoke targetSpoke = ISpoke(spoke); ISpoke.DynamicReserveConfig memory dynamicReserveConfig = targetSpoke.getDynamicReserveConfig( reserveId, @@ -188,7 +176,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { address spoke, uint256 reserveId, uint256 maxLiquidationBonus - ) external onlyOwner returns (uint24) { + ) external restricted returns (uint32) { ISpoke targetSpoke = ISpoke(spoke); ISpoke.DynamicReserveConfig memory dynamicReserveConfig = targetSpoke.getDynamicReserveConfig( reserveId, @@ -202,9 +190,9 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { function updateMaxLiquidationBonus( address spoke, uint256 reserveId, - uint24 dynamicConfigKey, + uint32 dynamicConfigKey, uint256 maxLiquidationBonus - ) external onlyOwner { + ) external restricted { ISpoke targetSpoke = ISpoke(spoke); ISpoke.DynamicReserveConfig memory dynamicReserveConfig = targetSpoke.getDynamicReserveConfig( reserveId, @@ -219,7 +207,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { address spoke, uint256 reserveId, uint256 liquidationFee - ) external onlyOwner returns (uint24) { + ) external restricted returns (uint32) { ISpoke targetSpoke = ISpoke(spoke); ISpoke.DynamicReserveConfig memory dynamicReserveConfig = targetSpoke.getDynamicReserveConfig( reserveId, @@ -233,9 +221,9 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { function updateLiquidationFee( address spoke, uint256 reserveId, - uint24 dynamicConfigKey, + uint32 dynamicConfigKey, uint256 liquidationFee - ) external onlyOwner { + ) external restricted { ISpoke targetSpoke = ISpoke(spoke); ISpoke.DynamicReserveConfig memory dynamicReserveConfig = targetSpoke.getDynamicReserveConfig( reserveId, @@ -250,7 +238,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { address spoke, uint256 reserveId, ISpoke.DynamicReserveConfig calldata dynamicConfig - ) external onlyOwner returns (uint24) { + ) external restricted returns (uint32) { return ISpoke(spoke).addDynamicReserveConfig(reserveId, dynamicConfig); } @@ -258,14 +246,14 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { function updateDynamicReserveConfig( address spoke, uint256 reserveId, - uint24 dynamicConfigKey, + uint32 dynamicConfigKey, ISpoke.DynamicReserveConfig calldata dynamicConfig - ) external onlyOwner { + ) external restricted { ISpoke(spoke).updateDynamicReserveConfig(reserveId, dynamicConfigKey, dynamicConfig); } /// @inheritdoc ISpokeConfigurator - function pauseAllReserves(address spoke) external onlyOwner { + function pauseAllReserves(address spoke) external restricted { ISpoke targetSpoke = ISpoke(spoke); uint256 reserveCount = targetSpoke.getReserveCount(); for (uint256 reserveId = 0; reserveId < reserveCount; ++reserveId) { @@ -276,7 +264,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { } /// @inheritdoc ISpokeConfigurator - function freezeAllReserves(address spoke) external onlyOwner { + function freezeAllReserves(address spoke) external restricted { ISpoke targetSpoke = ISpoke(spoke); uint256 reserveCount = targetSpoke.getReserveCount(); for (uint256 reserveId = 0; reserveId < reserveCount; ++reserveId) { @@ -286,12 +274,28 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { } } + // @inheritdoc ISpokeConfigurator + function pauseReserve(address spoke, uint256 reserveId) external restricted { + ISpoke targetSpoke = ISpoke(spoke); + ISpoke.ReserveConfig memory reserveConfig = targetSpoke.getReserveConfig(reserveId); + reserveConfig.paused = true; + targetSpoke.updateReserveConfig(reserveId, reserveConfig); + } + + // @inheritdoc ISpokeConfigurator + function freezeReserve(address spoke, uint256 reserveId) external restricted { + ISpoke targetSpoke = ISpoke(spoke); + ISpoke.ReserveConfig memory reserveConfig = targetSpoke.getReserveConfig(reserveId); + reserveConfig.frozen = true; + targetSpoke.updateReserveConfig(reserveId, reserveConfig); + } + /// @inheritdoc ISpokeConfigurator function updatePositionManager( address spoke, address positionManager, bool active - ) external onlyOwner { + ) external restricted { ISpoke(spoke).updatePositionManager(positionManager, active); } @@ -304,7 +308,7 @@ contract SpokeConfigurator is Ownable2Step, ISpokeConfigurator { function _getReserveLastDynamicConfigKey( address spoke, uint256 reserveId - ) internal view returns (uint24) { + ) internal view returns (uint32) { return ISpoke(spoke).getReserve(reserveId).dynamicConfigKey; } } diff --git a/src/spoke/SpokeStorage.sol b/src/spoke/SpokeStorage.sol new file mode 100644 index 000000000..bd511e209 --- /dev/null +++ b/src/spoke/SpokeStorage.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity 0.8.28; + +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +/// @title SpokeStorage +/// @author Aave Labs +/// @notice Storage layout for the Spoke contract. +/// @dev This contract defines all storage variables used by Spoke. +abstract contract SpokeStorage { + /// @dev Number of reserves listed in the Spoke. + uint256 internal _reserveCount; + + /// @dev Liquidation configuration for the Spoke. + ISpoke.LiquidationConfig internal _liquidationConfig; + + /// @dev Map of reserve identifiers to their Reserve data. + mapping(uint256 reserveId => ISpoke.Reserve) internal _reserves; + + /// @dev Map of hub addresses and asset identifiers to the reserve identifier. + mapping(address hub => mapping(uint256 assetId => uint256 reserveId)) + internal _hubAssetIdToReserveId; + + /// @dev Map of reserve identifiers and dynamic configuration keys to the dynamic configuration data. + mapping(uint256 reserveId => mapping(uint32 dynamicConfigKey => ISpoke.DynamicReserveConfig)) + internal _dynamicConfig; + + /// @dev Map of user addresses to their position status. + mapping(address user => ISpoke.PositionStatus) internal _positionStatus; + + /// @dev Map of user addresses and reserve identifiers to user positions. + mapping(address user => mapping(uint256 reserveId => ISpoke.UserPosition)) + internal _userPositions; + + /// @dev Map of position manager addresses to their configuration data. + mapping(address positionManager => ISpoke.PositionManagerConfig) internal _positionManager; + + /// @dev Reserved storage space to allow for future layout updates. + uint256[50] private __gap; +} diff --git a/src/spoke/TokenizationSpoke.sol b/src/spoke/TokenizationSpoke.sol new file mode 100644 index 000000000..01c831f4e --- /dev/null +++ b/src/spoke/TokenizationSpoke.sol @@ -0,0 +1,448 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity 0.8.28; + +import {ERC20Upgradeable} from 'src/dependencies/openzeppelin-upgradeable/ERC20Upgradeable.sol'; +import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; +import {ECDSA} from 'src/dependencies/openzeppelin/ECDSA.sol'; +import {IERC4626, IERC20Metadata} from 'src/dependencies/openzeppelin/IERC4626.sol'; +import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; +import {EIP712Hash} from 'src/spoke/libraries/EIP712Hash.sol'; +import {MathUtils} from 'src/libraries/math/MathUtils.sol'; +import {IntentConsumer} from 'src/utils/IntentConsumer.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {ITokenizationSpoke} from 'src/spoke/interfaces/ITokenizationSpoke.sol'; + +/// @title TokenizationSpoke +/// @author Aave Labs +/// @notice ERC4626 compliant wrapper to tokenize one listed asset of the connected Hub. +abstract contract TokenizationSpoke is ITokenizationSpoke, ERC20Upgradeable, IntentConsumer { + using SafeERC20 for IERC20; + using EIP712Hash for *; + using MathUtils for uint256; + + /// @inheritdoc ITokenizationSpoke + uint192 public constant PERMIT_NONCE_NAMESPACE = 0; + /// @inheritdoc ITokenizationSpoke + bytes32 public constant PERMIT_TYPEHASH = EIP712Hash.PERMIT_TYPEHASH; + /// @inheritdoc ITokenizationSpoke + bytes32 public constant DEPOSIT_TYPEHASH = EIP712Hash.TOKENIZED_DEPOSIT_TYPEHASH; + /// @inheritdoc ITokenizationSpoke + bytes32 public constant MINT_TYPEHASH = EIP712Hash.TOKENIZED_MINT_TYPEHASH; + /// @inheritdoc ITokenizationSpoke + bytes32 public constant WITHDRAW_TYPEHASH = EIP712Hash.TOKENIZED_WITHDRAW_TYPEHASH; + /// @inheritdoc ITokenizationSpoke + bytes32 public constant REDEEM_TYPEHASH = EIP712Hash.TOKENIZED_REDEEM_TYPEHASH; + /// @inheritdoc ITokenizationSpoke + uint40 public immutable MAX_ALLOWED_SPOKE_CAP; + + /// @dev Immutable references to the Hub and tokenized asset details. + IHub internal immutable HUB; + uint256 internal immutable ASSET_ID; + address internal immutable ASSET; + uint8 internal immutable DECIMALS; + uint256 internal immutable ASSET_UNITS; + + /// @dev Constructor. + /// @param hub_ The address of the associated Hub contract. + /// @param assetId_ The registered identifier of the asset to be tokenized by this spoke. + constructor(address hub_, uint256 assetId_) { + require(assetId_ < IHub(hub_).getAssetCount()); + HUB = IHub(hub_); + ASSET_ID = assetId_; + (ASSET, DECIMALS) = HUB.getAssetUnderlyingAndDecimals(ASSET_ID); + ASSET_UNITS = MathUtils.uncheckedExp(10, DECIMALS); + MAX_ALLOWED_SPOKE_CAP = HUB.MAX_ALLOWED_SPOKE_CAP(); + } + + /// @dev To be overridden by the inheriting TokenizationSpokeInstance contract. + function initialize(string memory shareName, string memory shareSymbol) external virtual; + + /// @dev Sets the vault share token's ERC20 name and symbol. Must be called at first initialization. + function __TokenizationSpoke_init( + string memory shareName, + string memory shareSymbol + ) internal onlyInitializing { + __ERC20_init(shareName, shareSymbol); + } + + /// @inheritdoc IERC4626 + function deposit(uint256 assets, address receiver) public override returns (uint256) { + return _executeDeposit({depositor: msg.sender, receiver: receiver, assets: assets}); + } + + /// @inheritdoc IERC4626 + function mint(uint256 shares, address receiver) public override returns (uint256) { + return _executeMint({depositor: msg.sender, receiver: receiver, shares: shares}); + } + + /// @inheritdoc IERC4626 + function withdraw( + uint256 assets, + address receiver, + address owner + ) public override returns (uint256) { + return _executeWithdraw({caller: msg.sender, receiver: receiver, owner: owner, assets: assets}); + } + + /// @inheritdoc IERC4626 + function redeem( + uint256 shares, + address receiver, + address owner + ) public override returns (uint256) { + return _executeRedeem({caller: msg.sender, receiver: receiver, owner: owner, shares: shares}); + } + + /// @inheritdoc ITokenizationSpoke + function depositWithSig( + TokenizedDeposit calldata params, + bytes calldata signature + ) external returns (uint256) { + _verifyAndConsumeIntent({ + signer: params.depositor, + intentHash: params.hash(), + nonce: params.nonce, + deadline: params.deadline, + signature: signature + }); + return + _executeDeposit({ + depositor: params.depositor, + receiver: params.receiver, + assets: params.assets + }); + } + + /// @inheritdoc ITokenizationSpoke + function mintWithSig( + TokenizedMint calldata params, + bytes calldata signature + ) external returns (uint256) { + _verifyAndConsumeIntent({ + signer: params.depositor, + intentHash: params.hash(), + nonce: params.nonce, + deadline: params.deadline, + signature: signature + }); + return + _executeMint({depositor: params.depositor, receiver: params.receiver, shares: params.shares}); + } + + /// @inheritdoc ITokenizationSpoke + function withdrawWithSig( + TokenizedWithdraw calldata params, + bytes calldata signature + ) external returns (uint256) { + _verifyAndConsumeIntent({ + signer: params.owner, + intentHash: params.hash(), + nonce: params.nonce, + deadline: params.deadline, + signature: signature + }); + return + _executeWithdraw({ + caller: params.owner, + receiver: params.receiver, + owner: params.owner, + assets: params.assets + }); + } + + /// @inheritdoc ITokenizationSpoke + function redeemWithSig( + TokenizedRedeem calldata params, + bytes calldata signature + ) external returns (uint256) { + _verifyAndConsumeIntent({ + signer: params.owner, + intentHash: params.hash(), + nonce: params.nonce, + deadline: params.deadline, + signature: signature + }); + return + _executeRedeem({ + caller: params.owner, + receiver: params.receiver, + owner: params.owner, + shares: params.shares + }); + } + + /// @inheritdoc ITokenizationSpoke + function depositWithPermit( + uint256 assets, + address receiver, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external returns (uint256) { + try + IERC20Permit(ASSET).permit({ + owner: msg.sender, + spender: address(this), + value: assets, + deadline: deadline, + v: v, + r: r, + s: s + }) + {} catch {} + return _executeDeposit({depositor: msg.sender, receiver: receiver, assets: assets}); + } + + /// @inheritdoc ITokenizationSpoke + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + require(block.timestamp <= deadline, InvalidSignature()); + bytes32 digest = _hashTypedData( + keccak256( + abi.encode( + EIP712Hash.PERMIT_TYPEHASH, + owner, + spender, + value, + _useNonce({owner: owner, key: PERMIT_NONCE_NAMESPACE}), + deadline + ) + ) + ); + require(owner == ECDSA.recover({hash: digest, v: v, r: r, s: s}), InvalidSignature()); + _approve({owner: owner, spender: spender, value: value}); + } + + /// @inheritdoc ITokenizationSpoke + function usePermitNonce() external returns (uint256) { + return _useNonce({owner: msg.sender, key: PERMIT_NONCE_NAMESPACE}); + } + + /// @inheritdoc ITokenizationSpoke + function renounceAllowance(address owner) external override { + if (allowance({owner: owner, spender: msg.sender}) == 0) { + return; + } + _approve({owner: owner, spender: msg.sender, value: 0}); + } + + /// @inheritdoc IERC4626 + function previewDeposit(uint256 assets) public view virtual returns (uint256) { + return HUB.previewAddByAssets(ASSET_ID, assets); + } + + /// @inheritdoc IERC4626 + function previewMint(uint256 shares) public view virtual returns (uint256) { + return HUB.previewAddByShares(ASSET_ID, shares); + } + + /// @inheritdoc IERC4626 + function previewWithdraw(uint256 assets) public view virtual returns (uint256) { + return HUB.previewRemoveByAssets(ASSET_ID, assets); + } + + /// @inheritdoc IERC4626 + function previewRedeem(uint256 shares) public view virtual returns (uint256) { + return HUB.previewRemoveByShares(ASSET_ID, shares); + } + + /// @inheritdoc IERC4626 + function convertToShares(uint256 assets) public view returns (uint256) { + return previewDeposit(assets); + } + + /// @inheritdoc IERC4626 + function convertToAssets(uint256 shares) public view returns (uint256) { + return previewRedeem(shares); + } + + /// @inheritdoc IERC4626 + function maxDeposit(address) public view returns (uint256) { + IHub.SpokeConfig memory config = HUB.getSpokeConfig(ASSET_ID, address(this)); + if (!config.active || config.halted) { + return 0; + } + if (config.addCap == MAX_ALLOWED_SPOKE_CAP) { + return type(uint256).max; + } + uint256 allowed = config.addCap * ASSET_UNITS; + uint256 balance = previewMint(totalSupply()); + return allowed.zeroFloorSub(balance); + } + + /// @inheritdoc IERC4626 + function maxMint(address receiver) public view returns (uint256) { + uint256 maxAssets = maxDeposit(receiver); + if (maxAssets == type(uint256).max) { + return type(uint256).max; + } + return convertToShares(maxAssets); + } + + /// @inheritdoc IERC4626 + function maxWithdraw(address owner) public view returns (uint256) { + uint256 maxRemovableAssets = _maxRemovableAssets(); + uint256 balance = convertToAssets(balanceOf(owner)); + return balance.min(maxRemovableAssets); + } + + /// @inheritdoc IERC4626 + function maxRedeem(address owner) public view returns (uint256) { + uint256 maxRemovableShares = convertToShares(_maxRemovableAssets()); + uint256 balance = balanceOf(owner); + return balance.min(maxRemovableShares); + } + + /// @inheritdoc IERC4626 + function totalAssets() public view virtual returns (uint256) { + return previewRedeem(totalSupply()); + } + + /// @inheritdoc ITokenizationSpoke + function hub() public view returns (address) { + return address(HUB); + } + + /// @inheritdoc ITokenizationSpoke + function assetId() public view returns (uint256) { + return ASSET_ID; + } + + /// @inheritdoc IERC4626 + function asset() public view returns (address) { + return ASSET; + } + + /// @inheritdoc IERC20Metadata + function decimals() public view override(ERC20Upgradeable, IERC20Metadata) returns (uint8) { + return DECIMALS; + } + + /// @inheritdoc IERC20Permit + function nonces(address owner) public view returns (uint256) { + return nonces({owner: owner, key: PERMIT_NONCE_NAMESPACE}); + } + + /// @inheritdoc IERC20Permit + function DOMAIN_SEPARATOR() + public + view + override(ITokenizationSpoke, IntentConsumer) + returns (bytes32) + { + return _domainSeparator(); + } + + function _executeDeposit( + address depositor, + address receiver, + uint256 assets + ) internal returns (uint256) { + uint256 shares = previewDeposit(assets); + _deposit({caller: depositor, receiver: receiver, assets: assets, shares: shares}); + return shares; + } + + function _executeMint( + address depositor, + address receiver, + uint256 shares + ) internal returns (uint256) { + uint256 assets = previewMint(shares); + _deposit({caller: depositor, receiver: receiver, assets: assets, shares: shares}); + return assets; + } + + function _executeWithdraw( + address caller, + address receiver, + address owner, + uint256 assets + ) internal returns (uint256) { + uint256 shares = previewWithdraw(assets); + _withdraw({caller: caller, receiver: receiver, owner: owner, assets: assets, shares: shares}); + return shares; + } + + function _executeRedeem( + address caller, + address receiver, + address owner, + uint256 shares + ) internal returns (uint256) { + uint256 assets = previewRedeem(shares); + _withdraw({caller: caller, receiver: receiver, owner: owner, assets: assets, shares: shares}); + return assets; + } + + /// @dev Deposit/Mint common workflow. Emits {Deposit} event. + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal virtual { + _pullAndDepositAssets(caller, assets); + _mint(receiver, shares); + _afterDeposit(assets, shares); + emit Deposit(caller, receiver, assets, shares); + } + + /// @dev Withdraw/Redeem common workflow. Emits {Withdraw} event. + /// @dev Consumes share token allowance if `caller` is not `owner`. + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual { + if (caller != owner) { + _spendAllowance({owner: owner, spender: caller, value: shares}); + } + _beforeWithdraw(assets, shares); + _burn(owner, shares); + _removeAndPushAssets(receiver, assets); + emit Withdraw(caller, receiver, owner, assets, shares); + } + + /// @dev Pulls the underlying asset from `from` and deposits it into the Hub. + /// @dev Added shares in the Hub should match the minted shares in `_deposit`. + function _pullAndDepositAssets(address from, uint256 amount) internal virtual { + IERC20(ASSET).safeTransferFrom(from, address(HUB), amount); + HUB.add(ASSET_ID, amount); + } + + /// @dev Removes the underlying asset from the Hub and pushes it to `to`. + /// @dev Removed shares in the Hub should match the burned shares in `_withdraw`. + function _removeAndPushAssets(address to, uint256 amount) internal virtual { + HUB.remove(ASSET_ID, amount, to); + } + + /// @dev Hook that is called after any deposit or mint. + function _afterDeposit(uint256 assets, uint256 shares) internal virtual {} + + /// @dev Hook that is called before any withdrawal or redemption. + function _beforeWithdraw(uint256 assets, uint256 shares) internal virtual {} + + function _maxRemovableAssets() internal view returns (uint256) { + IHub.SpokeConfig memory config = HUB.getSpokeConfig(ASSET_ID, address(this)); + if (!config.active || config.halted) { + return 0; + } + return HUB.getAssetLiquidity(ASSET_ID); + } + + function _domainNameAndVersion() internal pure override returns (string memory, string memory) { + return ('Tokenization Spoke', '1'); + } +} diff --git a/src/spoke/instances/SpokeInstance.sol b/src/spoke/instances/SpokeInstance.sol index 69a869dc9..528252036 100644 --- a/src/spoke/instances/SpokeInstance.sol +++ b/src/spoke/instances/SpokeInstance.sol @@ -2,7 +2,6 @@ // Copyright (c) 2025 Aave Labs pragma solidity 0.8.28; -import {LiquidationLogic} from 'src/spoke/libraries/LiquidationLogic.sol'; import {Spoke} from 'src/spoke/Spoke.sol'; /// @title SpokeInstance @@ -14,7 +13,8 @@ contract SpokeInstance is Spoke { /// @dev Constructor. /// @dev During upgrade, must ensure that the new oracle is supporting existing assets on the spoke and the replaced oracle. /// @param oracle_ The address of the oracle. - constructor(address oracle_) Spoke(oracle_) { + /// @param maxUserReservesLimit_ The maximum number of collateral and borrow reserves a user can have. + constructor(address oracle_, uint16 maxUserReservesLimit_) Spoke(oracle_, maxUserReservesLimit_) { _disableInitializers(); } @@ -22,7 +22,8 @@ contract SpokeInstance is Spoke { /// @dev The authority contract must implement the `AccessManaged` interface for access control. /// @param authority The address of the authority contract which manages permissions. function initialize(address authority) external override reinitializer(SPOKE_REVISION) { - emit UpdateOracle(ORACLE); + emit SetSpokeImmutables(ORACLE, MAX_USER_RESERVES_LIMIT); + require(authority != address(0), InvalidAddress()); __AccessManaged_init(authority); if (_liquidationConfig.targetHealthFactor == 0) { diff --git a/src/spoke/instances/TokenizationSpokeInstance.sol b/src/spoke/instances/TokenizationSpokeInstance.sol new file mode 100644 index 000000000..894ab8709 --- /dev/null +++ b/src/spoke/instances/TokenizationSpokeInstance.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity 0.8.28; + +import {TokenizationSpoke} from 'src/spoke/TokenizationSpoke.sol'; + +/// @title TokenizationSpokeInstance +/// @author Aave Labs +/// @notice Implementation contract for the TokenizationSpoke. +contract TokenizationSpokeInstance is TokenizationSpoke { + uint64 public constant SPOKE_REVISION = 1; + + /// @dev Constructor. + /// @param hub_ The address of the hub. + /// @param assetId_ The identifier of the asset. + constructor(address hub_, uint256 assetId_) TokenizationSpoke(hub_, assetId_) { + _disableInitializers(); + } + + /// @notice Initializer. + /// @param shareName The ERC20 name of the share issued by this vault. + /// @param shareSymbol The ERC20 symbol of the share issued by this vault. + function initialize( + string memory shareName, + string memory shareSymbol + ) external override reinitializer(SPOKE_REVISION) { + __TokenizationSpoke_init(shareName, shareSymbol); + } +} diff --git a/src/spoke/interfaces/IAaveOracle.sol b/src/spoke/interfaces/IAaveOracle.sol index f11fc29e2..8d49f0f14 100644 --- a/src/spoke/interfaces/IAaveOracle.sol +++ b/src/spoke/interfaces/IAaveOracle.sol @@ -13,6 +13,16 @@ interface IAaveOracle is IPriceOracle { /// @param source The price feed source of the reserve. event UpdateReserveSource(uint256 indexed reserveId, address indexed source); + /// @dev Emitted when the spoke is set. + /// @param spoke The address of the spoke. + event SetSpoke(address indexed spoke); + + /// @dev Thrown when the caller is not the deployer. + error OnlyDeployer(); + + /// @dev Thrown when the spoke is already set. + error SpokeAlreadySet(); + /// @dev Thrown when the price feed source uses a different number of decimals than the oracle. /// @param reserveId The identifier of the reserve. error InvalidSourceDecimals(uint256 reserveId); @@ -28,6 +38,15 @@ interface IAaveOracle is IPriceOracle { /// @dev Thrown when the given address is invalid. error InvalidAddress(); + /// @dev Thrown when the spoke's oracle does not match the current oracle. + error OracleMismatch(); + + /// @notice Sets the address of the spoke. + /// @dev Can only be called once by the deployer. + /// @dev The spoke should be set before any other function is called. + /// @param spoke The address of the spoke. + function setSpoke(address spoke) external; + /// @notice Sets the price feed source of a reserve. /// @dev Must be called by the spoke. /// @dev The source must implement the AggregatorV3Interface. diff --git a/src/spoke/interfaces/ISpoke.sol b/src/spoke/interfaces/ISpoke.sol index 29a0a5819..bab3cbc1f 100644 --- a/src/spoke/interfaces/ISpoke.sol +++ b/src/spoke/interfaces/ISpoke.sol @@ -3,34 +3,55 @@ pragma solidity ^0.8.0; import {IAccessManaged} from 'src/dependencies/openzeppelin/IAccessManaged.sol'; -import {INoncesKeyed} from 'src/interfaces/INoncesKeyed.sol'; +import {IIntentConsumer} from 'src/interfaces/IIntentConsumer.sol'; import {IMulticall} from 'src/interfaces/IMulticall.sol'; import {IHubBase} from 'src/hub/interfaces/IHubBase.sol'; import {ISpokeBase} from 'src/spoke/interfaces/ISpokeBase.sol'; +import {IExtSload} from 'src/interfaces/IExtSload.sol'; type ReserveFlags is uint8; /// @title ISpoke /// @author Aave Labs /// @notice Full interface for Spoke. -interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { +interface ISpoke is ISpokeBase, IAccessManaged, IIntentConsumer, IExtSload, IMulticall { + /// @notice Intent data to set user position managers with EIP712-typed signature. + /// @param onBehalfOf The address of the user on whose behalf position manager can act. + /// @param updates The array of position manager updates. + /// @param nonce The nonce for the signature. + /// @param deadline The deadline for the signature. + struct SetUserPositionManagers { + address onBehalfOf; + PositionManagerUpdate[] updates; + uint256 nonce; + uint256 deadline; + } + + /// @notice Sub-Intent data to apply position manager update for user. + /// @param positionManager The address of the position manager. + /// @param approve True to approve the position manager, false to revoke approval. + struct PositionManagerUpdate { + address positionManager; + bool approve; + } + /// @notice Reserve level data. /// @dev underlying The address of the underlying asset. /// @dev hub The address of the associated Hub. /// @dev assetId The identifier of the asset in the Hub. /// @dev decimals The number of decimals of the underlying asset. - /// @dev dynamicConfigKey The key of the last reserve dynamic config. /// @dev collateralRisk The risk associated with a collateral asset, expressed in BPS. /// @dev flags The packed boolean flags of the reserve (a wrapped uint8). + /// @dev dynamicConfigKey The key of the last reserve dynamic config. struct Reserve { address underlying; // IHubBase hub; uint16 assetId; uint8 decimals; - uint24 dynamicConfigKey; uint24 collateralRisk; ReserveFlags flags; + uint32 dynamicConfigKey; } /// @notice Reserve configuration. Subset of the `Reserve` struct. @@ -38,14 +59,12 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @dev paused True if all actions are prevented for the reserve. /// @dev frozen True if new activity is prevented for the reserve. /// @dev borrowable True if the reserve is borrowable. - /// @dev liquidatable True if the reserve can be liquidated when used as collateral. /// @dev receiveSharesEnabled True if the liquidator can receive collateral shares during liquidation. struct ReserveConfig { uint24 collateralRisk; bool paused; bool frozen; bool borrowable; - bool liquidatable; bool receiveSharesEnabled; } @@ -82,7 +101,7 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { int200 premiumOffsetRay; // uint120 suppliedShares; - uint24 dynamicConfigKey; + uint32 dynamicConfigKey; } /// @notice Position manager configuration data. @@ -105,23 +124,24 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @dev riskPremium The risk premium of the user position, expressed in BPS. /// @dev avgCollateralFactor The weighted average collateral factor of the user position, expressed in WAD. /// @dev healthFactor The health factor of the user position, expressed in WAD. 1e18 represents a health factor of 1.00. - /// @dev totalCollateralValue The total collateral value of the user position, expressed in units of base currency. 1e26 represents 1 USD. - /// @dev totalDebtValue The total debt value of the user position, expressed in units of base currency. 1e26 represents 1 USD. + /// @dev totalCollateralValue The total collateral value of the user position, expressed in units of Value. + /// @dev totalDebtValueRay The total debt value of the user position, expressed in units of Value and scaled by RAY. /// @dev activeCollateralCount The number of active collaterals, which includes reserves with `collateralFactor` > 0, `enabledAsCollateral` and `suppliedAmount` > 0. - /// @dev borrowedCount The number of borrowed reserves of the user position. + /// @dev borrowCount The number of borrowed reserves of the user position. struct UserAccountData { uint256 riskPremium; uint256 avgCollateralFactor; uint256 healthFactor; uint256 totalCollateralValue; - uint256 totalDebtValue; + uint256 totalDebtValueRay; uint256 activeCollateralCount; - uint256 borrowedCount; + uint256 borrowCount; } - /// @notice Emitted when the oracle address of the spoke is updated. - /// @param oracle The new address of the oracle. - event UpdateOracle(address indexed oracle); + /// @notice Emitted upon setting the immutable variables on the spoke. + /// @param oracle The address of the oracle. + /// @param maxUserReservesLimit The max user reserves limit. + event SetSpokeImmutables(address indexed oracle, uint16 maxUserReservesLimit); /// @notice Emitted when a liquidation config is updated. /// @param config The new liquidation config. @@ -151,7 +171,7 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @param config The dynamic reserve config. event AddDynamicReserveConfig( uint256 indexed reserveId, - uint24 indexed dynamicConfigKey, + uint32 indexed dynamicConfigKey, DynamicReserveConfig config ); @@ -161,7 +181,7 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @param config The dynamic reserve config. event UpdateDynamicReserveConfig( uint256 indexed reserveId, - uint24 indexed dynamicConfigKey, + uint32 indexed dynamicConfigKey, DynamicReserveConfig config ); @@ -233,6 +253,9 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @notice Thrown when adding a new reserve if an asset id is invalid. error InvalidAssetId(); + /// @notice Thrown when adding a new reserve if the asset decimals are invalid. + error InvalidAssetDecimals(); + /// @notice Thrown when updating a reserve if it is not listed. error ReserveNotListed(); @@ -246,9 +269,6 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @dev Can only occur during an attempted `supply`, `borrow`, or `setUsingAsCollateral` action. error ReserveFrozen(); - /// @notice Thrown when the collateral reserve is not enabled to be liquidated. - error CollateralCannotBeLiquidated(); - /// @notice Thrown when an action causes a user's health factor to fall below the liquidation threshold. error HealthFactorBelowThreshold(); @@ -265,13 +285,7 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { error Unauthorized(); /// @notice Thrown if a config key is uninitialized when updating a dynamic reserve config. - error ConfigKeyUninitialized(); - - /// @notice Thrown if an inactive position manager is set as a user's position manager. - error InactivePositionManager(); - - /// @notice Thrown when a signature is invalid. - error InvalidSignature(); + error DynamicConfigKeyUninitialized(); /// @notice Thrown for an invalid zero address. error InvalidAddress(); @@ -279,6 +293,9 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @notice Thrown when the oracle decimals are not 8 in the constructor. error InvalidOracleDecimals(); + /// @notice Thrown when the maximum user reserves limit is zero in the constructor. + error InvalidMaxUserReservesLimit(); + /// @notice Thrown when a collateral risk exceeds the maximum allowed. error InvalidCollateralRisk(); @@ -312,8 +329,11 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @notice Thrown when the maximum number of dynamic config keys is reached. error MaximumDynamicConfigKeyReached(); + /// @notice Thrown when user attempts to exceed either the maximum allowed collateral or borrowed reserves. + error MaximumUserReservesExceeded(); + /// @notice Updates the liquidation config. - /// @param config The liquidation config. + /// @param config The new liquidation config. function updateLiquidationConfig(LiquidationConfig calldata config) external; /// @notice Adds a new reserve to the spoke. @@ -354,18 +374,18 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { function addDynamicReserveConfig( uint256 reserveId, DynamicReserveConfig calldata dynamicConfig - ) external returns (uint24 dynamicConfigKey); + ) external returns (uint32 dynamicConfigKey); /// @notice Updates the dynamic reserve config for a given reserve at the specified key. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. - /// @dev Reverts with `ConfigKeyUninitialized` if the config key has not been initialized yet. + /// @dev Reverts with `DynamicConfigKeyUninitialized` if the config key has not been initialized yet. /// @dev Reverts with `InvalidCollateralFactor` if the collateral factor is 0. /// @param reserveId The identifier of the reserve. /// @param dynamicConfigKey The key of the config to update. /// @param dynamicConfig The new dynamic reserve config. function updateDynamicReserveConfig( uint256 reserveId, - uint24 dynamicConfigKey, + uint32 dynamicConfigKey, DynamicReserveConfig calldata dynamicConfig ) external; @@ -376,6 +396,8 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @notice Allows suppliers to enable/disable a specific supplied reserve as collateral. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @dev It reverts if the user exceeds the maximum allowed collateral reserves when enabling. + /// @dev Reserves with zero supplied or zero collateral factor count towards the max allowed collateral reserves. /// @dev Caller must be `onBehalfOf` or an authorized position manager for `onBehalfOf`. /// @param reserveId The reserve identifier of the underlying asset. /// @param usingAsCollateral True if the user wants to use the supply as collateral. @@ -396,25 +418,19 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @param onBehalfOf The owner of the position being modified. function updateUserDynamicConfig(address onBehalfOf) external; - /// @notice Enables a user to grant or revoke approval for a position manager + /// @notice Enables a user to grant or revoke approval for a position manager. + /// @dev Allows approving inactive position managers. /// @param positionManager The address of the position manager. /// @param approve True to approve the position manager, false to revoke approval. function setUserPositionManager(address positionManager, bool approve) external; - /// @notice Enables a user to grant or revoke approval for a position manager using an EIP712-typed intent. + /// @notice Enables a user to grant or revoke approval for an array of position managers using an EIP712-typed intent. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. - /// @param positionManager The address of the position manager. - /// @param user The address of the user on whose behalf position manager can act. - /// @param approve True to approve the position manager, false to revoke approval. - /// @param nonce The key-prefixed nonce for the signature. - /// @param deadline The deadline for the signature. + /// @dev Allows duplicated updates and the last one is persisted. Allows approving inactive position managers. + /// @param params The structured setUserPositionManagers parameter. /// @param signature The EIP712-compliant signature bytes. - function setUserPositionManagerWithSig( - address positionManager, - address user, - bool approve, - uint256 nonce, - uint256 deadline, + function setUserPositionManagersWithSig( + SetUserPositionManagers calldata params, bytes calldata signature ) external; @@ -424,7 +440,7 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @notice Allows consuming a permit signature for the given reserve's underlying asset. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. - /// @dev Spender is the corresponding Hub of the given reserve. + /// @dev The Spoke must be configured as the spender. /// @param reserveId The identifier of the reserve. /// @param onBehalfOf The address of the user on whose behalf the permit is being used. /// @param value The amount of the underlying asset to permit. @@ -446,6 +462,13 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @dev Count includes reserves that are not currently active. function getReserveCount() external view returns (uint256); + /// @notice Returns the reserve identifier for a given assetId in a Hub. + /// @dev It reverts if no reserve is associated with the given assetId. + /// @param hub The address of the Hub. + /// @param assetId The identifier of the asset on the Hub. + /// @return The identifier of the reserve. + function getReserveId(address hub, uint256 assetId) external view returns (uint256); + /// @notice Returns the reserve struct data in storage. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @param reserveId The identifier of the reserve. @@ -466,7 +489,7 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @return The dynamic reserve configuration struct. function getDynamicReserveConfig( uint256 reserveId, - uint24 dynamicConfigKey + uint32 dynamicConfigKey ) external view returns (DynamicReserveConfig memory); /// @notice Returns two flags indicating whether the reserve is used as collateral and whether it is borrowed by the user. @@ -521,17 +544,17 @@ interface ISpoke is ISpokeBase, IMulticall, INoncesKeyed, IAccessManaged { /// @return True if positionManager is active and approved by user. function isPositionManager(address user, address positionManager) external view returns (bool); - /// @notice Returns the EIP-712 domain separator. - function DOMAIN_SEPARATOR() external view returns (bytes32); - /// @notice Returns the address of the external `LiquidationLogic` library. /// @return The address of the library. function getLiquidationLogic() external pure returns (address); - /// @notice Returns the type hash for the SetUserPositionManager intent. + /// @notice Returns the type hash for the SetUserPositionManagers intent. /// @return The bytes-encoded EIP-712 struct hash representing the intent. - function SET_USER_POSITION_MANAGER_TYPEHASH() external view returns (bytes32); + function SET_USER_POSITION_MANAGERS_TYPEHASH() external view returns (bytes32); /// @notice Returns the address of the AaveOracle contract. function ORACLE() external view returns (address); + + /// @notice Returns the maximum allowed number of collateral and borrow reserves per user (each counted separately). + function MAX_USER_RESERVES_LIMIT() external view returns (uint16); } diff --git a/src/spoke/interfaces/ISpokeBase.sol b/src/spoke/interfaces/ISpokeBase.sol index 370c6b141..3fb928783 100644 --- a/src/spoke/interfaces/ISpokeBase.sol +++ b/src/spoke/interfaces/ISpokeBase.sol @@ -72,11 +72,11 @@ interface ISpokeBase { /// @param user The address of the borrower getting liquidated. /// @param liquidator The address of the liquidator. /// @param receiveShares True if the liquidator received collateral in supplied shares rather than underlying assets. - /// @param debtToLiquidate The debt amount of borrowed reserve to be liquidated. - /// @param drawnSharesToLiquidate The amount of drawn shares to be liquidated. + /// @param debtAmountRestored The amount of debt restored, expressed in asset units. + /// @param drawnSharesLiquidated The amount of drawn shares liquidated. /// @param premiumDelta A struct representing the changes to premium debt after liquidation. - /// @param collateralToLiquidate The total amount of collateral asset to be liquidated, inclusive of liquidation fee. - /// @param collateralSharesToLiquidate The total amount of collateral shares to liquidate. + /// @param collateralAmountRemoved The amount of collateral removed, expressed in asset units. + /// @param collateralSharesLiquidated The total amount of collateral shares liquidated. /// @param collateralSharesToLiquidator The amount of collateral shares that the liquidator received. event LiquidationCall( uint256 indexed collateralReserveId, @@ -84,11 +84,11 @@ interface ISpokeBase { address indexed user, address liquidator, bool receiveShares, - uint256 debtToLiquidate, - uint256 drawnSharesToLiquidate, + uint256 debtAmountRestored, + uint256 drawnSharesLiquidated, IHubBase.PremiumDelta premiumDelta, - uint256 collateralToLiquidate, - uint256 collateralSharesToLiquidate, + uint256 collateralAmountRemoved, + uint256 collateralSharesLiquidated, uint256 collateralSharesToLiquidator ); @@ -125,6 +125,7 @@ interface ISpokeBase { /// @notice Borrows a specified amount of underlying asset from the given reserve. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @dev It reverts if the user would borrow more than the maximum allowed number of borrowed reserves. /// @dev Caller must be `onBehalfOf` or an authorized position manager for `onBehalfOf`. /// @dev Caller receives the underlying asset borrowed. /// @param reserveId The identifier of the reserve. diff --git a/src/spoke/interfaces/ISpokeConfigurator.sol b/src/spoke/interfaces/ISpokeConfigurator.sol index b3a2b55e1..fb3f2c614 100644 --- a/src/spoke/interfaces/ISpokeConfigurator.sol +++ b/src/spoke/interfaces/ISpokeConfigurator.sol @@ -9,39 +9,39 @@ import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; /// @notice Interface for the SpokeConfigurator. interface ISpokeConfigurator { /// @notice Emitted when the maximum allowed number of reserves for a spoke is updated. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param maxReserves The new maximum number of reserves. event UpdateMaxReserves(address indexed spoke, uint256 maxReserves); /// @dev Thrown upon adding a reserve when the maximum allowed number of reserves is already reached. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param maxReserves The maximum allowed number of reserves. error MaximumReservesReached(address spoke, uint256 maxReserves); /// @notice Updates the price source of a reserve. /// @dev The price source must implement the AggregatorV3Interface. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param priceSource The new price source. function updateReservePriceSource(address spoke, uint256 reserveId, address priceSource) external; /// @notice Updates the liquidation target health factor of a spoke. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param targetHealthFactor The new liquidation target health factor. function updateLiquidationTargetHealthFactor(address spoke, uint256 targetHealthFactor) external; /// @notice Updates the health factor for max liquidation bonus of a spoke. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param healthFactorForMaxBonus The new health factor for max liquidation bonus. function updateHealthFactorForMaxBonus(address spoke, uint256 healthFactorForMaxBonus) external; /// @notice Updates the liquidation bonus factor of a spoke. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param liquidationBonusFactor The new liquidation bonus factor. function updateLiquidationBonusFactor(address spoke, uint256 liquidationBonusFactor) external; /// @notice Updates the liquidation config of a spoke. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param liquidationConfig The new liquidation config. function updateLiquidationConfig( address spoke, @@ -50,14 +50,15 @@ interface ISpokeConfigurator { /// @notice Updates the maximum number of reserves allowed to exist on a spoke. /// @dev It allows setting the maximum below the amount of reserves that currently exist. - /// @param spoke The address of the spoke. + /// @dev It can also be set for an arbitrary spoke address. + /// @param spoke The address of the Spoke. /// @param maxReserves The new maximum number of reserves. function updateMaxReserves(address spoke, uint256 maxReserves) external; /// @notice Adds a new reserve to a spoke. /// @dev The asset corresponding to the reserve must be already listed on the Hub. /// @dev The price source must implement the AggregatorV3Interface. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param hub The address of the Hub where the asset corresponding to the reserve is listed. /// @param assetId The identifier of the asset. /// @param priceSource The address of the price source. @@ -74,31 +75,25 @@ interface ISpokeConfigurator { ) external returns (uint256); /// @notice Updates the paused flag of a reserve. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param paused The new paused flag. function updatePaused(address spoke, uint256 reserveId, bool paused) external; /// @notice Updates the frozen flag of a reserve. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param frozen The new frozen flag. function updateFrozen(address spoke, uint256 reserveId, bool frozen) external; /// @notice Updates the borrowable flag of a reserve. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param borrowable The new borrowable flag. function updateBorrowable(address spoke, uint256 reserveId, bool borrowable) external; - /// @notice Updates the liquidatable flag of a reserve. - /// @param spoke The address of the spoke. - /// @param reserveId The identifier of the reserve. - /// @param liquidatable The new liquidatable flag. - function updateLiquidatable(address spoke, uint256 reserveId, bool liquidatable) external; - /// @notice Updates whether receiving shares on liquidation is enabled. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param receiveSharesEnabled The new receiveSharesEnabled flag. function updateReceiveSharesEnabled( @@ -108,13 +103,13 @@ interface ISpokeConfigurator { ) external; /// @notice Updates the collateral risk of a reserve. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param collateralRisk The new collateral risk. function updateCollateralRisk(address spoke, uint256 reserveId, uint256 collateralRisk) external; /// @notice Adds a dynamic config to a reserve, identical to the latest one but with the specified collateral factor. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param collateralFactor The new collateral factor. /// @return The dynamicConfigKey of the added dynamic configuration. @@ -122,22 +117,22 @@ interface ISpokeConfigurator { address spoke, uint256 reserveId, uint16 collateralFactor - ) external returns (uint24); + ) external returns (uint32); /// @notice Updates an existing collateral factor of a reserve at the specified key. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param dynamicConfigKey The key of the dynamic config to update. /// @param collateralFactor The new collateral factor. function updateCollateralFactor( address spoke, uint256 reserveId, - uint24 dynamicConfigKey, + uint32 dynamicConfigKey, uint16 collateralFactor ) external; /// @notice Adds a dynamic config to a reserve, identical to the latest one but with the specified max liquidation bonus. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param maxLiquidationBonus The new max liquidation bonus. /// @return The dynamicConfigKey of the added dynamic configuration. @@ -145,22 +140,22 @@ interface ISpokeConfigurator { address spoke, uint256 reserveId, uint256 maxLiquidationBonus - ) external returns (uint24); + ) external returns (uint32); /// @notice Updates an existing liquidation bonus of a reserve at the specified key. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param dynamicConfigKey The key of the dynamic config to update. /// @param maxLiquidationBonus The new liquidation bonus. function updateMaxLiquidationBonus( address spoke, uint256 reserveId, - uint24 dynamicConfigKey, + uint32 dynamicConfigKey, uint256 maxLiquidationBonus ) external; /// @notice Adds a dynamic config to a reserve, identical to the latest one but with the specified liquidation fee. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param liquidationFee The new liquidation fee. /// @return The dynamicConfigKey of the added dynamic configuration. @@ -168,22 +163,22 @@ interface ISpokeConfigurator { address spoke, uint256 reserveId, uint256 liquidationFee - ) external returns (uint24); + ) external returns (uint32); /// @notice Updates an existing liquidation fee of a reserve at the specified key. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param dynamicConfigKey The key of the dynamic config to update. /// @param liquidationFee The new liquidation fee. function updateLiquidationFee( address spoke, uint256 reserveId, - uint24 dynamicConfigKey, + uint32 dynamicConfigKey, uint256 liquidationFee ) external; /// @notice Adds a dynamic config to a reserve. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param dynamicConfig The new dynamic config. /// @return dynamicConfigKey The key of the added dynamic config. @@ -191,36 +186,46 @@ interface ISpokeConfigurator { address spoke, uint256 reserveId, ISpoke.DynamicReserveConfig calldata dynamicConfig - ) external returns (uint24); + ) external returns (uint32); /// @notice Updates the dynamic config of a reserve at the specified key. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param reserveId The identifier of the reserve. /// @param dynamicConfigKey The key of the dynamic config to update. /// @param dynamicConfig The new dynamic config. function updateDynamicReserveConfig( address spoke, uint256 reserveId, - uint24 dynamicConfigKey, + uint32 dynamicConfigKey, ISpoke.DynamicReserveConfig calldata dynamicConfig ) external; /// @notice Pauses all reserves of a spoke. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. function pauseAllReserves(address spoke) external; /// @notice Freezes all reserves of a spoke. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. function freezeAllReserves(address spoke) external; + /// @notice Pauses a reserve of a spoke. + /// @param spoke The address of the Spoke. + /// @param reserveId The identifier of the reserve. + function pauseReserve(address spoke, uint256 reserveId) external; + + /// @notice Freezes a reserve of a spoke. + /// @param spoke The address of the Spoke. + /// @param reserveId The identifier of the reserve. + function freezeReserve(address spoke, uint256 reserveId) external; + /// @notice Updates the active flag of a spoke's position manager. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @param positionManager The address of the position manager. /// @param active The new active flag. function updatePositionManager(address spoke, address positionManager, bool active) external; /// @notice Returns the maximum number of reserves allowed to exist on a spoke. - /// @param spoke The address of the spoke. + /// @param spoke The address of the Spoke. /// @return The maximum number of reserves. function getMaxReserves(address spoke) external view returns (uint256); } diff --git a/src/spoke/interfaces/ITokenizationSpoke.sol b/src/spoke/interfaces/ITokenizationSpoke.sol new file mode 100644 index 000000000..7357fb603 --- /dev/null +++ b/src/spoke/interfaces/ITokenizationSpoke.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {IERC2612, IERC20Permit} from 'src/dependencies/openzeppelin/IERC2612.sol'; +import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.sol'; +import {IIntentConsumer} from 'src/interfaces/IIntentConsumer.sol'; + +/// @title ITokenizationSpoke +/// @author Aave Labs +interface ITokenizationSpoke is IERC4626, IERC2612, IIntentConsumer { + /// @notice Intent data to deposit assets into the tokenization spoke. + /// @param depositor The address of the user depositing assets. + /// @param assets The amount of assets to deposit. + /// @param receiver The address that will receive the minted shares. + /// @param nonce The key-prefixed nonce for the signature. + /// @param deadline The deadline for the intent. + struct TokenizedDeposit { + address depositor; + uint256 assets; + address receiver; + uint256 nonce; + uint256 deadline; + } + + /// @notice Intent data to mint shares from the tokenization spoke. + /// @param depositor The address of the user depositing assets. + /// @param shares The amount of shares to mint. + /// @param receiver The address that will receive the minted shares. + /// @param nonce The key-prefixed nonce for the signature. + /// @param deadline The deadline for the intent. + struct TokenizedMint { + address depositor; + uint256 shares; + address receiver; + uint256 nonce; + uint256 deadline; + } + + /// @notice Intent data to withdraw assets from the tokenization spoke. + /// @param owner The address of the user withdrawing assets. + /// @param assets The amount of assets to withdraw. + /// @param receiver The address that will receive the withdrawn assets. + /// @param nonce The key-prefixed nonce for the signature. + /// @param deadline The deadline for the intent. + struct TokenizedWithdraw { + address owner; + uint256 assets; + address receiver; + uint256 nonce; + uint256 deadline; + } + + /// @notice Intent data to redeem shares from the tokenization spoke. + /// @param owner The address of the user redeeming shares. + /// @param shares The amount of shares to redeem. + /// @param receiver The address that will receive the redeemed assets. + /// @param nonce The key-prefixed nonce for the signature. + /// @param deadline The deadline for the intent. + struct TokenizedRedeem { + address owner; + uint256 shares; + address receiver; + uint256 nonce; + uint256 deadline; + } + + /// @notice Deposits assets into the tokenization spoke with a signature. + /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. + /// @param params The parameters for the deposit. + /// @param signature The EIP712-typed signed bytes for the deposit. + /// @return The amount of shares minted. + function depositWithSig( + TokenizedDeposit calldata params, + bytes calldata signature + ) external returns (uint256); + + /// @notice Mints shares of the tokenization spoke with a signature. + /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. + /// @param params The parameters for the mint. + /// @param signature The EIP712-typed signed bytes for the mint. + /// @return The amount of assets deposited. + function mintWithSig( + TokenizedMint calldata params, + bytes calldata signature + ) external returns (uint256); + + /// @notice Withdraws assets from the tokenization spoke with a signature. + /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. + /// @param params The parameters for the withdraw. + /// @param signature The EIP712-typed signed bytes for the withdraw. + /// @return The amount of shares burnt. + function withdrawWithSig( + TokenizedWithdraw calldata params, + bytes calldata signature + ) external returns (uint256); + + /// @notice Redeems shares from the tokenization spoke with a signature. + /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. + /// @param params The parameters for the redeem. + /// @param signature The EIP712-typed signed bytes for the redeem. + /// @return The amount of assets burnt. + function redeemWithSig( + TokenizedRedeem calldata params, + bytes calldata signature + ) external returns (uint256); + + /// @notice Deposits assets into the vault with an underlying asset ERC2612-typed permit. + /// @param assets The amount of assets to deposit. + /// @param receiver The receiver of the shares. + /// @param deadline The deadline of the permit. + /// @param v The v value of the permit. + /// @param r The r value of the permit. + /// @param s The s value of the permit. + /// @return The amount of shares minted. + function depositWithPermit( + uint256 assets, + address receiver, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external returns (uint256); + + /// @notice Sets approval for `spender` to spend `owner`'s share tokens via EIP712-typed signature. + /// @dev Uses keyed-nonces where the share token permit nonce is consumed sequentially and key namespace is always set to `PERMIT_NONCE_NAMESPACE`. + /// @dev Implements EIP-2612 permit functionality for the vault share token. + /// @param owner The address of the token owner granting approval. + /// @param spender The address being granted approval to spend tokens. + /// @param value The amount of tokens approved for spending. + /// @param deadline The timestamp by which the permit must be used. + /// @param v The recovery byte of the signature. + /// @param r The first 32 bytes of the signature. + /// @param s The second 32 bytes of the signature. + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + /// @notice Revokes the current PERMIT_NAMESPACE_NONCE of caller & increments the nonce at this key. + /// @return The consumed keyed-nonce. + function usePermitNonce() external returns (uint256); + + /// @notice Resets the allowance of an owner for the caller. + /// @param owner The owner of the allowance to renounce. + function renounceAllowance(address owner) external; + + /// @notice Returns the address of the associated Hub. + function hub() external view returns (address); + + /// @notice Returns the identifier of the associated asset. + function assetId() external view returns (uint256); + + /// @notice Returns the maximum allowed spoke cap. + function MAX_ALLOWED_SPOKE_CAP() external view returns (uint40); + + /// @notice Returns the nonce namespace for share token EIP-2612 permit signatures. + /// @dev Share token permits strictly use this dedicated namespace in the keyed-nonce system as the nonce key. + /// @dev Other vault intent operations can also use the this namespace as the nonce key. + function PERMIT_NONCE_NAMESPACE() external pure returns (uint192); + + /// @notice Returns the type hash for the deposit intent. + function DEPOSIT_TYPEHASH() external pure returns (bytes32); + + /// @notice Returns the type hash for the mint intent. + function MINT_TYPEHASH() external pure returns (bytes32); + + /// @notice Returns the type hash for the withdraw intent. + function WITHDRAW_TYPEHASH() external pure returns (bytes32); + + /// @notice Returns the type hash for the redeem intent. + function REDEEM_TYPEHASH() external pure returns (bytes32); + + /// @notice Returns the type hash for the share token permit intent. + function PERMIT_TYPEHASH() external pure returns (bytes32); + + /// @notice Returns the EIP-712 domain separator. + function DOMAIN_SEPARATOR() + external + view + override(IERC20Permit, IIntentConsumer) + returns (bytes32); +} diff --git a/src/spoke/libraries/EIP712Hash.sol b/src/spoke/libraries/EIP712Hash.sol new file mode 100644 index 000000000..9cd9d88e3 --- /dev/null +++ b/src/spoke/libraries/EIP712Hash.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.20; + +import {ITokenizationSpoke} from 'src/spoke/interfaces/ITokenizationSpoke.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +/// @title EIP712Hash library +/// @author Aave Labs +/// @notice Helper methods to hash EIP712 typed data structs. +library EIP712Hash { + using EIP712Hash for *; + + bytes32 public constant SET_USER_POSITION_MANAGERS_TYPEHASH = + // keccak256('SetUserPositionManagers(address onBehalfOf,PositionManagerUpdate[] updates,uint256 nonce,uint256 deadline)PositionManagerUpdate(address positionManager,bool approve)') + 0xba01f7bf3d3674c63670ec4a78b0d56aac1ad6e8c84468920b9e61bfe0b9851a; + + bytes32 public constant POSITION_MANAGER_UPDATE = + // keccak256('PositionManagerUpdate(address positionManager,bool approve)') + 0x187dbd227227274b90655fb4011fc21dd749e8966fc040bd91e0b92609202565; + + bytes32 public constant TOKENIZED_DEPOSIT_TYPEHASH = + // keccak256('TokenizedDeposit(address depositor,uint256 assets,address receiver,uint256 nonce,uint256 deadline)') + 0xdecc632fabbd6d9f578203db4396740eb2d81cf0fd7681b726d116e49cbc240c; + + bytes32 public constant TOKENIZED_MINT_TYPEHASH = + // keccak256('TokenizedMint(address depositor,uint256 shares,address receiver,uint256 nonce,uint256 deadline)') + 0x12737e595645af6fb99e7985f3dff6fb716ac1ec517c0d2b21313985dc207343; + + bytes32 public constant TOKENIZED_WITHDRAW_TYPEHASH = + // keccak256('TokenizedWithdraw(address owner,uint256 assets,address receiver,uint256 nonce,uint256 deadline)') + 0xe81b79af873473ec5cb79baa56499159fca87ff2e3333f24183127408a14acb5; + + bytes32 public constant TOKENIZED_REDEEM_TYPEHASH = + // keccak256('TokenizedRedeem(address owner,uint256 shares,address receiver,uint256 nonce,uint256 deadline)') + 0x03929148275eed00e4c3ef9c0ee72e49ec6cb96c7a34941708e052f9a511334e; + + bytes32 public constant PERMIT_TYPEHASH = + // keccak256('Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)') + 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; + + function hash(ISpoke.SetUserPositionManagers calldata params) internal pure returns (bytes32) { + bytes32[] memory updatesHashes = new bytes32[](params.updates.length); + for (uint256 i = 0; i < updatesHashes.length; ++i) { + updatesHashes[i] = params.updates[i].hash(); + } + return + keccak256( + abi.encode( + SET_USER_POSITION_MANAGERS_TYPEHASH, + params.onBehalfOf, + keccak256(abi.encodePacked(updatesHashes)), + params.nonce, + params.deadline + ) + ); + } + + function hash( + ISpoke.PositionManagerUpdate calldata params + ) internal pure returns (bytes32 digest) { + // equivalent to: keccak256(abi.encode(POSITION_MANAGER_UPDATE, params.positionManager, params.approve)) + assembly { + let fmp := mload(0x40) + mstore(0, POSITION_MANAGER_UPDATE) + mstore(0x20, shr(96, shl(96, calldataload(params)))) // params.positionManager + mstore(0x40, iszero(iszero(calldataload(add(params, 0x20))))) // params.approve + digest := keccak256(0, 0x60) + mstore(0x40, fmp) + } + } + + function hash( + ITokenizationSpoke.TokenizedDeposit calldata params + ) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + TOKENIZED_DEPOSIT_TYPEHASH, + params.depositor, + params.assets, + params.receiver, + params.nonce, + params.deadline + ) + ); + } + + function hash(ITokenizationSpoke.TokenizedMint calldata params) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + TOKENIZED_MINT_TYPEHASH, + params.depositor, + params.shares, + params.receiver, + params.nonce, + params.deadline + ) + ); + } + + function hash( + ITokenizationSpoke.TokenizedWithdraw calldata params + ) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + TOKENIZED_WITHDRAW_TYPEHASH, + params.owner, + params.assets, + params.receiver, + params.nonce, + params.deadline + ) + ); + } + + function hash( + ITokenizationSpoke.TokenizedRedeem calldata params + ) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + TOKENIZED_REDEEM_TYPEHASH, + params.owner, + params.shares, + params.receiver, + params.nonce, + params.deadline + ) + ); + } +} diff --git a/src/spoke/libraries/KeyValueList.sol b/src/spoke/libraries/KeyValueList.sol index 7869244ab..1de96fa5f 100644 --- a/src/spoke/libraries/KeyValueList.sol +++ b/src/spoke/libraries/KeyValueList.sol @@ -8,21 +8,25 @@ import {Arrays} from 'src/dependencies/openzeppelin/Arrays.sol'; /// @author Aave Labs /// @notice Library to pack key-value pairs in a list. /// @dev The `sortByKey` helper sorts by ascending order of the `key` & in case of collision by descending order of the `value`. -/// @dev This is achieved by sorting the packed `key-value` pair in descending order, but storing the invert of the `key` (ie `_MAX_KEY - key`). +/// @dev This is achieved by sorting the packed `key-value` pair in descending order, but storing the invert of the `key` (ie `MAX_KEY - key`). /// @dev Uninitialized keys are returned as (key: 0, value: 0) and are placed at the end of the list after sorting. library KeyValueList { - /// @notice Thrown when adding a key which can't be stored in `_KEY_BITS` or value in `_VALUE_BITS`. + using Arrays for uint256[]; + using KeyValueList for *; + + /// @notice Thrown when adding a key which can't be stored in `KEY_BITS` or value in `VALUE_BITS`. error MaxDataSizeExceeded(); + /// @notice Container for packed key value dynamic list. struct List { uint256[] _inner; } - uint256 internal constant _KEY_BITS = 32; - uint256 internal constant _VALUE_BITS = 224; - uint256 internal constant _MAX_KEY = (1 << _KEY_BITS) - 1; - uint256 internal constant _MAX_VALUE = (1 << _VALUE_BITS) - 1; - uint256 internal constant _KEY_SHIFT = 256 - _KEY_BITS; + uint256 internal constant KEY_BITS = 32; + uint256 internal constant VALUE_BITS = 224; + uint256 internal constant MAX_KEY = (1 << KEY_BITS) - 1; + uint256 internal constant MAX_VALUE = (1 << VALUE_BITS) - 1; + uint256 internal constant KEY_SHIFT = 256 - KEY_BITS; /// @notice Allocates memory for a KeyValue list of `size` elements. function init(uint256 size) internal pure returns (List memory) { @@ -35,16 +39,22 @@ library KeyValueList { } /// @notice Inserts packed `key`, `value` at `idx`. Reverts if data exceeds maximum allowed size. - /// @dev Reverts if `key` equals or exceeds the `_MAX_KEY` value and reverts if `value` equals or exceeds the `_MAX_VALUE` value. + /// @dev Reverts if `key` equals or exceeds the `MAX_KEY` value and reverts if `value` equals or exceeds the `MAX_VALUE` value. function add(List memory self, uint256 idx, uint256 key, uint256 value) internal pure { - require(key < _MAX_KEY && value < _MAX_VALUE, MaxDataSizeExceeded()); + require(key < MAX_KEY && value < MAX_VALUE, MaxDataSizeExceeded()); self._inner[idx] = pack(key, value); } /// @notice Returns the key-value pair at the given index. /// @dev Uninitialized keys are returned as (key: 0, value: 0). function get(List memory self, uint256 idx) internal pure returns (uint256, uint256) { - return unpack(self._inner[idx]); + return self._inner[idx].unpack(); + } + + /// @notice Returns the key-value pair at the given index without bounds checking. + /// @dev Uninitialized keys are returned as (key: 0, value: 0). + function uncheckedAt(List memory self, uint256 idx) internal pure returns (uint256, uint256) { + return self._inner.unsafeMemoryAccess(idx).unpack(); } /// @notice Sorts the list in-place by ascending order of `key`, and descending order of `value` on collision. @@ -52,25 +62,27 @@ library KeyValueList { /// @dev Since `key` is in the MSB, we can sort by the key by sorting the array in descending order /// (so the keys are in ascending order when unpacking, due to the inversion when packed). function sortByKey(List memory self) internal pure { - Arrays.sort(self._inner, gtComparator); + self._inner.sort(gtComparator); } /// @notice Packs a given `key`, `value` pair into a single word. /// @dev Bound checks are expected to be done before packing. function pack(uint256 key, uint256 value) internal pure returns (uint256) { - return ((_MAX_KEY - key) << _KEY_SHIFT) | value; + return ((MAX_KEY - key) << KEY_SHIFT) | value; } /// @notice Unpacks `key` from a previously packed word containing `key` and `value`. /// @dev The key is stored in the most significant bits of the word. function unpackKey(uint256 data) internal pure returns (uint256) { - return _MAX_KEY - (data >> _KEY_SHIFT); + unchecked { + return MAX_KEY - (data >> KEY_SHIFT); + } } /// @notice Unpacks `value` from a previously packed word containing `key` and `value`. /// @dev The value is stored in the least significant bits of the word. function unpackValue(uint256 data) internal pure returns (uint256) { - return data & ((1 << _KEY_SHIFT) - 1); + return data & ((1 << KEY_SHIFT) - 1); } /// @notice Unpacks both `key` and `value` from a previously packed word containing `key` and `value`. @@ -78,7 +90,7 @@ library KeyValueList { /// @param data The packed word containing `key` and `value`. function unpack(uint256 data) internal pure returns (uint256, uint256) { if (data == 0) return (0, 0); - return (unpackKey(data), unpackValue(data)); + return (data.unpackKey(), data.unpackValue()); } /// @notice Comparator function performing greater-than comparison. diff --git a/src/spoke/libraries/LiquidationLogic.sol b/src/spoke/libraries/LiquidationLogic.sol index d141e8b53..46737dc67 100644 --- a/src/spoke/libraries/LiquidationLogic.sol +++ b/src/spoke/libraries/LiquidationLogic.sol @@ -2,13 +2,15 @@ // Copyright (c) 2025 Aave Labs pragma solidity ^0.8.20; +import {Math} from 'src/dependencies/openzeppelin/Math.sol'; import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.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'; +import {SpokeUtils} from 'src/spoke/libraries/SpokeUtils.sol'; import {PositionStatusMap} from 'src/spoke/libraries/PositionStatusMap.sol'; -import {UserPositionDebt} from 'src/spoke/libraries/UserPositionDebt.sol'; +import {UserPositionUtils} from 'src/spoke/libraries/UserPositionUtils.sol'; import {ReserveFlags, ReserveFlagsMap} from 'src/spoke/libraries/ReserveFlagsMap.sol'; import {IHubBase} from 'src/hub/interfaces/IHubBase.sol'; import {IAaveOracle} from 'src/spoke/interfaces/IAaveOracle.sol'; @@ -23,7 +25,8 @@ library LiquidationLogic { using MathUtils for *; using PercentageMath for uint256; using WadRayMath for uint256; - using UserPositionDebt for ISpoke.UserPosition; + using SpokeUtils for *; + using UserPositionUtils for ISpoke.UserPosition; using ReserveFlagsMap for ReserveFlags; using PositionStatusMap for ISpoke.PositionStatus; @@ -32,40 +35,76 @@ library LiquidationLogic { uint256 debtReserveId; address oracle; address user; + ISpoke.LiquidationConfig liquidationConfig; uint256 debtToCover; - uint256 healthFactor; - uint256 drawnDebt; - uint256 premiumDebtRay; - uint256 drawnIndex; - uint256 totalDebtValue; + ISpoke.UserAccountData userAccountData; address liquidator; + bool receiveShares; + } + + struct ExecuteLiquidationParams { + IHubBase collateralHub; + uint256 collateralAssetId; + uint256 collateralAssetDecimals; + uint256 collateralReserveId; + ReserveFlags collateralReserveFlags; + ISpoke.DynamicReserveConfig collateralDynConfig; + IHubBase debtHub; + uint256 debtAssetId; + uint256 debtAssetDecimals; + address debtUnderlying; + uint256 debtReserveId; + ReserveFlags debtReserveFlags; + ISpoke.LiquidationConfig liquidationConfig; + address oracle; + address user; + uint256 debtToCover; + uint256 healthFactor; + uint256 totalDebtValueRay; uint256 activeCollateralCount; - uint256 borrowedCount; + uint256 borrowCount; + address liquidator; bool receiveShares; } struct LiquidateCollateralParams { - uint256 collateralToLiquidate; - uint256 collateralToLiquidator; + IHubBase hub; + uint256 assetId; + uint256 sharesToLiquidate; + uint256 sharesToLiquidator; address liquidator; bool receiveShares; } + struct LiquidateCollateralResult { + uint256 amountRemoved; + bool isCollateralPositionEmpty; + } + struct LiquidateDebtParams { - uint256 debtReserveId; - uint256 debtToLiquidate; - uint256 premiumDebtRay; + IHubBase hub; + uint256 assetId; + address underlying; + uint256 reserveId; + uint256 drawnSharesToLiquidate; + uint256 premiumDebtRayToLiquidate; uint256 drawnIndex; address liquidator; } + struct LiquidateDebtResult { + uint256 amountRestored; + IHubBase.PremiumDelta premiumDelta; + bool isDebtPositionEmpty; + } + struct ValidateLiquidationCallParams { address user; address liquidator; ReserveFlags collateralReserveFlags; ReserveFlags debtReserveFlags; - uint256 collateralReserveBalance; - uint256 debtReserveBalance; + uint256 suppliedShares; + uint256 drawnShares; uint256 debtToCover; uint256 collateralFactor; bool isUsingAsCollateral; @@ -74,7 +113,7 @@ library LiquidationLogic { } struct CalculateDebtToTargetHealthFactorParams { - uint256 totalDebtValue; + uint256 totalDebtValueRay; uint256 debtAssetUnit; uint256 debtAssetPrice; uint256 collateralFactor; @@ -84,8 +123,11 @@ library LiquidationLogic { } struct CalculateDebtToLiquidateParams { - uint256 debtReserveBalance; - uint256 totalDebtValue; + uint256 drawnShares; + uint256 premiumDebtRay; + uint256 drawnIndex; + uint256 totalDebtValueRay; + uint256 debtAssetDecimals; uint256 debtAssetUnit; uint256 debtAssetPrice; uint256 debtToCover; @@ -95,14 +137,31 @@ library LiquidationLogic { uint256 targetHealthFactor; } - struct CalculateLiquidationAmountsParams { - uint256 collateralReserveBalance; + struct CalculateCollateralToLiquidateParams { + IHubBase collateralReserveHub; + uint256 collateralReserveAssetId; uint256 collateralAssetUnit; uint256 collateralAssetPrice; - uint256 debtReserveBalance; - uint256 totalDebtValue; + uint256 drawnSharesToLiquidate; + uint256 premiumDebtRayToLiquidate; + uint256 drawnIndex; uint256 debtAssetUnit; uint256 debtAssetPrice; + uint256 liquidationBonus; + } + + struct CalculateLiquidationAmountsParams { + IHubBase collateralReserveHub; + uint256 collateralReserveAssetId; + uint256 suppliedShares; + uint256 collateralAssetDecimals; + uint256 collateralAssetPrice; + uint256 drawnShares; + uint256 premiumDebtRay; + uint256 drawnIndex; + uint256 totalDebtValueRay; + uint256 debtAssetDecimals; + uint256 debtAssetPrice; uint256 debtToCover; uint256 collateralFactor; uint256 healthFactorForMaxBonus; @@ -114,133 +173,134 @@ library LiquidationLogic { } struct LiquidationAmounts { - uint256 collateralToLiquidate; - uint256 collateralToLiquidator; - uint256 debtToLiquidate; + uint256 collateralSharesToLiquidate; + uint256 collateralSharesToLiquidator; + uint256 drawnSharesToLiquidate; + uint256 premiumDebtRayToLiquidate; } - // see ISpoke.HEALTH_FACTOR_LIQUIDATION_THRESHOLD docs + /// @dev See Spoke.HEALTH_FACTOR_LIQUIDATION_THRESHOLD docs uint64 public constant HEALTH_FACTOR_LIQUIDATION_THRESHOLD = 1e18; - // see ISpoke.DUST_LIQUIDATION_THRESHOLD docs + /// @dev See Spoke.DUST_LIQUIDATION_THRESHOLD docs uint256 public constant DUST_LIQUIDATION_THRESHOLD = 1000e26; /// @notice Liquidates a user position. - /// @param collateralReserve The collateral reserve to seize during liquidation. - /// @param debtReserve The debt reserve to repay during liquidation. - /// @param positions The mapping of positions per reserve per user. + /// @param reserves The mapping of reserves per reserve id. + /// @param userPositions The mapping of user positions per user per reserve. /// @param positionStatus The mapping of position status per user. - /// @param liquidationConfig The liquidation config. - /// @param collateralDynConfig The collateral dynamic config. + /// @param dynamicConfig The mapping of dynamic config per reserve per dynamic config key. /// @param params The liquidate user params. /// @return True if the liquidation results in deficit. function liquidateUser( - ISpoke.Reserve storage collateralReserve, - ISpoke.Reserve storage debtReserve, - mapping(address user => mapping(uint256 reserveId => ISpoke.UserPosition)) storage positions, + mapping(uint256 reserveId => ISpoke.Reserve) storage reserves, + mapping(address user => mapping(uint256 reserveId => ISpoke.UserPosition)) storage userPositions, mapping(address user => ISpoke.PositionStatus) storage positionStatus, - ISpoke.LiquidationConfig storage liquidationConfig, - ISpoke.DynamicReserveConfig storage collateralDynConfig, + mapping(uint256 reserveId => mapping(uint32 dynamicConfigKey => ISpoke.DynamicReserveConfig)) storage dynamicConfig, LiquidateUserParams memory params ) external returns (bool) { - uint256 collateralReserveBalance = collateralReserve.hub.previewRemoveByShares( - collateralReserve.assetId, - positions[params.user][params.collateralReserveId].suppliedShares - ); - _validateLiquidationCall( - ValidateLiquidationCallParams({ - user: params.user, - liquidator: params.liquidator, - collateralReserveFlags: collateralReserve.flags, - debtReserveFlags: debtReserve.flags, - collateralReserveBalance: collateralReserveBalance, - debtReserveBalance: params.drawnDebt + params.premiumDebtRay.fromRayUp(), - debtToCover: params.debtToCover, - collateralFactor: collateralDynConfig.collateralFactor, - isUsingAsCollateral: positionStatus[params.user].isUsingAsCollateral( - params.collateralReserveId - ), - healthFactor: params.healthFactor, - receiveShares: params.receiveShares - }) - ); + ISpoke.Reserve storage collateralReserve = reserves.get(params.collateralReserveId); + ISpoke.Reserve storage debtReserve = reserves.get(params.debtReserveId); + + ISpoke.UserPosition storage collateralUserPosition = userPositions[params.user][ + params.collateralReserveId + ]; + ISpoke.DynamicReserveConfig storage collateralDynConfig = dynamicConfig[ + params.collateralReserveId + ][collateralUserPosition.dynamicConfigKey]; + + ExecuteLiquidationParams memory executeLiquidationParams = ExecuteLiquidationParams({ + collateralHub: collateralReserve.hub, + collateralAssetId: collateralReserve.assetId, + collateralAssetDecimals: collateralReserve.decimals, + collateralReserveId: params.collateralReserveId, + collateralReserveFlags: collateralReserve.flags, + collateralDynConfig: collateralDynConfig, + debtHub: debtReserve.hub, + debtAssetId: debtReserve.assetId, + debtAssetDecimals: debtReserve.decimals, + debtUnderlying: debtReserve.underlying, + debtReserveId: params.debtReserveId, + debtReserveFlags: debtReserve.flags, + liquidationConfig: params.liquidationConfig, + oracle: params.oracle, + user: params.user, + debtToCover: params.debtToCover, + healthFactor: params.userAccountData.healthFactor, + totalDebtValueRay: params.userAccountData.totalDebtValueRay, + activeCollateralCount: params.userAccountData.activeCollateralCount, + borrowCount: params.userAccountData.borrowCount, + liquidator: params.liquidator, + receiveShares: params.receiveShares + }); - LiquidationAmounts memory liquidationAmounts = _calculateLiquidationAmounts( - CalculateLiquidationAmountsParams({ - collateralReserveBalance: collateralReserveBalance, - collateralAssetUnit: MathUtils.uncheckedExp(10, collateralReserve.decimals), - collateralAssetPrice: IAaveOracle(params.oracle).getReservePrice( - params.collateralReserveId - ), - debtReserveBalance: params.drawnDebt + params.premiumDebtRay.fromRayUp(), - totalDebtValue: params.totalDebtValue, - debtAssetUnit: MathUtils.uncheckedExp(10, debtReserve.decimals), - debtAssetPrice: IAaveOracle(params.oracle).getReservePrice(params.debtReserveId), - debtToCover: params.debtToCover, - collateralFactor: collateralDynConfig.collateralFactor, - healthFactorForMaxBonus: liquidationConfig.healthFactorForMaxBonus, - liquidationBonusFactor: liquidationConfig.liquidationBonusFactor, - maxLiquidationBonus: collateralDynConfig.maxLiquidationBonus, - targetHealthFactor: liquidationConfig.targetHealthFactor, - healthFactor: params.healthFactor, - liquidationFee: collateralDynConfig.liquidationFee - }) - ); + ISpoke.UserPosition storage debtUserPosition = userPositions[params.user][params.debtReserveId]; + ISpoke.UserPosition storage collateralLiquidatorPosition = userPositions[params.liquidator][ + params.collateralReserveId + ]; + ISpoke.PositionStatus storage userPositionStatus = positionStatus[params.user]; - ( - uint256 collateralSharesToLiquidate, - uint256 collateralSharesToLiquidator, - bool isCollateralPositionEmpty - ) = _liquidateCollateral( - collateralReserve, - positions[params.user][params.collateralReserveId], - positions[params.liquidator][params.collateralReserveId], - LiquidateCollateralParams({ - collateralToLiquidate: liquidationAmounts.collateralToLiquidate, - collateralToLiquidator: liquidationAmounts.collateralToLiquidator, - liquidator: params.liquidator, - receiveShares: params.receiveShares - }) + return + _executeLiquidation({ + collateralUserPosition: collateralUserPosition, + debtUserPosition: debtUserPosition, + collateralLiquidatorPosition: collateralLiquidatorPosition, + userPositionStatus: userPositionStatus, + params: executeLiquidationParams + }); + } + + /// @notice Reports deficits for all debt reserves of the user. + /// @dev Deficit validation should already have occurred during liquidation. + /// @dev It clears the user position, setting drawn debt, premium debt and user risk premium to zero. + /// @param reserves The mapping of reserves per reserve identifier. + /// @param userPositions The mapping of user positions per reserve per user. + /// @param positionStatus The mapping of position status per user. + /// @param reserveCount The number of reserves. + /// @param user The address of the user. + function notifyReportDeficit( + mapping(uint256 reserveId => ISpoke.Reserve) storage reserves, + mapping(address user => mapping(uint256 reserveId => ISpoke.UserPosition)) storage userPositions, + mapping(address user => ISpoke.PositionStatus) storage positionStatus, + uint256 reserveCount, + address user + ) external { + ISpoke.PositionStatus storage userPositionStatus = positionStatus[user]; + userPositionStatus.riskPremium = 0; + + uint256 reserveId = reserveCount; + while ( + (reserveId = userPositionStatus.nextBorrowing(reserveId)) != PositionStatusMap.NOT_FOUND + ) { + ISpoke.UserPosition storage userPosition = userPositions[user][reserveId]; + ISpoke.Reserve storage reserve = reserves[reserveId]; + IHubBase hub = reserve.hub; + uint256 assetId = reserve.assetId; + + UserPositionUtils.DebtComponents memory debtComponents = userPosition.getDebtComponents( + hub, + assetId ); + IHubBase.PremiumDelta memory premiumDelta = userPosition.calculatePremiumDelta({ + drawnSharesTaken: debtComponents.drawnShares, + drawnIndex: debtComponents.drawnIndex, + riskPremium: 0, + restoredPremiumRay: debtComponents.premiumDebtRay + }); - ( - uint256 drawnSharesToLiquidate, - IHubBase.PremiumDelta memory premiumDelta, - bool isDebtPositionEmpty - ) = _liquidateDebt( - debtReserve, - positions[params.user][params.debtReserveId], - positionStatus[params.user], - LiquidateDebtParams({ - debtReserveId: params.debtReserveId, - debtToLiquidate: liquidationAmounts.debtToLiquidate, - premiumDebtRay: params.premiumDebtRay, - drawnIndex: params.drawnIndex, - liquidator: params.liquidator - }) + hub.reportDeficit( + assetId, + debtComponents.drawnShares.rayMulUp(debtComponents.drawnIndex), + premiumDelta ); + userPosition.applyPremiumDelta(premiumDelta); + userPosition.drawnShares -= debtComponents.drawnShares.toUint120(); + userPositionStatus.setBorrowing(reserveId, false); - emit ISpokeBase.LiquidationCall( - params.collateralReserveId, - params.debtReserveId, - params.user, - params.liquidator, - params.receiveShares, - liquidationAmounts.debtToLiquidate, - drawnSharesToLiquidate, - premiumDelta, - liquidationAmounts.collateralToLiquidate, - collateralSharesToLiquidate, - collateralSharesToLiquidator - ); + emit ISpoke.ReportDeficit(reserveId, user, debtComponents.drawnShares, premiumDelta); + } - return - _evaluateDeficit({ - isCollateralPositionEmpty: isCollateralPositionEmpty, - isDebtPositionEmpty: isDebtPositionEmpty, - activeCollateralCount: params.activeCollateralCount, - borrowedCount: params.borrowedCount - }); + emit ISpoke.UpdateUserRiskPremium(user, 0); } /// @notice Calculates the liquidation bonus at a given health factor. @@ -255,7 +315,7 @@ library LiquidationLogic { uint256 liquidationBonusFactor, uint256 healthFactor, uint256 maxLiquidationBonus - ) internal pure returns (uint256) { + ) public pure returns (uint256) { if (healthFactor <= healthFactorForMaxBonus) { return maxLiquidationBonus; } @@ -273,79 +333,200 @@ library LiquidationLogic { ); } + /// @dev Executes the liquidation. + /// @param collateralUserPosition User's collateral position. + /// @param debtUserPosition User's debt position. + /// @param collateralLiquidatorPosition Liquidator's collateral position. + /// @param userPositionStatus User's position status. + /// @param params The execute liquidation params. + /// @return True if the liquidation results in deficit. + function _executeLiquidation( + ISpoke.UserPosition storage collateralUserPosition, + ISpoke.UserPosition storage debtUserPosition, + ISpoke.UserPosition storage collateralLiquidatorPosition, + ISpoke.PositionStatus storage userPositionStatus, + ExecuteLiquidationParams memory params + ) internal returns (bool) { + uint256 suppliedShares = collateralUserPosition.suppliedShares; + UserPositionUtils.DebtComponents memory debtComponents = debtUserPosition.getDebtComponents( + params.debtHub, + params.debtAssetId + ); + + _validateLiquidationCall( + ValidateLiquidationCallParams({ + user: params.user, + liquidator: params.liquidator, + collateralReserveFlags: params.collateralReserveFlags, + debtReserveFlags: params.debtReserveFlags, + suppliedShares: suppliedShares, + drawnShares: debtComponents.drawnShares, + debtToCover: params.debtToCover, + collateralFactor: params.collateralDynConfig.collateralFactor, + isUsingAsCollateral: userPositionStatus.isUsingAsCollateral(params.collateralReserveId), + healthFactor: params.healthFactor, + receiveShares: params.receiveShares + }) + ); + + LiquidationAmounts memory liquidationAmounts = _calculateLiquidationAmounts( + CalculateLiquidationAmountsParams({ + collateralReserveHub: params.collateralHub, + collateralReserveAssetId: params.collateralAssetId, + suppliedShares: suppliedShares, + collateralAssetDecimals: params.collateralAssetDecimals, + collateralAssetPrice: IAaveOracle(params.oracle).getReservePrice( + params.collateralReserveId + ), + drawnShares: debtComponents.drawnShares, + premiumDebtRay: debtComponents.premiumDebtRay, + drawnIndex: debtComponents.drawnIndex, + totalDebtValueRay: params.totalDebtValueRay, + debtAssetDecimals: params.debtAssetDecimals, + debtAssetPrice: IAaveOracle(params.oracle).getReservePrice(params.debtReserveId), + debtToCover: params.debtToCover, + collateralFactor: params.collateralDynConfig.collateralFactor, + healthFactorForMaxBonus: params.liquidationConfig.healthFactorForMaxBonus, + liquidationBonusFactor: params.liquidationConfig.liquidationBonusFactor, + maxLiquidationBonus: params.collateralDynConfig.maxLiquidationBonus, + targetHealthFactor: params.liquidationConfig.targetHealthFactor, + healthFactor: params.healthFactor, + liquidationFee: params.collateralDynConfig.liquidationFee + }) + ); + + LiquidateCollateralResult memory liquidateCollateralResult = _liquidateCollateral( + collateralUserPosition, + collateralLiquidatorPosition, + LiquidateCollateralParams({ + hub: params.collateralHub, + assetId: params.collateralAssetId, + sharesToLiquidate: liquidationAmounts.collateralSharesToLiquidate, + sharesToLiquidator: liquidationAmounts.collateralSharesToLiquidator, + liquidator: params.liquidator, + receiveShares: params.receiveShares + }) + ); + + LiquidateDebtResult memory liquidateDebtResult = _liquidateDebt( + debtUserPosition, + userPositionStatus, + LiquidateDebtParams({ + hub: params.debtHub, + assetId: params.debtAssetId, + underlying: params.debtUnderlying, + reserveId: params.debtReserveId, + drawnSharesToLiquidate: liquidationAmounts.drawnSharesToLiquidate, + premiumDebtRayToLiquidate: liquidationAmounts.premiumDebtRayToLiquidate, + drawnIndex: debtComponents.drawnIndex, + liquidator: params.liquidator + }) + ); + + emit ISpokeBase.LiquidationCall({ + collateralReserveId: params.collateralReserveId, + debtReserveId: params.debtReserveId, + user: params.user, + liquidator: params.liquidator, + receiveShares: params.receiveShares, + debtAmountRestored: liquidateDebtResult.amountRestored, + drawnSharesLiquidated: liquidationAmounts.drawnSharesToLiquidate, + premiumDelta: liquidateDebtResult.premiumDelta, + collateralAmountRemoved: liquidateCollateralResult.amountRemoved, + collateralSharesLiquidated: liquidationAmounts.collateralSharesToLiquidate, + collateralSharesToLiquidator: liquidationAmounts.collateralSharesToLiquidator + }); + + return + _evaluateDeficit({ + isCollateralPositionEmpty: liquidateCollateralResult.isCollateralPositionEmpty, + isDebtPositionEmpty: liquidateDebtResult.isDebtPositionEmpty, + activeCollateralCount: params.activeCollateralCount, + borrowCount: params.borrowCount + }); + } + /// @dev Invoked by `liquidateUser` method. - /// @return The total amount of collateral shares to be liquidated. - /// @return The amount of collateral shares that the liquidator receives. - /// @return True if the user collateral position becomes empty after removing. + /// @return The liquidate collateral result. function _liquidateCollateral( - ISpoke.Reserve storage collateralReserve, - ISpoke.UserPosition storage collateralPosition, - ISpoke.UserPosition storage liquidatorCollateralPosition, + ISpoke.UserPosition storage userPosition, + ISpoke.UserPosition storage liquidatorPosition, LiquidateCollateralParams memory params - ) internal returns (uint256, uint256, bool) { - IHubBase hub = collateralReserve.hub; - uint256 assetId = collateralReserve.assetId; - - uint256 sharesToLiquidate = hub.previewRemoveByAssets(assetId, params.collateralToLiquidate); - uint120 userSuppliedShares = collateralPosition.suppliedShares - sharesToLiquidate.toUint120(); + ) internal returns (LiquidateCollateralResult memory) { + uint120 newUserSuppliedShares = userPosition.suppliedShares - + params.sharesToLiquidate.toUint120(); + userPosition.suppliedShares = newUserSuppliedShares; + + uint256 amountRemoved = params.hub.previewRemoveByShares( + params.assetId, + params.sharesToLiquidate + ); - uint256 sharesToLiquidator; - if (params.collateralToLiquidator > 0) { + if (params.sharesToLiquidator > 0) { if (params.receiveShares) { - sharesToLiquidator = hub.previewAddByAssets(assetId, params.collateralToLiquidator); - if (sharesToLiquidator > 0) { - liquidatorCollateralPosition.suppliedShares += sharesToLiquidator.toUint120(); - } + liquidatorPosition.suppliedShares += params.sharesToLiquidator.toUint120(); } else { - sharesToLiquidator = hub.remove(assetId, params.collateralToLiquidator, params.liquidator); + uint256 amountToLiquidator = amountRemoved; + if (params.sharesToLiquidator != params.sharesToLiquidate) { + amountToLiquidator = params.hub.previewRemoveByShares( + params.assetId, + params.sharesToLiquidator + ); + } + params.hub.remove(params.assetId, amountToLiquidator, params.liquidator); } } - collateralPosition.suppliedShares = userSuppliedShares; - - if (sharesToLiquidate > sharesToLiquidator) { - hub.payFeeShares(assetId, sharesToLiquidate.uncheckedSub(sharesToLiquidator)); + uint256 feeShares = params.sharesToLiquidate - params.sharesToLiquidator; + if (feeShares > 0) { + params.hub.payFeeShares(params.assetId, feeShares); } - return (sharesToLiquidate, sharesToLiquidator, userSuppliedShares == 0); + return + LiquidateCollateralResult({ + amountRemoved: amountRemoved, + isCollateralPositionEmpty: newUserSuppliedShares == 0 + }); } /// @dev Invoked by `liquidateUser` method. - /// @return The amount of drawn shares to be liquidated. - /// @return A struct representing the changes to premium debt after liquidation. - /// @return True if the debt position becomes zero after restoring. + /// @return The liquidate debt result. function _liquidateDebt( - ISpoke.Reserve storage debtReserve, - ISpoke.UserPosition storage debtPosition, + ISpoke.UserPosition storage userPosition, ISpoke.PositionStatus storage positionStatus, LiquidateDebtParams memory params - ) internal returns (uint256, IHubBase.PremiumDelta memory, bool) { - uint256 premiumDebtToLiquidateRay = params.debtToLiquidate.toRay().min(params.premiumDebtRay); - uint256 drawnDebtLiquidated = params.debtToLiquidate - premiumDebtToLiquidateRay.fromRayUp(); - uint256 drawnSharesLiquidated = drawnDebtLiquidated.rayDivDown(params.drawnIndex); - - IHubBase.PremiumDelta memory premiumDelta = debtPosition.getPremiumDelta({ - drawnSharesTaken: drawnSharesLiquidated, + ) internal returns (LiquidateDebtResult memory) { + IHubBase.PremiumDelta memory premiumDelta = userPosition.calculatePremiumDelta({ + drawnSharesTaken: params.drawnSharesToLiquidate, drawnIndex: params.drawnIndex, riskPremium: positionStatus.riskPremium, - restoredPremiumRay: premiumDebtToLiquidateRay + restoredPremiumRay: params.premiumDebtRayToLiquidate }); - IERC20(debtReserve.underlying).safeTransferFrom( + uint256 drawnAmountToRestore = params.drawnSharesToLiquidate.rayMulUp(params.drawnIndex); + uint256 amountToRestore = drawnAmountToRestore + params.premiumDebtRayToLiquidate.fromRayUp(); + IERC20(params.underlying).safeTransferFrom( params.liquidator, - address(debtReserve.hub), - params.debtToLiquidate + address(params.hub), + amountToRestore ); - debtReserve.hub.restore(debtReserve.assetId, drawnDebtLiquidated, premiumDelta); + params.hub.restore(params.assetId, drawnAmountToRestore, premiumDelta); - debtPosition.applyPremiumDelta(premiumDelta); - debtPosition.drawnShares -= drawnSharesLiquidated.toUint120(); - if (debtPosition.drawnShares == 0) { - positionStatus.setBorrowing(params.debtReserveId, false); - return (drawnSharesLiquidated, premiumDelta, true); + userPosition.applyPremiumDelta(premiumDelta); + userPosition.drawnShares -= params.drawnSharesToLiquidate.toUint120(); + + bool isDebtPositionEmpty; + if (userPosition.drawnShares == 0) { + positionStatus.setBorrowing(params.reserveId, false); + isDebtPositionEmpty = true; } - return (drawnSharesLiquidated, premiumDelta, false); + return + LiquidateDebtResult({ + amountRestored: amountToRestore, + premiumDelta: premiumDelta, + isDebtPositionEmpty: isDebtPositionEmpty + }); } /// @notice Validates the liquidation call. @@ -357,9 +538,10 @@ library LiquidationLogic { !params.collateralReserveFlags.paused() && !params.debtReserveFlags.paused(), ISpoke.ReservePaused() ); - require(params.collateralReserveBalance > 0, ISpoke.ReserveNotSupplied()); - require(params.debtReserveBalance > 0, ISpoke.ReserveNotBorrowed()); - require(params.collateralReserveFlags.liquidatable(), ISpoke.CollateralCannotBeLiquidated()); + require(params.suppliedShares > 0, ISpoke.ReserveNotSupplied()); + // user has active debt if and only if user has drawn shares (premium debt is always repaid first, + // and can only be created when drawn shares exist) + require(params.drawnShares > 0, ISpoke.ReserveNotBorrowed()); require( params.healthFactor < HEALTH_FACTOR_LIQUIDATION_THRESHOLD, ISpoke.HealthFactorNotBelowThreshold() @@ -381,7 +563,10 @@ library LiquidationLogic { /// @dev Invoked by `liquidateUser` method. function _calculateLiquidationAmounts( CalculateLiquidationAmountsParams memory params - ) internal pure returns (LiquidationAmounts memory) { + ) internal view returns (LiquidationAmounts memory) { + uint256 collateralAssetUnit = MathUtils.uncheckedExp(10, params.collateralAssetDecimals); + uint256 debtAssetUnit = MathUtils.uncheckedExp(10, params.debtAssetDecimals); + uint256 liquidationBonus = calculateLiquidationBonus({ healthFactorForMaxBonus: params.healthFactorForMaxBonus, liquidationBonusFactor: params.liquidationBonusFactor, @@ -393,11 +578,14 @@ library LiquidationLogic { // 1. liquidate all debt // 2. liquidate all collateral // 3. leave at least `DUST_LIQUIDATION_THRESHOLD` of collateral and debt (in value terms) - uint256 debtToLiquidate = _calculateDebtToLiquidate( + (uint256 drawnSharesToLiquidate, uint256 premiumDebtRayToLiquidate) = _calculateDebtToLiquidate( CalculateDebtToLiquidateParams({ - debtReserveBalance: params.debtReserveBalance, - totalDebtValue: params.totalDebtValue, - debtAssetUnit: params.debtAssetUnit, + drawnShares: params.drawnShares, + premiumDebtRay: params.premiumDebtRay, + drawnIndex: params.drawnIndex, + totalDebtValueRay: params.totalDebtValueRay, + debtAssetDecimals: params.debtAssetDecimals, + debtAssetUnit: debtAssetUnit, debtAssetPrice: params.debtAssetPrice, debtToCover: params.debtToCover, collateralFactor: params.collateralFactor, @@ -407,65 +595,150 @@ library LiquidationLogic { }) ); - uint256 collateralToLiquidate = debtToLiquidate.mulDivDown( - params.debtAssetPrice * params.collateralAssetUnit * liquidationBonus, - params.debtAssetUnit * params.collateralAssetPrice * PercentageMath.PERCENTAGE_FACTOR + uint256 collateralSharesToLiquidate = _calculateCollateralToLiquidate( + CalculateCollateralToLiquidateParams({ + collateralReserveHub: params.collateralReserveHub, + collateralReserveAssetId: params.collateralReserveAssetId, + collateralAssetUnit: collateralAssetUnit, + collateralAssetPrice: params.collateralAssetPrice, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate, + drawnIndex: params.drawnIndex, + debtAssetUnit: debtAssetUnit, + debtAssetPrice: params.debtAssetPrice, + liquidationBonus: liquidationBonus + }) ); - bool leavesCollateralDust = collateralToLiquidate < params.collateralReserveBalance && - (params.collateralReserveBalance - collateralToLiquidate).mulDivDown( - params.collateralAssetPrice.toWad(), - params.collateralAssetUnit - ) < - DUST_LIQUIDATION_THRESHOLD; + bool leavesCollateralDust; + if (collateralSharesToLiquidate < params.suppliedShares) { + uint256 collateralRemaining = params.collateralReserveHub.previewRemoveByShares( + params.collateralReserveAssetId, + params.suppliedShares.uncheckedSub(collateralSharesToLiquidate) + ); + leavesCollateralDust = + collateralRemaining.toValue({ + decimals: params.collateralAssetDecimals, + price: params.collateralAssetPrice + }) < DUST_LIQUIDATION_THRESHOLD; + } + // debt is fully liquidated if and only if all drawn shares are liquidated if ( - collateralToLiquidate > params.collateralReserveBalance || - (leavesCollateralDust && debtToLiquidate < params.debtReserveBalance) + collateralSharesToLiquidate > params.suppliedShares || + (leavesCollateralDust && drawnSharesToLiquidate < params.drawnShares) ) { - collateralToLiquidate = params.collateralReserveBalance; - - // - `debtToLiquidate` is decreased if `collateralToLiquidate > params.collateralReserveBalance` (if so, debt dust could remain). - // - `debtToLiquidate` is increased if `(leavesCollateralDust && debtToLiquidate < params.debtReserveBalance)`, ensuring collateral reserve - // is fully liquidated (potentially bypassing the target health factor). Can only increase by at most `DUST_LIQUIDATION_THRESHOLD` (in - // value terms). Since debt dust condition was enforced, it is guaranteed that `debtToLiquidate` will never exceed `params.debtReserveBalance`. - debtToLiquidate = collateralToLiquidate.mulDivUp( - params.collateralAssetPrice * params.debtAssetUnit * PercentageMath.PERCENTAGE_FACTOR, - params.debtAssetPrice * params.collateralAssetUnit * liquidationBonus + collateralSharesToLiquidate = params.suppliedShares; + + // - `debtRayToLiquidate` is decreased if `collateralSharesToLiquidate > params.suppliedShares` (if so, debt dust could remain). + // - `debtRayToLiquidate` is increased if `(leavesCollateralDust && drawnSharesToLiquidate < params.drawnShares)`, + // ensuring collateral reserve is fully liquidated (potentially bypassing the target health factor). + uint256 debtRayToLiquidate = Math.mulDiv( + params.collateralReserveHub.previewAddByShares( + params.collateralReserveAssetId, + collateralSharesToLiquidate + ), + params.collateralAssetPrice * + debtAssetUnit * + PercentageMath.PERCENTAGE_FACTOR * + WadRayMath.RAY, + params.debtAssetPrice * collateralAssetUnit * liquidationBonus, + Math.Rounding.Ceil ); + + if (debtRayToLiquidate <= params.premiumDebtRay) { + // `premiumDebtRayToLiquidate` may exceed `debtRayToLiquidate` as a result of rounding up to asset units, ensuring full utilization of assets + premiumDebtRayToLiquidate = debtRayToLiquidate.roundRayUp().min(params.premiumDebtRay); + drawnSharesToLiquidate = 0; + } else { + premiumDebtRayToLiquidate = params.premiumDebtRay; + drawnSharesToLiquidate = (debtRayToLiquidate - premiumDebtRayToLiquidate).divUp( + params.drawnIndex + ); + + // `drawnSharesToLiquidate` may exceed `params.drawnShares` due to rounding. + if (drawnSharesToLiquidate > params.drawnShares) { + drawnSharesToLiquidate = params.drawnShares; + + // `collateralSharesToLiquidate` may exceed `params.suppliedShares` due to rounding. + // If this happens, simply cap `collateralSharesToLiquidate` to `params.suppliedShares` since + // debt to liquidate would be the same (it is already calculated based on `params.suppliedShares`). + collateralSharesToLiquidate = _calculateCollateralToLiquidate( + CalculateCollateralToLiquidateParams({ + collateralReserveHub: params.collateralReserveHub, + collateralReserveAssetId: params.collateralReserveAssetId, + collateralAssetUnit: collateralAssetUnit, + collateralAssetPrice: params.collateralAssetPrice, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate, + drawnIndex: params.drawnIndex, + debtAssetUnit: debtAssetUnit, + debtAssetPrice: params.debtAssetPrice, + liquidationBonus: liquidationBonus + }) + ).min(params.suppliedShares); + } + } } - // revert if the liquidator does not cover the necessary debt to prevent dust from remaining - require(params.debtToCover >= debtToLiquidate, ISpoke.MustNotLeaveDust()); + // revert if the liquidator does not intend to cover the necessary debt to prevent dust from remaining + require( + params.debtToCover >= + drawnSharesToLiquidate.rayMulUp(params.drawnIndex) + premiumDebtRayToLiquidate.fromRayUp(), + ISpoke.MustNotLeaveDust() + ); - uint256 collateralToLiquidator = collateralToLiquidate - - collateralToLiquidate.mulDivDown( + uint256 collateralSharesToLiquidator = collateralSharesToLiquidate - + collateralSharesToLiquidate.mulDivDown( params.liquidationFee * (liquidationBonus - PercentageMath.PERCENTAGE_FACTOR), liquidationBonus * PercentageMath.PERCENTAGE_FACTOR ); return LiquidationAmounts({ - collateralToLiquidate: collateralToLiquidate, - collateralToLiquidator: collateralToLiquidator, - debtToLiquidate: debtToLiquidate + collateralSharesToLiquidate: collateralSharesToLiquidate, + collateralSharesToLiquidator: collateralSharesToLiquidator, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate }); } - /// @notice Calculates the debt that should be liquidated. - /// @dev Generally, it returns the minimum of `debtToCover`, `debtReserveBalance` and `debtToTarget`. - /// If debt dust would be left behind, it returns `debtReserveBalance` to ensure the debt is fully cleared and no dust is left. + /// @notice Calculates the amount of collateral shares that should be liquidated based on liquidated debt. + /// @return The amount of collateral shares that should be liquidated. + function _calculateCollateralToLiquidate( + CalculateCollateralToLiquidateParams memory params + ) internal view returns (uint256) { + uint256 debtRayToLiquidate = params.drawnSharesToLiquidate * params.drawnIndex + + params.premiumDebtRayToLiquidate; + + uint256 collateralToLiquidate = Math.mulDiv( + debtRayToLiquidate, + params.debtAssetPrice * params.collateralAssetUnit * params.liquidationBonus, + params.debtAssetUnit * + params.collateralAssetPrice * + PercentageMath.PERCENTAGE_FACTOR * + WadRayMath.RAY, + Math.Rounding.Floor + ); + + uint256 collateralSharesToLiquidate = params.collateralReserveHub.previewAddByAssets( + params.collateralReserveAssetId, + collateralToLiquidate + ); + + return collateralSharesToLiquidate; + } + + /// @notice Calculates the amount of drawn shares and premium debt that should be liquidated. + /// @dev Returned values ensure that total assets required to liquidate will not exceed `params.debtToCover`. + /// @return The amount of drawn shares to liquidate. Does not exceed `params.drawnShares`. + /// @return The amount of premium debt to liquidate. Does not exceed `params.premiumDebtRay`. function _calculateDebtToLiquidate( CalculateDebtToLiquidateParams memory params - ) internal pure returns (uint256) { - uint256 debtToLiquidate = params.debtReserveBalance; - if (params.debtToCover < debtToLiquidate) { - debtToLiquidate = params.debtToCover; - } - - uint256 debtToTarget = _calculateDebtToTargetHealthFactor( + ) internal pure returns (uint256, uint256) { + uint256 debtRayToTarget = _calculateDebtToTargetHealthFactor( CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: params.totalDebtValue, + totalDebtValueRay: params.totalDebtValueRay, debtAssetUnit: params.debtAssetUnit, debtAssetPrice: params.debtAssetPrice, collateralFactor: params.collateralFactor, @@ -474,26 +747,52 @@ library LiquidationLogic { targetHealthFactor: params.targetHealthFactor }) ); - if (debtToTarget < debtToLiquidate) { - debtToLiquidate = debtToTarget; + + // `premiumDebtRayToLiquidate` may exceed `debtRayToTarget` as a result of rounding up to asset units, ensuring full utilization of assets + uint256 premiumDebtRayToLiquidate = debtRayToTarget.roundRayUp().min(params.premiumDebtRay); + // strict inequality is mandatory given rounding + if (params.debtToCover < premiumDebtRayToLiquidate.fromRayUp()) { + premiumDebtRayToLiquidate = params.debtToCover.toRay(); } - bool leavesDebtDust = debtToLiquidate < params.debtReserveBalance && - (params.debtReserveBalance - debtToLiquidate).mulDivDown( - params.debtAssetPrice.toWad(), - params.debtAssetUnit - ) < - DUST_LIQUIDATION_THRESHOLD; + uint256 drawnSharesToLiquidate; + if ( + premiumDebtRayToLiquidate == params.premiumDebtRay && + premiumDebtRayToLiquidate < debtRayToTarget + ) { + uint256 drawnSharesToTarget = (debtRayToTarget - premiumDebtRayToLiquidate).divUp( + params.drawnIndex + ); + uint256 drawnSharesToCover = Math.mulDiv( + params.debtToCover - premiumDebtRayToLiquidate.fromRayUp(), + WadRayMath.RAY, + params.drawnIndex, + Math.Rounding.Floor + ); + + drawnSharesToLiquidate = drawnSharesToTarget.min(drawnSharesToCover).min(params.drawnShares); + } + + uint256 debtRayRemaining = (params.drawnShares - drawnSharesToLiquidate) * params.drawnIndex + + params.premiumDebtRay - + premiumDebtRayToLiquidate; + + // debt is fully liquidated if and only if all drawn shares are liquidated (premium debt is always liquidated first) + bool leavesDebtDust = (drawnSharesToLiquidate < params.drawnShares) && + debtRayRemaining.toValue({decimals: params.debtAssetDecimals, price: params.debtAssetPrice}) < + DUST_LIQUIDATION_THRESHOLD.toRay(); if (leavesDebtDust) { // target health factor is bypassed to prevent leaving dust - debtToLiquidate = params.debtReserveBalance; + drawnSharesToLiquidate = params.drawnShares; + premiumDebtRayToLiquidate = params.premiumDebtRay; } - return debtToLiquidate; + return (drawnSharesToLiquidate, premiumDebtRayToLiquidate); } /// @notice Calculates the amount of debt needed to be liquidated to restore a position to the target health factor. + /// @return The amount of debt needed to be liquidated to restore user to the target health factor, expressed in units of debt asset and scaled by RAY. function _calculateDebtToTargetHealthFactor( CalculateDebtToTargetHealthFactorParams memory params ) internal pure returns (uint256) { @@ -505,9 +804,11 @@ library LiquidationLogic { // `liquidationBonus.percentMulUp(collateralFactor) < PercentageMath.PERCENTAGE_FACTOR` is enforced in `_validateDynamicReserveConfig` // and targetHealthFactor is always >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD return - params.totalDebtValue.mulDivUp( + Math.mulDiv( + params.totalDebtValueRay, params.debtAssetUnit * (params.targetHealthFactor - params.healthFactor), - (params.targetHealthFactor - liquidationPenalty) * params.debtAssetPrice.toWad() + (params.targetHealthFactor - liquidationPenalty) * params.debtAssetPrice.toWad(), + Math.Rounding.Ceil ); } @@ -516,11 +817,11 @@ library LiquidationLogic { bool isCollateralPositionEmpty, bool isDebtPositionEmpty, uint256 activeCollateralCount, - uint256 borrowedCount + uint256 borrowCount ) internal pure returns (bool) { if (!isCollateralPositionEmpty || activeCollateralCount > 1) { return false; } - return !isDebtPositionEmpty || borrowedCount > 1; + return !isDebtPositionEmpty || borrowCount > 1; } } diff --git a/src/spoke/libraries/PositionStatusMap.sol b/src/spoke/libraries/PositionStatusMap.sol index e26387c22..e0b8be876 100644 --- a/src/spoke/libraries/PositionStatusMap.sol +++ b/src/spoke/libraries/PositionStatusMap.sol @@ -98,6 +98,23 @@ library PositionStatusMap { } } + /// @notice Counts the number of reserves borrowed. + /// @dev Disregards potential dirty bits set after `reserveCount`. + /// @param reserveCount The current `reserveCount`, to avoid reading uninitialized buckets. + function borrowCount( + ISpoke.PositionStatus storage self, + uint256 reserveCount + ) internal view returns (uint256) { + unchecked { + uint256 bucket = reserveCount.bucketId(); + uint256 count = self.map[bucket].isolateBorrowingUntil(reserveCount).popCount(); + while (bucket != 0) { + count += self.map[--bucket].isolateBorrowing().popCount(); + } + return count; + } + } + /// @notice Finds the previous borrowing or collateralized reserve strictly before `fromReserveId`. /// @dev The search starts at `fromReserveId` (exclusive) and scans backward across buckets. /// @dev Returns `NOT_FOUND` if no borrowing or collateralized reserve exists before the bound. diff --git a/src/spoke/libraries/ReserveFlagsMap.sol b/src/spoke/libraries/ReserveFlagsMap.sol index fef874f9c..a7f52daf3 100644 --- a/src/spoke/libraries/ReserveFlagsMap.sol +++ b/src/spoke/libraries/ReserveFlagsMap.sol @@ -14,30 +14,25 @@ library ReserveFlagsMap { uint8 internal constant FROZEN_MASK = 0x02; /// @dev Mask for the `borrowable` flag. uint8 internal constant BORROWABLE_MASK = 0x04; - /// @dev Mask for the `liquidatable` flag. - uint8 internal constant LIQUIDATABLE_MASK = 0x08; /// @dev Mask for the `receiveSharesEnabled` flag. - uint8 internal constant RECEIVE_SHARES_ENABLED_MASK = 0x10; + uint8 internal constant RECEIVE_SHARES_ENABLED_MASK = 0x08; /// @notice Initializes the ReserveFlags with the given values. /// @param initPaused The initial `paused` flag status. /// @param initFrozen The initial `frozen` flag status. /// @param initBorrowable The initial `borrowable` flag status. - /// @param initLiquidatable The initial `liquidatable` flag status. /// @param initReceiveSharesEnabled The initial `receiveSharesEnabled` flag status. /// @return The initialized ReserveFlags. function create( bool initPaused, bool initFrozen, bool initBorrowable, - bool initLiquidatable, bool initReceiveSharesEnabled ) internal pure returns (ReserveFlags) { uint8 flags = 0; flags = _setStatus(flags, PAUSED_MASK, initPaused); flags = _setStatus(flags, FROZEN_MASK, initFrozen); flags = _setStatus(flags, BORROWABLE_MASK, initBorrowable); - flags = _setStatus(flags, LIQUIDATABLE_MASK, initLiquidatable); flags = _setStatus(flags, RECEIVE_SHARES_ENABLED_MASK, initReceiveSharesEnabled); return ReserveFlags.wrap(flags); } @@ -66,14 +61,6 @@ library ReserveFlagsMap { return ReserveFlags.wrap(_setStatus(ReserveFlags.unwrap(flags), BORROWABLE_MASK, status)); } - /// @notice Sets the new status for the `liquidatable` flag. - /// @param flags The current ReserveFlags. - /// @param status The new status for the `liquidatable` flag. - /// @return The updated ReserveFlags. - function setLiquidatable(ReserveFlags flags, bool status) internal pure returns (ReserveFlags) { - return ReserveFlags.wrap(_setStatus(ReserveFlags.unwrap(flags), LIQUIDATABLE_MASK, status)); - } - /// @notice Sets the new status for the `receiveSharesEnabled` flag. /// @param flags The current ReserveFlags. /// @param status The new status for the `receiveSharesEnabled` flag. @@ -109,13 +96,6 @@ library ReserveFlagsMap { return (ReserveFlags.unwrap(flags) & BORROWABLE_MASK) != 0; } - /// @notice Returns the `liquidatable` flag status. - /// @param flags The current ReserveFlags. - /// @return True if the flag is set. - function liquidatable(ReserveFlags flags) internal pure returns (bool) { - return (ReserveFlags.unwrap(flags) & LIQUIDATABLE_MASK) != 0; - } - /// @notice Returns the `receiveSharesEnabled` flag status. /// @param flags The current ReserveFlags. /// @return True if the flag is set. diff --git a/src/spoke/libraries/SpokeUtils.sol b/src/spoke/libraries/SpokeUtils.sol new file mode 100644 index 000000000..8c083f8ff --- /dev/null +++ b/src/spoke/libraries/SpokeUtils.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.20; + +import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; +import {MathUtils} from 'src/libraries/math/MathUtils.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +/// @title SpokeUtils library +/// @author Aave Labs +/// @notice Provides utility functions for the Spoke contract. +library SpokeUtils { + /// @dev See Spoke.ORACLE_DECIMALS docs + uint8 public constant ORACLE_DECIMALS = 8; + + /// @notice Returns the reserve for a given reserve id. + /// @param reserves The mapping of reserves per reserve id. + /// @param reserveId The identifier of the reserve. + /// @return The reserve. + function get( + mapping(uint256 reserveId => ISpoke.Reserve) storage reserves, + uint256 reserveId + ) internal view returns (ISpoke.Reserve storage) { + ISpoke.Reserve storage reserve = reserves[reserveId]; + require(address(reserve.hub) != address(0), ISpoke.ReserveNotListed()); + return reserve; + } + + /// @notice Converts an asset amount to Value. 1e26 represents 1 USD. + /// @dev Reverts if asset uses more than 18 decimals. Reverts if multiplication overflows. + /// @param amount The asset amount. + /// @param decimals The decimals of the asset. + /// @param price The price of the asset. + /// @return The amount in units of Value. + function toValue( + uint256 amount, + uint256 decimals, + uint256 price + ) internal pure returns (uint256) { + return amount * price * MathUtils.uncheckedExp(10, WadRayMath.WAD_DECIMALS - decimals); + } +} diff --git a/src/spoke/libraries/UserPositionDebt.sol b/src/spoke/libraries/UserPositionDebt.sol index 6c9543808..fae23b1ef 100644 --- a/src/spoke/libraries/UserPositionDebt.sol +++ b/src/spoke/libraries/UserPositionDebt.sol @@ -13,13 +13,23 @@ import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; /// @title User Debt library /// @author Aave Labs /// @notice Implements debt calculations for user positions. -library UserPositionDebt { - using UserPositionDebt for ISpoke.UserPosition; +library UserPositionUtils { + using UserPositionUtils for ISpoke.UserPosition; using SafeCast for *; using PercentageMath for uint256; using WadRayMath for *; using MathUtils for *; + /// @notice Debt components of a user position. + /// @dev drawnShares The amount of drawn shares. + /// @dev premiumDebtRay The amount of premium debt, expressed in asset units and scaled by RAY. + /// @dev drawnIndex The drawn index of the reserve, expressed in RAY. + struct DebtComponents { + uint256 drawnShares; + uint256 premiumDebtRay; + uint256 drawnIndex; + } + /// @notice Applies the premium delta to the user position. /// @param userPosition The user position. /// @param premiumDelta The premium delta to apply. @@ -42,7 +52,7 @@ library UserPositionDebt { /// @param riskPremium The new risk premium, expressed in BPS. /// @param restoredPremiumRay The amount of premium to be restored, expressed in asset units and scaled by RAY. /// @return The calculated premium delta. - function getPremiumDelta( + function calculatePremiumDelta( ISpoke.UserPosition storage userPosition, uint256 drawnSharesTaken, uint256 drawnIndex, @@ -116,6 +126,21 @@ library UserPositionDebt { return (userPosition.drawnShares.rayMulUp(drawnIndex), premiumDebtRay); } + /// @return The debt components of the user position. + function getDebtComponents( + ISpoke.UserPosition storage userPosition, + IHubBase hub, + uint256 assetId + ) internal view returns (DebtComponents memory) { + uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); + return + DebtComponents({ + drawnShares: userPosition.drawnShares, + premiumDebtRay: _calculatePremiumRay(userPosition, drawnIndex), + drawnIndex: drawnIndex + }); + } + /// @dev Calculates the premium debt of a user position with full precision. /// @param userPosition The user position. /// @param drawnIndex The current drawn index. diff --git a/src/spoke/libraries/UserPositionUtils.sol b/src/spoke/libraries/UserPositionUtils.sol new file mode 100644 index 000000000..fae23b1ef --- /dev/null +++ b/src/spoke/libraries/UserPositionUtils.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.20; + +import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; +import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; +import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; +import {MathUtils} from 'src/libraries/math/MathUtils.sol'; +import {Premium} from 'src/hub/libraries/Premium.sol'; +import {IHubBase} from 'src/hub/interfaces/IHubBase.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +/// @title User Debt library +/// @author Aave Labs +/// @notice Implements debt calculations for user positions. +library UserPositionUtils { + using UserPositionUtils for ISpoke.UserPosition; + using SafeCast for *; + using PercentageMath for uint256; + using WadRayMath for *; + using MathUtils for *; + + /// @notice Debt components of a user position. + /// @dev drawnShares The amount of drawn shares. + /// @dev premiumDebtRay The amount of premium debt, expressed in asset units and scaled by RAY. + /// @dev drawnIndex The drawn index of the reserve, expressed in RAY. + struct DebtComponents { + uint256 drawnShares; + uint256 premiumDebtRay; + uint256 drawnIndex; + } + + /// @notice Applies the premium delta to the user position. + /// @param userPosition The user position. + /// @param premiumDelta The premium delta to apply. + function applyPremiumDelta( + ISpoke.UserPosition storage userPosition, + IHubBase.PremiumDelta memory premiumDelta + ) internal { + userPosition.premiumShares = userPosition + .premiumShares + .add(premiumDelta.sharesDelta) + .toUint120(); + userPosition.premiumOffsetRay = (userPosition.premiumOffsetRay + premiumDelta.offsetRayDelta) + .toInt200(); + } + + /// @notice Calculates the premium delta for a user position given a new risk premium. + /// @param userPosition The user position. + /// @param drawnSharesTaken The amount of drawn shares taken from the user position. + /// @param drawnIndex The current drawn index. + /// @param riskPremium The new risk premium, expressed in BPS. + /// @param restoredPremiumRay The amount of premium to be restored, expressed in asset units and scaled by RAY. + /// @return The calculated premium delta. + function calculatePremiumDelta( + ISpoke.UserPosition storage userPosition, + uint256 drawnSharesTaken, + uint256 drawnIndex, + uint256 riskPremium, + uint256 restoredPremiumRay + ) internal view returns (IHubBase.PremiumDelta memory) { + uint256 oldPremiumShares = userPosition.premiumShares; + int256 oldPremiumOffsetRay = userPosition.premiumOffsetRay; + uint256 premiumDebtRay = Premium.calculatePremiumRay({ + premiumShares: oldPremiumShares, + premiumOffsetRay: oldPremiumOffsetRay, + drawnIndex: drawnIndex + }); + uint256 newPremiumShares = (userPosition.drawnShares - drawnSharesTaken).percentMulUp( + riskPremium + ); + int256 newPremiumOffsetRay = (newPremiumShares * drawnIndex).signedSub( + premiumDebtRay - restoredPremiumRay + ); + + return + IHubBase.PremiumDelta({ + sharesDelta: newPremiumShares.signedSub(oldPremiumShares), + offsetRayDelta: newPremiumOffsetRay - oldPremiumOffsetRay, + restoredPremiumRay: restoredPremiumRay + }); + } + + /// @dev Calculates the drawn debt and premium debt to restore for the given user position and amount. + /// @param userPosition The user position. + /// @param drawnIndex The drawn index of the reserve. + /// @param amount The amount to restore. + /// @return The amount of drawn debt to restore, expressed in asset units. + /// @return The amount of premium debt to restore, expressed in asset units and scaled by RAY. + function calculateRestoreAmount( + ISpoke.UserPosition storage userPosition, + uint256 drawnIndex, + uint256 amount + ) internal view returns (uint256, uint256) { + (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt(drawnIndex); + uint256 premiumDebt = premiumDebtRay.fromRayUp(); + if (amount >= drawnDebt + premiumDebt) { + return (drawnDebt, premiumDebtRay); + } + + if (amount < premiumDebt) { + // amount.toRay() cannot overflow here + uint256 amountRay = amount.toRay(); + return (0, amountRay); + } + return (amount - premiumDebt, premiumDebtRay); + } + + /// @return The user's drawn debt, expressed in asset units. + /// @return The user's premium debt, expressed in asset units and scaled by RAY. + function getDebt( + ISpoke.UserPosition storage userPosition, + IHubBase hub, + uint256 assetId + ) internal view returns (uint256, uint256) { + return userPosition.getDebt(hub.getAssetDrawnIndex(assetId)); + } + + /// @return The user's drawn debt, expressed in asset units. + /// @return The user's premium debt, expressed in asset units and scaled by RAY. + function getDebt( + ISpoke.UserPosition storage userPosition, + uint256 drawnIndex + ) internal view returns (uint256, uint256) { + uint256 premiumDebtRay = _calculatePremiumRay(userPosition, drawnIndex); + return (userPosition.drawnShares.rayMulUp(drawnIndex), premiumDebtRay); + } + + /// @return The debt components of the user position. + function getDebtComponents( + ISpoke.UserPosition storage userPosition, + IHubBase hub, + uint256 assetId + ) internal view returns (DebtComponents memory) { + uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); + return + DebtComponents({ + drawnShares: userPosition.drawnShares, + premiumDebtRay: _calculatePremiumRay(userPosition, drawnIndex), + drawnIndex: drawnIndex + }); + } + + /// @dev Calculates the premium debt of a user position with full precision. + /// @param userPosition The user position. + /// @param drawnIndex The current drawn index. + /// @return The premium debt, expressed in asset units and scaled by RAY. + function _calculatePremiumRay( + ISpoke.UserPosition storage userPosition, + uint256 drawnIndex + ) internal view returns (uint256) { + return + Premium.calculatePremiumRay({ + premiumShares: userPosition.premiumShares, + premiumOffsetRay: userPosition.premiumOffsetRay, + drawnIndex: drawnIndex + }); + } +} diff --git a/src/utils/ExtSload.sol b/src/utils/ExtSload.sol new file mode 100644 index 000000000..dd80b91ac --- /dev/null +++ b/src/utils/ExtSload.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {IExtSload} from 'src/interfaces/IExtSload.sol'; + +/// @title ExtSload +/// @author Aave Labs +/// @notice This allows the source contract to make its state available to external contracts. +abstract contract ExtSload is IExtSload { + /// @inheritdoc IExtSload + function extSload(bytes32 slot) external view returns (bytes32 ret) { + assembly ('memory-safe') { + ret := sload(slot) + } + } + + /// @inheritdoc IExtSload + function extSloads(bytes32[] calldata slots) external view returns (bytes32[] memory) { + // @dev we disregard solidity memory conventions since we take control of entire execution + assembly { + mstore(0x00, 0x20) // to abi-encode response, the array will be found at the next word + mstore(0x20, slots.length) // set the length of dynamic array + let start := 0x40 // start of the array + let end := add(start, shl(5, slots.length)) + for { + let input := slots.offset + } lt(start, end) { + start := add(start, 0x20) + } { + mstore(start, sload(calldataload(input))) + input := add(input, 0x20) + } + return(0x00, end) // return abi-encoded dynamic array + } + } +} diff --git a/src/utils/IntentConsumer.sol b/src/utils/IntentConsumer.sol new file mode 100644 index 000000000..854ed71df --- /dev/null +++ b/src/utils/IntentConsumer.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity 0.8.28; + +import {EIP712} from 'src/dependencies/solady/EIP712.sol'; +import {SignatureChecker} from 'src/dependencies/openzeppelin/SignatureChecker.sol'; +import {NoncesKeyed} from 'src/utils/NoncesKeyed.sol'; +import {IIntentConsumer} from 'src/interfaces/IIntentConsumer.sol'; + +/// @title IntentConsumer +/// @author Aave Labs +/// @notice Base contract to consume EIP712-signed intents with keyed-nonces. +/// @dev The `_domainNameAndVersion() `function must be implemented to specify the EIP712 domain name and version. +/// @dev Implements ERC-5267 with `address(this)` as verifyingContract and no custom extensions or optional EIP-712 salt. +abstract contract IntentConsumer is IIntentConsumer, NoncesKeyed, EIP712 { + /// @inheritdoc IIntentConsumer + function DOMAIN_SEPARATOR() external view virtual returns (bytes32) { + return _domainSeparator(); + } + + /// @dev Verifies the signature of an EIP712-typed intent and consumes its associated keyed-nonce. + /// @param signer The address of the user. + /// @param intentHash The hash of the intent struct. + /// @param nonce The keyed-nonce for the intent. + /// @param deadline The deadline timestamp for the intent. + /// @param signature The signature bytes. + function _verifyAndConsumeIntent( + address signer, + bytes32 intentHash, + uint256 nonce, + uint256 deadline, + bytes calldata signature + ) internal { + require(block.timestamp <= deadline, InvalidSignature()); + bytes32 digest = _hashTypedData(intentHash); + require( + SignatureChecker.isValidSignatureNowCalldata(signer, digest, signature), + InvalidSignature() + ); + _useCheckedNonce(signer, nonce); + } +} diff --git a/src/utils/NoncesKeyed.sol b/src/utils/NoncesKeyed.sol index efd8afe51..6040ebd2b 100644 --- a/src/utils/NoncesKeyed.sol +++ b/src/utils/NoncesKeyed.sol @@ -4,11 +4,21 @@ pragma solidity ^0.8.20; import {INoncesKeyed} from 'src/interfaces/INoncesKeyed.sol'; -/// @notice Provides tracking nonces for addresses. Supports key-ed nonces, where nonces will only increment for each key. -/// @author Modified from OpenZeppelin https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v5.2.0/contracts/utils/NoncesKeyed.sol +/// @title NoncesKeyed +/// @author Aave Labs +/// @notice Provides tracking nonces for addresses. Supports keyed nonces, where nonces will only increment for each key. /// @dev Follows the https://eips.ethereum.org/EIPS/eip-4337#semi-abstracted-nonce-support[ERC-4337's semi-abstracted nonce system]. +/// @dev Inspired by the OpenZeppelin NoncesKeyed contract. contract NoncesKeyed is INoncesKeyed { - mapping(address owner => mapping(uint192 key => uint64 nonce)) private _nonces; + /// @custom:storage-location erc7201:aave-v4.storage.NoncesKeyed + struct NoncesKeyedStorage { + mapping(address owner => mapping(uint192 key => uint64 nonce)) _nonces; + } + + /// @dev The storage slot for the NoncesKeyed storage struct. + bytes32 private constant NAMESPACE_SLOT = + // keccak256(abi.encode(uint256(keccak256("aave-v4.storage.NoncesKeyed")) - 1)) & ~bytes32(uint256(0xff)) + 0x474d4a5585c1bae3dbeb574bb96408c7174aadd8ab635de4ab498e2723195f00; /// @inheritdoc INoncesKeyed function useNonce(uint192 key) external returns (uint256) { @@ -16,8 +26,8 @@ contract NoncesKeyed is INoncesKeyed { } /// @inheritdoc INoncesKeyed - function nonces(address owner, uint192 key) external view returns (uint256) { - return _pack(key, _nonces[owner][key]); + function nonces(address owner, uint192 key) public view returns (uint256) { + return _pack(key, _getNoncesKeyedStorage()._nonces[owner][key]); } /// @notice Consumes the next unused nonce for an address and key. @@ -28,7 +38,7 @@ contract NoncesKeyed is INoncesKeyed { // decremented or reset. This guarantees that the nonce never overflows. unchecked { // It is important to do x++ and not ++x here. - return _pack(key, _nonces[owner][key]++); + return _pack(key, _getNoncesKeyedStorage()._nonces[owner][key]++); } } @@ -48,4 +58,11 @@ contract NoncesKeyed is INoncesKeyed { function _unpack(uint256 keyNonce) private pure returns (uint192 key, uint64 nonce) { return (uint192(keyNonce >> 64), uint64(keyNonce)); } + + /// @dev Loads the NoncesKeyed storage struct. + function _getNoncesKeyedStorage() private pure returns (NoncesKeyedStorage storage $) { + assembly ('memory-safe') { + $.slot := NAMESPACE_SLOT + } + } } diff --git a/tests/Base.t.sol b/tests/Base.t.sol index 424bc3683..d441c983f 100644 --- a/tests/Base.t.sol +++ b/tests/Base.t.sol @@ -11,18 +11,25 @@ import {console2 as console} from 'forge-std/console2.sol'; // dependencies import {AggregatorV3Interface} from 'src/dependencies/chainlink/AggregatorV3Interface.sol'; -import {TransparentUpgradeableProxy, ITransparentUpgradeableProxy} from 'src/dependencies/openzeppelin/TransparentUpgradeableProxy.sol'; +import { + TransparentUpgradeableProxy, + ITransparentUpgradeableProxy +} from 'src/dependencies/openzeppelin/TransparentUpgradeableProxy.sol'; +import {ProxyAdmin} from 'src/dependencies/openzeppelin/ProxyAdmin.sol'; +import {ReentrancyGuardTransient} from 'src/dependencies/openzeppelin/ReentrancyGuardTransient.sol'; import {IERC20Metadata} from 'src/dependencies/openzeppelin/IERC20Metadata.sol'; import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; import {IERC20Errors} from 'src/dependencies/openzeppelin/IERC20Errors.sol'; -import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; +import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; import {IERC5267} from 'src/dependencies/openzeppelin/IERC5267.sol'; +import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.sol'; import {AccessManager} from 'src/dependencies/openzeppelin/AccessManager.sol'; import {IAccessManager} from 'src/dependencies/openzeppelin/IAccessManager.sol'; import {IAccessManaged} from 'src/dependencies/openzeppelin/IAccessManaged.sol'; import {AuthorityUtils} from 'src/dependencies/openzeppelin/AuthorityUtils.sol'; import {Ownable2Step, Ownable} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; import {Math} from 'src/dependencies/openzeppelin/Math.sol'; +import {SlotDerivation} from 'src/dependencies/openzeppelin/SlotDerivation.sol'; import {WETH9} from 'src/dependencies/weth/WETH9.sol'; import {LibBit} from 'src/dependencies/solady/LibBit.sol'; @@ -33,32 +40,40 @@ import {IERC1967} from 'src/dependencies/openzeppelin/IERC1967.sol'; import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; import {MathUtils} from 'src/libraries/math/MathUtils.sol'; import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; -import {EIP712Types} from 'src/libraries/types/EIP712Types.sol'; import {Roles} from 'src/libraries/types/Roles.sol'; import {Rescuable, IRescuable} from 'src/utils/Rescuable.sol'; import {NoncesKeyed, INoncesKeyed} from 'src/utils/NoncesKeyed.sol'; import {UnitPriceFeed} from 'src/misc/UnitPriceFeed.sol'; +import {IntentConsumer, IIntentConsumer} from 'src/utils/IntentConsumer.sol'; import {AccessManagerEnumerable} from 'src/access/AccessManagerEnumerable.sol'; // hub import {HubConfigurator, IHubConfigurator} from 'src/hub/HubConfigurator.sol'; -import {Hub, IHub, IHubBase} from 'src/hub/Hub.sol'; +import {IHub, IHubBase} from 'src/hub/interfaces/IHub.sol'; import {SharesMath} from 'src/hub/libraries/SharesMath.sol'; -import {AssetInterestRateStrategy, IAssetInterestRateStrategy, IBasicInterestRateStrategy} from 'src/hub/AssetInterestRateStrategy.sol'; +import { + AssetInterestRateStrategy, + IAssetInterestRateStrategy, + IBasicInterestRateStrategy +} from 'src/hub/AssetInterestRateStrategy.sol'; // spoke -import {Spoke, ISpoke, ISpokeBase} from 'src/spoke/Spoke.sol'; +import {ISpoke, ISpokeBase} from 'src/spoke/interfaces/ISpoke.sol'; import {TreasurySpoke, ITreasurySpoke} from 'src/spoke/TreasurySpoke.sol'; import {IPriceOracle} from 'src/spoke/interfaces/IPriceOracle.sol'; import {AaveOracle} from 'src/spoke/AaveOracle.sol'; import {IAaveOracle} from 'src/spoke/interfaces/IAaveOracle.sol'; import {SpokeConfigurator, ISpokeConfigurator} from 'src/spoke/SpokeConfigurator.sol'; -import {SpokeInstance} from 'src/spoke/instances/SpokeInstance.sol'; +import {SpokeUtils} from 'src/spoke/libraries/SpokeUtils.sol'; import {PositionStatusMap} from 'src/spoke/libraries/PositionStatusMap.sol'; import {ReserveFlags, ReserveFlagsMap} from 'src/spoke/libraries/ReserveFlagsMap.sol'; import {LiquidationLogic} from 'src/spoke/libraries/LiquidationLogic.sol'; import {KeyValueList} from 'src/spoke/libraries/KeyValueList.sol'; +// tokenization spoke +import {TokenizationSpoke, ITokenizationSpoke} from 'src/spoke/TokenizationSpoke.sol'; +import {TokenizationSpokeInstance} from 'src/spoke/instances/TokenizationSpokeInstance.sol'; + // position manager import {GatewayBase, IGatewayBase} from 'src/position-manager/GatewayBase.sol'; import {NativeTokenGateway, INativeTokenGateway} from 'src/position-manager/NativeTokenGateway.sol'; @@ -66,9 +81,11 @@ import {SignatureGateway, ISignatureGateway} from 'src/position-manager/Signatur // test import {Constants} from 'tests/Constants.sol'; +import {DeployUtils} from 'tests/DeployUtils.sol'; import {Utils} from 'tests/Utils.sol'; // mocks +import {EIP712Types} from 'tests/mocks/EIP712Types.sol'; import {TestnetERC20} from 'tests/mocks/TestnetERC20.sol'; import {MockERC20} from 'tests/mocks/MockERC20.sol'; import {MockPriceFeed} from 'tests/mocks/MockPriceFeed.sol'; @@ -80,6 +97,10 @@ import {MockSpoke} from 'tests/mocks/MockSpoke.sol'; import {MockERC1271Wallet} from 'tests/mocks/MockERC1271Wallet.sol'; import {MockSpokeInstance} from 'tests/mocks/MockSpokeInstance.sol'; import {MockSkimSpoke} from 'tests/mocks/MockSkimSpoke.sol'; +import {MockReentrantCaller} from 'tests/mocks/MockReentrantCaller.sol'; +import {ISpokeInstance} from 'tests/mocks/ISpokeInstance.sol'; +import {DeployWrapper} from 'tests/mocks/DeployWrapper.sol'; +import {SpokeUtilsWrapper} from 'tests/mocks/SpokeUtilsWrapper.sol'; abstract contract Base is Test { using stdStorage for StdStorage; @@ -94,6 +115,8 @@ abstract contract Base is Test { 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; bytes32 internal constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + bytes32 internal constant INITIALIZABLE_SLOT = + 0xf0c57e16840df040f15088dc2f81fe391c3923bec73e23a9662efc9c229c6a00; uint256 internal constant MAX_SUPPLY_AMOUNT = 1e30; uint256 internal constant MIN_TOKEN_DECIMALS_SUPPORTED = 6; @@ -107,8 +130,12 @@ abstract contract Base is Test { uint256 internal MAX_SUPPLY_AMOUNT_USDY; uint256 internal MAX_SUPPLY_AMOUNT_USDZ; uint256 internal constant MAX_SUPPLY_IN_BASE_CURRENCY = 1e39; - uint24 internal constant MIN_COLLATERAL_RISK_BPS = 1; + uint24 internal constant MIN_COLLATERAL_RISK_BPS = 0; uint24 internal constant MAX_COLLATERAL_RISK_BPS = 1000_00; + uint256 internal constant MAX_SUPPLY_PRICE = 100; + uint256 internal constant MIN_DRAWN_INDEX = WadRayMath.RAY; + uint256 internal constant MAX_DRAWN_INDEX = 100 * WadRayMath.RAY; + uint24 internal constant MIN_BORROW_RATE = 0; uint256 internal constant MAX_BORROW_RATE = 1000_00; // matches AssetInterestRateStrategy uint256 internal constant MIN_OPTIMAL_RATIO = 1_00; // 1.00% in BPS, matches AssetInterestRateStrategy uint256 internal constant MAX_OPTIMAL_RATIO = 99_00; // 99.00% in BPS, matches AssetInterestRateStrategy @@ -138,18 +165,30 @@ abstract contract Base is Test { AssetInterestRateStrategy internal irStrategy; IAccessManager internal accessManager; - address internal alice = makeAddr('alice'); - address internal bob = makeAddr('bob'); - address internal carol = makeAddr('carol'); - address internal derl = makeAddr('derl'); + string internal constant ALICE = 'alice'; + string internal constant BOB = 'bob'; + string internal constant CAROL = 'carol'; + string internal constant DERL = 'derl'; + + address internal alice = makeAddr(ALICE); + uint256 internal alicePk = makeKey(ALICE); + address internal bob = makeAddr(BOB); + uint256 internal bobPk = makeKey(BOB); + address internal carol = makeAddr(CAROL); + uint256 internal carolPk = makeKey(CAROL); + address internal derl = makeAddr(DERL); + uint256 internal derlPk = makeKey(DERL); address internal ADMIN = makeAddr('ADMIN'); address internal HUB_ADMIN = makeAddr('HUB_ADMIN'); address internal SPOKE_ADMIN = makeAddr('SPOKE_ADMIN'); address internal USER_POSITION_UPDATER = makeAddr('USER_POSITION_UPDATER'); + address internal DEFICIT_ELIMINATOR = makeAddr('DEFICIT_ELIMINATOR'); address internal TREASURY_ADMIN = makeAddr('TREASURY_ADMIN'); address internal LIQUIDATOR = makeAddr('LIQUIDATOR'); address internal POSITION_MANAGER = makeAddr('POSITION_MANAGER'); + address internal HUB_CONFIGURATOR = makeAddr('HUB_CONFIGURATOR'); + address internal SPOKE_CONFIGURATOR = makeAddr('SPOKE_CONFIGURATOR'); TokenList internal tokenList; uint256 internal wethAssetId = 0; @@ -212,6 +251,7 @@ abstract contract Base is Test { struct Debts { uint256 drawnDebt; uint256 premiumDebt; + uint256 premiumDebtRay; uint256 totalDebt; } @@ -247,12 +287,12 @@ abstract contract Base is Test { IHub hub; uint16 assetId; uint8 decimals; - uint24 dynamicConfigKey; // key of the last reserve config + uint24 collateralRisk; bool paused; bool frozen; bool borrowable; bool receiveSharesEnabled; - uint24 collateralRisk; + uint32 dynamicConfigKey; // key of the last reserve config } mapping(ISpoke => SpokeInfo) internal spokeInfo; @@ -271,10 +311,15 @@ abstract contract Base is Test { return address(uint160(uint256(slotData))); } + function _getProxyInitializedVersion(address proxy) internal view returns (uint64) { + bytes32 slotData = vm.load(proxy, INITIALIZABLE_SLOT); + return uint64(uint256(slotData) & ((1 << 64) - 1)); + } + function deployFixtures() internal virtual { vm.startPrank(ADMIN); accessManager = IAccessManager(address(new AccessManagerEnumerable(ADMIN))); - hub1 = new Hub(address(accessManager)); + hub1 = DeployUtils.deployHub(address(accessManager)); irStrategy = new AssetInterestRateStrategy(address(hub1)); (spoke1, oracle1) = _deploySpokeWithOracle(ADMIN, address(accessManager), 'Spoke 1 (USD)'); (spoke2, oracle2) = _deploySpokeWithOracle(ADMIN, address(accessManager), 'Spoke 2 (USD)'); @@ -303,6 +348,12 @@ abstract contract Base is Test { manager.grantRole(Roles.USER_POSITION_UPDATER_ROLE, SPOKE_ADMIN, 0); manager.grantRole(Roles.USER_POSITION_UPDATER_ROLE, USER_POSITION_UPDATER, 0); + manager.grantRole(Roles.HUB_CONFIGURATOR_ROLE, HUB_CONFIGURATOR, 0); + manager.grantRole(Roles.SPOKE_CONFIGURATOR_ROLE, SPOKE_CONFIGURATOR, 0); + + manager.grantRole(Roles.DEFICIT_ELIMINATOR_ROLE, HUB_ADMIN, 0); + manager.grantRole(Roles.DEFICIT_ELIMINATOR_ROLE, DEFICIT_ELIMINATOR, 0); + // Grant responsibilities to roles { bytes4[] memory selectors = new bytes4[](7); @@ -333,6 +384,97 @@ abstract contract Base is Test { selectors[5] = IHub.mintFeeShares.selector; manager.setTargetFunctionRole(address(targetHub), selectors, Roles.HUB_ADMIN_ROLE); } + + { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = IHub.eliminateDeficit.selector; + manager.setTargetFunctionRole(address(targetHub), selectors, Roles.DEFICIT_ELIMINATOR_ROLE); + } + + setUpHubConfiguratorRoles(HUB_CONFIGURATOR, address(manager)); + setUpSpokeConfiguratorRoles(SPOKE_CONFIGURATOR, address(manager)); + + vm.stopPrank(); + } + + function setUpHubConfiguratorRoles(address hubConfigurator, address manager) internal { + vm.startPrank(ADMIN); + + // Grant HUB_ADMIN_ROLE so the configurator can call hub functions + IAccessManager(manager).grantRole(Roles.HUB_ADMIN_ROLE, hubConfigurator, 0); + + // Set up HubConfigurator function permissions - all functions callable by HUB_CONFIGURATOR_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.updateSpokeSupplyCap.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; + IAccessManager(manager).setTargetFunctionRole( + hubConfigurator, + selectors, + Roles.HUB_CONFIGURATOR_ROLE + ); + + vm.stopPrank(); + } + + function setUpSpokeConfiguratorRoles(address spokeConfigurator, address manager) internal { + vm.startPrank(ADMIN); + + // Grant SPOKE_ADMIN_ROLE so the configurator can call spoke functions + IAccessManager(manager).grantRole(Roles.SPOKE_ADMIN_ROLE, spokeConfigurator, 0); + + // Set up SpokeConfigurator function permissions - all functions callable by SPOKE_CONFIGURATOR_ROLE + bytes4[] memory selectors = new bytes4[](25); + 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.updateMaxReserves.selector; + selectors[6] = ISpokeConfigurator.addReserve.selector; + selectors[7] = ISpokeConfigurator.updatePaused.selector; + selectors[8] = ISpokeConfigurator.updateFrozen.selector; + selectors[9] = ISpokeConfigurator.updateBorrowable.selector; + selectors[10] = ISpokeConfigurator.updateReceiveSharesEnabled.selector; + selectors[11] = ISpokeConfigurator.updateCollateralRisk.selector; + selectors[12] = ISpokeConfigurator.addCollateralFactor.selector; + selectors[13] = ISpokeConfigurator.updateCollateralFactor.selector; + selectors[14] = ISpokeConfigurator.addMaxLiquidationBonus.selector; + selectors[15] = ISpokeConfigurator.updateMaxLiquidationBonus.selector; + selectors[16] = ISpokeConfigurator.addLiquidationFee.selector; + selectors[17] = ISpokeConfigurator.updateLiquidationFee.selector; + selectors[18] = ISpokeConfigurator.addDynamicReserveConfig.selector; + selectors[19] = ISpokeConfigurator.updateDynamicReserveConfig.selector; + selectors[20] = ISpokeConfigurator.pauseAllReserves.selector; + selectors[21] = ISpokeConfigurator.freezeAllReserves.selector; + selectors[22] = ISpokeConfigurator.pauseReserve.selector; + selectors[23] = ISpokeConfigurator.freezeReserve.selector; + selectors[24] = ISpokeConfigurator.updatePositionManager.selector; + IAccessManager(manager).setTargetFunctionRole( + spokeConfigurator, + selectors, + Roles.SPOKE_CONFIGURATOR_ROLE + ); + vm.stopPrank(); } @@ -433,7 +575,7 @@ abstract contract Base is Test { function configureTokenList() internal { IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK @@ -815,7 +957,7 @@ abstract contract Base is Test { */ function hub2Fixture() internal returns (IHub, AssetInterestRateStrategy) { IAccessManager accessManager2 = IAccessManager(address(new AccessManagerEnumerable(ADMIN))); - IHub hub2 = new Hub(address(accessManager2)); + IHub hub2 = DeployUtils.deployHub(address(accessManager2)); vm.label(address(hub2), 'Hub2'); AssetInterestRateStrategy hub2IrStrategy = new AssetInterestRateStrategy(address(hub2)); @@ -882,7 +1024,7 @@ abstract contract Base is Test { */ function hub3Fixture() internal returns (IHub, AssetInterestRateStrategy) { IAccessManager accessManager3 = IAccessManager(address(new AccessManagerEnumerable(ADMIN))); - IHub hub3 = new Hub(address(accessManager3)); + IHub hub3 = DeployUtils.deployHub(address(accessManager3)); AssetInterestRateStrategy hub3IrStrategy = new AssetInterestRateStrategy(address(hub3)); // Configure IR Strategy for hub 3 @@ -967,7 +1109,7 @@ abstract contract Base is Test { assertEq(hub.getAssetConfig(assetId), config); } - function updateReserveFrozenFlag( + function _updateReserveFrozenFlag( ISpoke spoke, uint256 reserveId, bool newFrozenFlag @@ -1023,12 +1165,12 @@ abstract contract Base is Test { ISpoke spoke, uint256 reserveId, uint32 newMaxLiquidationBonus - ) internal pausePrank returns (uint24) { + ) internal pausePrank returns (uint32) { ISpoke.DynamicReserveConfig memory config = _getLatestDynamicReserveConfig(spoke, reserveId); config.maxLiquidationBonus = newMaxLiquidationBonus; vm.prank(SPOKE_ADMIN); - uint24 dynamicConfigKey = spoke.addDynamicReserveConfig(reserveId, config); + uint32 dynamicConfigKey = spoke.addDynamicReserveConfig(reserveId, config); assertEq(_getLatestDynamicReserveConfig(spoke, reserveId), config); return dynamicConfigKey; @@ -1038,12 +1180,12 @@ abstract contract Base is Test { ISpoke spoke, uint256 reserveId, uint16 newLiquidationFee - ) internal pausePrank returns (uint24) { + ) internal pausePrank returns (uint32) { ISpoke.DynamicReserveConfig memory config = _getLatestDynamicReserveConfig(spoke, reserveId); config.liquidationFee = newLiquidationFee; vm.prank(SPOKE_ADMIN); - uint24 dynamicConfigKey = spoke.addDynamicReserveConfig(reserveId, config); + uint32 dynamicConfigKey = spoke.addDynamicReserveConfig(reserveId, config); assertEq(_getLatestDynamicReserveConfig(spoke, reserveId), config); return dynamicConfigKey; @@ -1054,13 +1196,13 @@ abstract contract Base is Test { uint256 reserveId, uint256 newCollateralFactor, uint256 newLiquidationBonus - ) internal pausePrank returns (uint24) { + ) internal pausePrank returns (uint32) { ISpoke.DynamicReserveConfig memory config = _getLatestDynamicReserveConfig(spoke, reserveId); config.collateralFactor = newCollateralFactor.toUint16(); config.maxLiquidationBonus = newLiquidationBonus.toUint32(); vm.prank(SPOKE_ADMIN); - uint24 dynamicConfigKey = spoke.addDynamicReserveConfig(reserveId, config); + uint32 dynamicConfigKey = spoke.addDynamicReserveConfig(reserveId, config); assertEq(_getLatestDynamicReserveConfig(spoke, reserveId), config); return dynamicConfigKey; @@ -1070,11 +1212,11 @@ abstract contract Base is Test { ISpoke spoke, uint256 reserveId, uint256 newCollateralFactor - ) internal pausePrank returns (uint24) { + ) internal pausePrank returns (uint32) { ISpoke.DynamicReserveConfig memory config = _getLatestDynamicReserveConfig(spoke, reserveId); config.collateralFactor = newCollateralFactor.toUint16(); vm.prank(SPOKE_ADMIN); - uint24 dynamicConfigKey = spoke.addDynamicReserveConfig(reserveId, config); + uint32 dynamicConfigKey = spoke.addDynamicReserveConfig(reserveId, config); assertEq(_getLatestDynamicReserveConfig(spoke, reserveId), config); return dynamicConfigKey; @@ -1083,8 +1225,8 @@ abstract contract Base is Test { function _updateCollateralFactorAtKey( ISpoke spoke, uint256 reserveId, - uint24 dynamicConfigKey, - uint256 newCollateralFactor + uint256 newCollateralFactor, + uint32 dynamicConfigKey ) internal pausePrank { ISpoke.DynamicReserveConfig memory config = spoke.getDynamicReserveConfig( reserveId, @@ -1097,7 +1239,16 @@ abstract contract Base is Test { assertEq(_getLatestDynamicReserveConfig(spoke, reserveId), config); } - function updateReserveBorrowableFlag( + function _addDynamicReserveConfig( + ISpoke spoke, + uint256 reserveId, + ISpoke.DynamicReserveConfig memory config + ) internal pausePrank returns (uint32) { + vm.prank(SPOKE_ADMIN); + return spoke.addDynamicReserveConfig(reserveId, config); + } + + function _updateReserveBorrowableFlag( ISpoke spoke, uint256 reserveId, bool newBorrowable @@ -1144,6 +1295,18 @@ abstract contract Base is Test { assertEq(spoke.getLiquidationConfig(), liqConfig); } + function _updateLiquidationBonusFactor( + ISpoke spoke, + uint16 newLiquidationBonusFactor + ) internal pausePrank { + ISpoke.LiquidationConfig memory liqConfig = spoke.getLiquidationConfig(); + liqConfig.liquidationBonusFactor = newLiquidationBonusFactor; + vm.prank(SPOKE_ADMIN); + spoke.updateLiquidationConfig(liqConfig); + + assertEq(spoke.getLiquidationConfig(), liqConfig); + } + function getTargetHealthFactor(ISpoke spoke) internal view returns (uint256) { ISpoke.LiquidationConfig memory liqConfig = spoke.getLiquidationConfig(); return liqConfig.targetHealthFactor; @@ -1192,21 +1355,21 @@ abstract contract Base is Test { return spokeInfo[spoke].usdz.reserveId; } - function _updateSpokePaused( + function _updateSpokeHalted( IHub hub, uint256 assetId, address spoke, - bool paused + bool halted ) internal pausePrank { IHub.SpokeConfig memory spokeConfig = hub.getSpokeConfig(assetId, spoke); - spokeConfig.paused = paused; + spokeConfig.halted = halted; vm.prank(HUB_ADMIN); hub.updateSpokeConfig(assetId, spoke, spokeConfig); assertEq(hub.getSpokeConfig(assetId, spoke), spokeConfig); } - function updateSpokeActive( + function _updateSpokeActive( IHub hub, uint256 assetId, address spoke, @@ -1220,6 +1383,20 @@ abstract contract Base is Test { assertEq(hub.getSpokeConfig(assetId, spoke), spokeConfig); } + function _updateAddCap( + IHub hub, + uint256 assetId, + address spoke, + uint40 newAddCap + ) internal pausePrank { + IHub.SpokeConfig memory spokeConfig = hub.getSpokeConfig(assetId, spoke); + spokeConfig.addCap = newAddCap; + vm.prank(HUB_ADMIN); + hub.updateSpokeConfig(assetId, spoke, spokeConfig); + + assertEq(hub.getSpokeConfig(assetId, spoke), spokeConfig); + } + function updateDrawCap( IHub hub, uint256 assetId, @@ -1248,6 +1425,18 @@ abstract contract Base is Test { assertEq(hub.getSpokeConfig(assetId, spoke), spokeConfig); } + function grantDeficitEliminatorRole(IHub hub, address target) internal pausePrank { + IAccessManager manager = IAccessManager(hub.authority()); + vm.prank(ADMIN); + manager.grantRole(Roles.DEFICIT_ELIMINATOR_ROLE, target, 0); + } + + function revokeDeficitEliminatorRole(IHub hub, address target) internal pausePrank { + IAccessManager manager = IAccessManager(hub.authority()); + vm.prank(ADMIN); + manager.revokeRole(Roles.DEFICIT_ELIMINATOR_ROLE, target); + } + function getUserInfo( ISpoke spoke, address user, @@ -1262,6 +1451,7 @@ abstract contract Base is Test { uint256 reserveId ) internal view returns (Debts memory data) { (data.drawnDebt, data.premiumDebt) = spoke.getUserDebt(reserveId, user); + data.premiumDebtRay = spoke.getUserPremiumDebtRay(reserveId, user); data.totalDebt = data.drawnDebt + data.premiumDebt; } @@ -1293,7 +1483,7 @@ abstract contract Base is Test { function _getReserveLastDynamicConfigKey( ISpoke spoke, uint256 reserveId - ) internal view returns (uint24) { + ) internal view returns (uint32) { return spoke.getReserve(reserveId).dynamicConfigKey; } @@ -1393,30 +1583,6 @@ abstract contract Base is Test { assertEq(newRate, oldRate, string.concat('debt rate should be constant ', label)); } - /// returns the USD value of the reserve normalized by it's decimals, in terms of WAD - function _getValue( - ISpoke spoke, - uint256 reserveId, - uint256 amount - ) internal view returns (uint256) { - return - (amount * IPriceOracle(spoke.ORACLE()).getReservePrice(reserveId)).wadDivDown( - 10 ** _underlying(spoke, reserveId).decimals() - ); - } - - /// returns the USD value of the reserve normalized by it's decimals, in terms of WAD - function _getDebtValue( - ISpoke spoke, - uint256 reserveId, - uint256 amount - ) internal view returns (uint256) { - return - (amount * IPriceOracle(spoke.ORACLE()).getReservePrice(reserveId)).wadDivUp( - 10 ** _underlying(spoke, reserveId).decimals() - ); - } - /// @notice Convert 1 asset amount to equivalent amount in another asset. /// @notice Will contain precision loss due to conversion split into two steps. /// @return Converted amount of toAsset. @@ -1463,30 +1629,20 @@ abstract contract Base is Test { userDrawnDebt, userPremiumDebt, repayAmount, - _spokeAssetId(spoke, reserveId) + _reserveAssetId(spoke, reserveId) ); } function _calculateRestoreAmounts( uint256 restoreAmount, uint256 drawn, - uint256 premium - ) internal pure returns (uint256 baseAmount, uint256 premiumAmount) { - if (restoreAmount <= premium) { - return (0, restoreAmount); + uint256 premiumRay + ) internal pure returns (uint256 drawnAmountToRestore, uint256 premiumRayToRestore) { + if (restoreAmount <= premiumRay / WadRayMath.RAY) { + return (0, restoreAmount.toRay()); } - return (drawn.min(restoreAmount - premium), premium); - } - - function _calculateRestoreAmounts( - ISpoke spoke, - uint256 reserveId, - address user, - uint256 repayAmount - ) internal view returns (uint256 baseAmount, uint256 premiumAmount) { - (uint256 userDrawnDebt, uint256 userPremiumDebt) = spoke.getUserDebt(reserveId, user); - return _calculateRestoreAmounts(repayAmount, userDrawnDebt, userPremiumDebt); + return (drawn.min(restoreAmount - premiumRay.fromRayUp()), premiumRay); } function _getExpectedPremiumDelta( @@ -1543,22 +1699,14 @@ abstract contract Base is Test { uint256 repayAmount ) internal view virtual returns (IHubBase.PremiumDelta memory) { Debts memory userDebt = getUserDebt(spoke, user, reserveId); - (, uint256 premiumAmountToRestore) = _calculateRestoreAmounts( + (, uint256 premiumRayToRestore) = _calculateRestoreAmounts( repayAmount, userDebt.drawnDebt, - userDebt.premiumDebt + userDebt.premiumDebtRay ); ISpoke.UserPosition memory userPosition = spoke.getUserPosition(reserveId, user); uint256 assetId = spoke.getReserve(reserveId).assetId; - uint256 premiumDebtRay = _calculatePremiumDebtRay( - hub1, - assetId, - userPosition.premiumShares, - userPosition.premiumOffsetRay - ); - - uint256 restoredPremiumRay = (premiumAmountToRestore * WadRayMath.RAY).min(premiumDebtRay); return _getExpectedPremiumDelta({ @@ -1568,7 +1716,7 @@ abstract contract Base is Test { oldPremiumOffsetRay: userPosition.premiumOffsetRay, drawnShares: 0, // risk premium is 0, so drawn shares do not matter here (otherwise they need to be updated with restored drawn shares amount) riskPremium: 0, - restoredPremiumRay: restoredPremiumRay + restoredPremiumRay: premiumRayToRestore }); } @@ -1580,25 +1728,18 @@ abstract contract Base is Test { uint256 repayAmount ) internal view virtual returns (IHubBase.PremiumDelta memory) { Debts memory userDebt = getUserDebt(spoke, user, reserveId); - (uint256 drawnDebtToRestore, uint256 premiumAmountToRestore) = _calculateRestoreAmounts( + (uint256 drawnDebtToRestore, uint256 premiumRayToRestore) = _calculateRestoreAmounts( repayAmount, userDebt.drawnDebt, - userDebt.premiumDebt + userDebt.premiumDebtRay ); { ISpoke.UserPosition memory userPosition = spoke.getUserPosition(reserveId, user); uint256 assetId = spoke.getReserve(reserveId).assetId; IHub hub = IHub(address(spoke.getReserve(reserveId).hub)); - uint256 premiumDebtRay = _calculatePremiumDebtRay( - hub, - assetId, - userPosition.premiumShares, - userPosition.premiumOffsetRay - ); - uint256 restoredPremiumRay = (premiumAmountToRestore * WadRayMath.RAY).min(premiumDebtRay); - uint256 restoredShares = drawnDebtToRestore.rayDivDown(hub.getAssetDrawnIndex(reserveId)); + uint256 restoredShares = drawnDebtToRestore.rayDivDown(hub.getAssetDrawnIndex(assetId)); uint256 riskPremium = _getUserLastRiskPremium(spoke, user); return @@ -1609,7 +1750,7 @@ abstract contract Base is Test { oldPremiumOffsetRay: userPosition.premiumOffsetRay, drawnShares: userPosition.drawnShares - restoredShares, riskPremium: riskPremium, - restoredPremiumRay: restoredPremiumRay + restoredPremiumRay: premiumRayToRestore }); } } @@ -1894,7 +2035,7 @@ abstract contract Base is Test { uint256 assetPrice, uint256 assetUnit ) internal pure returns (uint256) { - return (amount * assetPrice).wadDivUp(assetUnit); + return (amount * assetPrice) * (WadRayMath.WAD / assetUnit); } function _convertValueToAmount( @@ -1918,6 +2059,21 @@ abstract contract Base is Test { return ((valueAmount * assetUnit) / assetPrice).fromWadDown(); } + function _convertDecimals( + uint256 amount, + uint256 fromDecimals, + uint256 toDecimals, + bool roundUp + ) internal pure returns (uint256) { + return + Math.mulDiv( + amount, + 10 ** toDecimals, + 10 ** fromDecimals, + (roundUp) ? Math.Rounding.Ceil : Math.Rounding.Floor + ); + } + /** * @notice Returns the required debt amount to ensure user position is ~ a certain health factor. * @param desiredHf The desired health factor to be at. @@ -1927,7 +2083,7 @@ abstract contract Base is Test { address user, uint256 reserveId, uint256 desiredHf - ) internal view returns (uint256 requiredDebtAmount) { + ) internal returns (uint256 requiredDebtAmount) { uint256 requiredDebtAmountValue = _getRequiredDebtValueForHf(spoke, user, desiredHf); return _convertValueToAmount(spoke, reserveId, requiredDebtAmountValue); } @@ -1939,14 +2095,46 @@ abstract contract Base is Test { ISpoke spoke, address user, uint256 desiredHf - ) internal view returns (uint256 requiredDebtValue) { - ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); + ) internal returns (uint256 requiredDebtValue) { + ISpoke.UserAccountData memory userAccountData = _getUserAccountData(spoke, user, true); + uint256 totalAdjustedCollateralValue = userAccountData.totalCollateralValue.wadMulDown( + userAccountData.avgCollateralFactor + ); + uint256 targetTotalDebtValue = totalAdjustedCollateralValue.wadDivUp(desiredHf); + require( + userAccountData.totalDebtValueRay / WadRayMath.RAY < targetTotalDebtValue, + 'User has enough debt' + ); + return targetTotalDebtValue - userAccountData.totalDebtValueRay / WadRayMath.RAY; + } - requiredDebtValue = - userAccountData.totalCollateralValue.wadMulUp(userAccountData.avgCollateralFactor).wadDivUp( - desiredHf - ) - - userAccountData.totalDebtValue; + // Helper function to get user account data with potential dynamic config refresh + function _getUserAccountData( + ISpoke spoke, + address user, + bool refreshConfig + ) internal returns (ISpoke.UserAccountData memory) { + uint256 snapshot = vm.snapshotState(); + + address mockSpoke = address( + new MockSpoke(spoke.ORACLE(), Constants.MAX_ALLOWED_USER_RESERVES_LIMIT) + ); + + address implementation = _getImplementationAddress(address(spoke)); + + vm.prank(_getProxyAdminAddress(address(spoke))); + ITransparentUpgradeableProxy(address(spoke)).upgradeToAndCall(address(mockSpoke), ''); + + vm.prank(user); + ISpoke.UserAccountData memory userAccountData = MockSpoke(address(spoke)) + .calculateUserAccountData(user, refreshConfig); + + vm.prank(_getProxyAdminAddress(address(spoke))); + ITransparentUpgradeableProxy(address(spoke)).upgradeToAndCall(implementation, ''); + + vm.revertToState(snapshot); + + return userAccountData; } function _getUserHealthFactor(ISpoke spoke, address user) internal view returns (uint256) { @@ -1973,6 +2161,10 @@ abstract contract Base is Test { return a > b ? a : b; } + function _divUp(uint256 a, uint256 b) internal pure returns (uint256) { + return (a + b - 1) / b; + } + function _getTargetHealthFactor(ISpoke spoke) internal view returns (uint128) { return spoke.getLiquidationConfig().targetHealthFactor; } @@ -2117,6 +2309,14 @@ abstract contract Base is Test { return premiumShares * drawnIndex; } + function _calculateDebtAssetsToRestore( + uint256 drawnSharesToLiquidate, + uint256 premiumDebtRayToLiquidate, + uint256 drawnIndex + ) internal pure returns (uint256) { + return drawnSharesToLiquidate.rayMulUp(drawnIndex) + premiumDebtRayToLiquidate.fromRayUp(); + } + function _calculatePremiumAssetsRay( IHub hub, uint256 assetId, @@ -2178,7 +2378,7 @@ abstract contract Base is Test { uint256 reserveId, address user ) internal view returns (uint16) { - uint24 dynamicConfigKey = spoke.getUserPosition(reserveId, user).dynamicConfigKey; + uint32 dynamicConfigKey = spoke.getUserPosition(reserveId, user).dynamicConfigKey; return spoke.getDynamicReserveConfig(reserveId, dynamicConfigKey).collateralFactor; } @@ -2189,6 +2389,15 @@ abstract contract Base is Test { return _getLatestDynamicReserveConfig(spoke, reserveId(spoke)).collateralFactor; } + function _getLiquidationFee( + ISpoke spoke, + uint256 reserveId, + address user + ) internal view returns (uint16) { + uint32 dynamicConfigKey = spoke.getUserPosition(reserveId, user).dynamicConfigKey; + return spoke.getDynamicReserveConfig(reserveId, dynamicConfigKey).liquidationFee; + } + function _hasRole( IAccessManager authority, uint64 role, @@ -2206,10 +2415,37 @@ abstract contract Base is Test { return IHub(address(spoke.getReserve(reserveId).hub)); } - function _spokeAssetId(ISpoke spoke, uint256 reserveId) internal view returns (uint256) { + function _reserveAssetId(ISpoke spoke, uint256 reserveId) internal view returns (uint256) { return spoke.getReserve(reserveId).assetId; } + function _spokeMaxCollateralRisk(ISpoke spoke) internal view returns (uint24) { + uint24 maxCollateralRisk; + for (uint256 reserveId; reserveId < spoke.getReserveCount(); ++reserveId) { + uint24 collateralRisk = _getCollateralRisk(spoke, reserveId); + if (collateralRisk > maxCollateralRisk) { + maxCollateralRisk = collateralRisk; + } + } + return maxCollateralRisk; + } + + function _spokeMaxBorrowRate(ISpoke spoke) internal view returns (uint32) { + uint32 maxBorrowRate; + for (uint256 reserveId; reserveId < spoke.getReserveCount(); ++reserveId) { + uint32 borrowRate = ( + _hub(spoke, reserveId).getAssetDrawnRate(_reserveAssetId(spoke, reserveId)).mulDivUp( + PercentageMath.PERCENTAGE_FACTOR, + WadRayMath.RAY + ) + ).toUint32(); + if (borrowRate > maxBorrowRate) { + maxBorrowRate = borrowRate; + } + } + return maxBorrowRate; + } + function _underlying(ISpoke spoke, uint256 reserveId) internal view returns (TestnetERC20) { return TestnetERC20(spoke.getReserve(reserveId).underlying); } @@ -2222,29 +2458,99 @@ abstract contract Base is Test { } } + function _reserveDrawnIndex(ISpoke spoke, uint256 reserveId) internal view returns (uint256) { + return _hub(spoke, reserveId).getAssetDrawnIndex(_reserveAssetId(spoke, reserveId)); + } + function _deploySpokeWithOracle( address proxyAdminOwner, address _accessManager, string memory _oracleDesc ) internal pausePrank returns (ISpoke, IAaveOracle) { - address deployer = makeAddr('deployer'); - address predictedSpoke = vm.computeCreateAddress(deployer, vm.getNonce(deployer)); - IAaveOracle oracle = new AaveOracle(predictedSpoke, 8, _oracleDesc); - address spokeImpl = address(new SpokeInstance(address(oracle))); - ISpoke spoke = ISpoke( - _proxify( - deployer, - spokeImpl, + return + _deploySpokeWithOracle( proxyAdminOwner, - abi.encodeCall(Spoke.initialize, (_accessManager)) - ) + _accessManager, + _oracleDesc, + Constants.MAX_ALLOWED_USER_RESERVES_LIMIT + ); + } + + function _deploySpokeWithOracle( + address proxyAdminOwner, + address _accessManager, + string memory _oracleDesc, + uint16 maxUserReservesLimit + ) internal pausePrank returns (ISpoke, IAaveOracle) { + address deployer = makeAddr('deployer'); + + vm.startPrank(deployer); + IAaveOracle oracle = new AaveOracle(8, _oracleDesc); + + ISpoke spoke = DeployUtils.deploySpoke( + address(oracle), + maxUserReservesLimit, + proxyAdminOwner, + abi.encodeCall(ISpokeInstance.initialize, (_accessManager)) ); - assertEq(address(spoke), predictedSpoke, 'predictedSpoke'); + + oracle.setSpoke(address(spoke)); + vm.stopPrank(); + assertEq(spoke.ORACLE(), address(oracle)); assertEq(oracle.SPOKE(), address(spoke)); + return (spoke, oracle); } + function _deployTokenizationSpoke( + IHub hub, + uint256 assetId, + string memory shareName, + string memory shareSymbol, + address proxyAdminOwner + ) internal pausePrank returns (ITokenizationSpoke) { + address tokenizationSpokeImpl = address(new TokenizationSpokeInstance(address(hub), assetId)); + ITokenizationSpoke tokenizationSpoke = ITokenizationSpoke( + DeployUtils.proxify( + tokenizationSpokeImpl, + proxyAdminOwner, + abi.encodeCall(TokenizationSpokeInstance.initialize, (shareName, shareSymbol)) + ) + ); + return tokenizationSpoke; + } + + function _registerTokenizationSpoke( + IHub hub, + uint256 assetId, + ITokenizationSpoke tokenizationSpoke + ) internal { + return + _registerTokenizationSpoke( + hub, + assetId, + tokenizationSpoke, + IHub.SpokeConfig({ + addCap: type(uint40).max, + drawCap: 0, + riskPremiumThreshold: 0, + active: true, + halted: false + }) + ); + } + + function _registerTokenizationSpoke( + IHub hub, + uint256 assetId, + ITokenizationSpoke tokenizationSpoke, + IHub.SpokeConfig memory config + ) internal pausePrank { + vm.prank(ADMIN); + hub.addSpoke(assetId, address(tokenizationSpoke), config); + } + function _getDefaultReserveConfig( uint24 collateralRisk ) internal pure returns (ISpoke.ReserveConfig memory) { @@ -2253,27 +2559,11 @@ abstract contract Base is Test { paused: false, frozen: false, borrowable: true, - liquidatable: true, receiveSharesEnabled: true, collateralRisk: collateralRisk }); } - function _proxify( - address deployer, - address impl, - address proxyAdminOwner, - bytes memory initData - ) internal returns (address) { - vm.prank(deployer); - TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( - impl, - proxyAdminOwner, - initData - ); - return address(proxy); - } - function assertEq(IHubBase.PremiumDelta memory a, IHubBase.PremiumDelta memory b) internal pure { assertEq(a.sharesDelta, b.sharesDelta, 'sharesDelta'); assertEq(a.offsetRayDelta, b.offsetRayDelta, 'offsetRayDelta'); @@ -2294,7 +2584,7 @@ abstract contract Base is Test { assertEq(a.drawCap, b.drawCap, 'drawCap'); assertEq(a.riskPremiumThreshold, b.riskPremiumThreshold, 'riskPremiumThreshold'); assertEq(a.active, b.active, 'active'); - assertEq(a.paused, b.paused, 'paused'); + assertEq(a.halted, b.halted, 'halted'); assertEq(abi.encode(a), abi.encode(b)); } @@ -2356,6 +2646,13 @@ abstract contract Base is Test { indexDelta.rayMulUp(initialDrawnShares + initialPremiumShares).percentMulDown(liquidityFee); } + function _calculateMaxSupplyAmount( + ISpoke spoke, + uint256 reserveId + ) internal view returns (uint256) { + return MAX_SUPPLY_ASSET_UNITS * 10 ** spoke.getReserve(reserveId).decimals; + } + /// @dev Get the liquidation bonus for a given reserve at a user HF function _getLiquidationBonus( ISpoke spoke, @@ -2382,7 +2679,7 @@ abstract contract Base is Test { .totalCollateralValue .percentMulDown(userAccountData.avgCollateralFactor.fromWadDown()) .percentMulDown(99_00) - .wadDivDown(desiredHf) - userAccountData.totalDebtValue; + .wadDivDown(desiredHf) - userAccountData.totalDebtValueRay.fromRayUp(); // buffer to force debt lower (ie making sure resultant debt creates HF that is gt desired) } @@ -2415,6 +2712,68 @@ abstract contract Base is Test { ); } + // @dev Requires no previously added assets + // @dev Update _assetsSlot below if it changes + // Run: forge inspect Hub storage-layout + // @dev Update _addedSharesOffset below if it changes + // Have a look at IHub.Asset struct + function _mockSupplySharePrice( + IHub hub, + uint256 assetId, + uint256 totalAddedAssets, + uint256 addedShares + ) internal { + if (!hub.isSpokeListed(assetId, address(spoke1))) { + vm.prank(ADMIN); + hub.addSpoke( + assetId, + address(spoke1), + IHub.SpokeConfig({ + active: true, + halted: false, + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK + }) + ); + } + Utils.add({ + hub: hub, + assetId: assetId, + caller: address(spoke1), + amount: totalAddedAssets, + user: alice + }); + assertEq(hub.getAddedAssets(assetId), totalAddedAssets, '_mockSupplySharePrice: addedAssets'); + + uint256 _assetsSlot = 2; + uint256 _addedSharesOffset = 1; + vm.store( + address(hub), + bytes32( + uint256(SlotDerivation.deriveMapping({slot: bytes32(_assetsSlot), key: assetId})) + + _addedSharesOffset + ), + bytes32(addedShares) + ); + assertEq(hub.getAddedShares(assetId), addedShares, '_mockSupplySharePrice: addedShares'); + } + + function _setConstantInterestRateBps(IHub hub, uint256 assetId, uint32 interestRateBps) internal { + vm.prank(HUB_ADMIN); + hub.setInterestRateData( + assetId, + abi.encode( + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 90_00, + baseVariableBorrowRate: interestRateBps, + variableRateSlope1: 0, + variableRateSlope2: 0 + }) + ) + ); + } + function _mockInterestRateBps(uint256 interestRateBps) internal { _mockInterestRateBps(address(irStrategy), interestRateBps); } @@ -2659,12 +3018,12 @@ abstract contract Base is Test { hub: _hub(spoke, reserveId), assetId: reserve.assetId, decimals: reserve.decimals, - dynamicConfigKey: reserve.dynamicConfigKey, + collateralRisk: reserve.collateralRisk, paused: reserve.flags.paused(), frozen: reserve.flags.frozen(), borrowable: reserve.flags.borrowable(), receiveSharesEnabled: reserve.flags.receiveSharesEnabled(), - collateralRisk: reserve.collateralRisk + dynamicConfigKey: reserve.dynamicConfigKey }); } @@ -2731,14 +3090,14 @@ abstract contract Base is Test { function _getTypedDataHash( ISpoke spoke, - EIP712Types.SetUserPositionManager memory setUserPositionManager + ISpoke.SetUserPositionManagers memory setUserPositionManagers ) internal view returns (bytes32) { return keccak256( abi.encodePacked( '\x19\x01', spoke.DOMAIN_SEPARATOR(), - vm.eip712HashStruct('SetUserPositionManager', abi.encode(setUserPositionManager)) + vm.eip712HashStruct('SetUserPositionManagers', abi.encode(setUserPositionManagers)) ) ); } @@ -2809,6 +3168,17 @@ abstract contract Base is Test { return _packNonce(key, nonce); } + function _getRandomNonceAtKey(uint192 key) internal returns (uint256) { + uint64 nonce = _randomNonce(); + return _packNonce(key, nonce); + } + + function _randomAddressOmit(address omit) internal returns (address) { + address addr = vm.randomAddress(); + while (addr == omit) addr = vm.randomAddress(); + return addr; + } + function _assertNonceIncrement( INoncesKeyed verifier, address who, @@ -2820,6 +3190,16 @@ abstract contract Base is Test { assertEq(verifier.nonces(who, nonceKey), _packNonce(nonceKey, nonce)); } + function _assertEntityHasNoBalanceOrAllowance( + IERC20 underlying, + address entity, + address user + ) internal { + assertEq(underlying.balanceOf(entity), 0); + assertEq(underlying.allowance({owner: user, spender: entity}), 0); + assertEq(underlying.allowance({owner: entity, spender: vm.randomAddress()}), 0); + } + /// @dev Pack key and nonce into a keyNonce function _packNonce(uint192 key, uint64 nonce) internal pure returns (uint256) { return (uint256(key) << 64) | nonce; @@ -2868,4 +3248,90 @@ abstract contract Base is Test { hub.getAsset(assetId).realizedFees + _calcUnrealizedFees(hub, assetId); } + + function _addNewAssetsAndReserves(IHub hub, ISpoke spoke, uint256 count) internal { + for (uint256 i = 0; i < count; i++) { + MockERC20 newToken = new MockERC20(); + newToken.mint(alice, MAX_SUPPLY_AMOUNT * 10 ** 18); + newToken.mint(bob, MAX_SUPPLY_AMOUNT * 10 ** 18); + vm.prank(alice); + newToken.approve(address(spoke), UINT256_MAX); + vm.prank(bob); + newToken.approve(address(spoke), UINT256_MAX); + + IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ + active: true, + halted: false, + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: 1000_00 + }); + + bytes memory encodedIrData = abi.encode( + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 90_00, // 90.00% + baseVariableBorrowRate: 5_00, // 5.00% + variableRateSlope1: 5_00, // 5.00% + variableRateSlope2: 5_00 // 5.00% + }) + ); + + // Add asset to hub + vm.startPrank(ADMIN); + uint256 newTokenAssetId = hub.addAsset( + address(newToken), + 18, + address(treasurySpoke), + address(irStrategy), + encodedIrData + ); + hub.updateAssetConfig( + newTokenAssetId, + IHub.AssetConfig({ + liquidityFee: 10_00, + feeReceiver: address(treasurySpoke), + irStrategy: address(irStrategy), + reinvestmentController: address(0) + }), + new bytes(0) + ); + + // Prepare the reserve configs + ISpoke.ReserveConfig memory reserveConfig = ISpoke.ReserveConfig({ + collateralRisk: _randomBps(), + paused: false, + frozen: false, + borrowable: true, + receiveSharesEnabled: true + }); + ISpoke.DynamicReserveConfig memory dynamicConfig = ISpoke.DynamicReserveConfig({ + collateralFactor: 80_00, + maxLiquidationBonus: 105_00, + liquidationFee: 10_00 + }); + + // Add reserve to spoke + spoke.addReserve( + address(hub), + newTokenAssetId, + _deployMockPriceFeed(spoke, 1e8), + reserveConfig, + dynamicConfig + ); + + // Add spoke to hub + hub.addSpoke(newTokenAssetId, address(spoke), spokeConfig); + vm.stopPrank(); + } + } + + function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + return abi.encodePacked(r, s, v); + } + + function makeKey(string memory name) internal returns (uint256) { + (, uint256 key) = makeAddrAndKey(name); + return key; + } } diff --git a/tests/Constants.sol b/tests/Constants.sol index b32ed1d4f..6e029dde4 100644 --- a/tests/Constants.sol +++ b/tests/Constants.sol @@ -8,15 +8,15 @@ library Constants { uint8 public constant MIN_ALLOWED_UNDERLYING_DECIMALS = 6; uint40 public constant MAX_ALLOWED_SPOKE_CAP = type(uint40).max; uint24 public constant MAX_RISK_PREMIUM_THRESHOLD = type(uint24).max; // 167772.15% + uint256 public constant VIRTUAL_ASSETS = 1e6; + uint256 public constant VIRTUAL_SHARES = 1e6; /// @dev Spoke Constants uint8 public constant ORACLE_DECIMALS = 8; uint64 public constant HEALTH_FACTOR_LIQUIDATION_THRESHOLD = 1e18; uint256 public constant DUST_LIQUIDATION_THRESHOLD = 1000e26; uint24 public constant MAX_ALLOWED_COLLATERAL_RISK = 1000_00; // 1000.00% - uint256 public constant MAX_ALLOWED_DYNAMIC_CONFIG_KEY = type(uint24).max; - bytes32 public constant SET_USER_POSITION_MANAGER_TYPEHASH = - // keccak256('SetUserPositionManager(address positionManager,address user,bool approve,uint256 nonce,uint256 deadline)') - 0x758d23a3c07218b7ea0b4f7f63903c4e9d5cbde72d3bcfe3e9896639025a0214; + uint256 public constant MAX_ALLOWED_DYNAMIC_CONFIG_KEY = type(uint32).max; uint256 public constant MAX_ALLOWED_ASSET_ID = type(uint16).max; + uint16 public constant MAX_ALLOWED_USER_RESERVES_LIMIT = type(uint16).max; } diff --git a/tests/Create2Utils.sol b/tests/Create2Utils.sol new file mode 100644 index 000000000..383578260 --- /dev/null +++ b/tests/Create2Utils.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {Vm} from 'forge-std/Vm.sol'; + +library Create2Utils { + error NoCreate2Factory(); + error Create2DeploymentFailed(); + + // https://github.com/safe-global/safe-singleton-factory + address public constant CREATE2_FACTORY = 0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7; + bytes internal constant CREATE2_FACTORY_BYTECODE = + hex'7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3'; + + Vm internal constant vm = Vm(address(uint160(uint256(keccak256('hevm cheat code'))))); + + function loadCreate2Factory() internal { + if (_isContractDeployed(CREATE2_FACTORY)) { + return; + } + vm.etch(CREATE2_FACTORY, CREATE2_FACTORY_BYTECODE); + } + + function create2Deploy(bytes32 salt, bytes memory bytecode) internal returns (address) { + require(_isContractDeployed(CREATE2_FACTORY), NoCreate2Factory()); + + address computed = computeCreate2Address(salt, bytecode); + + if (_isContractDeployed(computed)) { + return computed; + } else { + bytes memory creationBytecode = abi.encodePacked(salt, bytecode); + bytes memory returnData; + (, returnData) = CREATE2_FACTORY.call(creationBytecode); + + address deployedAt = address(uint160(bytes20(returnData))); + require(deployedAt == computed, Create2DeploymentFailed()); + + return deployedAt; + } + } + + function _isContractDeployed(address instance) internal view returns (bool) { + return (instance.code.length > 0); + } + + function computeCreate2Address( + bytes32 salt, + bytes32 initcodeHash + ) internal pure returns (address) { + return + address( + uint160( + uint256(keccak256(abi.encodePacked(bytes1(0xff), CREATE2_FACTORY, salt, initcodeHash))) + ) + ); + } + + function computeCreate2Address( + bytes32 salt, + bytes memory bytecode + ) internal pure returns (address) { + return computeCreate2Address(salt, keccak256(abi.encodePacked(bytecode))); + } +} diff --git a/tests/DeployUtils.sol b/tests/DeployUtils.sol new file mode 100644 index 000000000..e928effd6 --- /dev/null +++ b/tests/DeployUtils.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {Vm} from 'forge-std/Vm.sol'; +import {TransparentUpgradeableProxy} from 'src/dependencies/openzeppelin/TransparentUpgradeableProxy.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {ISpokeInstance} from 'tests/mocks/ISpokeInstance.sol'; +import {Create2Utils} from 'tests/Create2Utils.sol'; + +library DeployUtils { + Vm internal constant vm = Vm(address(uint160(uint256(keccak256('hevm cheat code'))))); + + function deploySpokeImplementation( + address oracle, + uint16 maxUserReservesLimit + ) internal returns (ISpokeInstance) { + return deploySpokeImplementation(oracle, maxUserReservesLimit, ''); + } + + function deploySpokeImplementation( + address oracle, + uint16 maxUserReservesLimit, + bytes32 salt + ) internal returns (ISpokeInstance spoke) { + Create2Utils.loadCreate2Factory(); + return + ISpokeInstance( + Create2Utils.create2Deploy(salt, _getSpokeInstanceInitCode(oracle, maxUserReservesLimit)) + ); + } + + function deploySpoke( + address oracle, + uint16 maxUserReservesLimit, + address proxyAdminOwner, + bytes memory initData + ) internal returns (ISpoke) { + return + ISpoke( + proxify( + address(deploySpokeImplementation(oracle, maxUserReservesLimit, '')), + proxyAdminOwner, + initData + ) + ); + } + + function getDeterministicSpokeInstanceAddress( + address oracle, + uint16 maxUserReservesLimit + ) internal returns (address) { + return getDeterministicSpokeInstanceAddress(oracle, maxUserReservesLimit, ''); + } + + function getDeterministicSpokeInstanceAddress( + address oracle, + uint16 maxUserReservesLimit, + bytes32 salt + ) internal returns (address) { + bytes32 initCodeHash = keccak256(_getSpokeInstanceInitCode(oracle, maxUserReservesLimit)); + + Create2Utils.loadCreate2Factory(); + return Create2Utils.computeCreate2Address(salt, initCodeHash); + } + + function deployHub(address authority) internal returns (IHub) { + return deployHub(authority, ''); + } + + function deployHub(address authority, bytes32 salt) internal returns (IHub hub) { + Create2Utils.loadCreate2Factory(); + return IHub(Create2Utils.create2Deploy(salt, _getHubInitCode(authority))); + } + + function getDeterministicHubAddress(address authority) internal returns (address) { + return getDeterministicHubAddress(authority, ''); + } + + function getDeterministicHubAddress(address authority, bytes32 salt) internal returns (address) { + bytes32 initCodeHash = keccak256(_getHubInitCode(authority)); + + Create2Utils.loadCreate2Factory(); + return Create2Utils.computeCreate2Address(salt, initCodeHash); + } + + function proxify( + address impl, + address proxyAdminOwner, + bytes memory initData + ) internal returns (address) { + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + impl, + proxyAdminOwner, + initData + ); + return address(proxy); + } + + function _getSpokeInstanceInitCode( + address oracle, + uint16 maxUserReservesLimit + ) internal view returns (bytes memory) { + return + abi.encodePacked( + vm.getCode('src/spoke/instances/SpokeInstance.sol:SpokeInstance'), + abi.encode(oracle, maxUserReservesLimit) + ); + } + + function _getHubInitCode(address authority) internal view returns (bytes memory) { + return abi.encodePacked(vm.getCode('src/hub/Hub.sol:Hub'), abi.encode(authority)); + } +} diff --git a/tests/Utils.sol b/tests/Utils.sol index d02fb097c..a3fbdc6f1 100644 --- a/tests/Utils.sol +++ b/tests/Utils.sol @@ -3,11 +3,14 @@ pragma solidity ^0.8.0; import {Vm} from 'forge-std/Vm.sol'; -import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; +import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; import {IHub, IHubBase} from 'src/hub/interfaces/IHub.sol'; import {ISpokeBase, ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {ITokenizationSpoke} from 'src/spoke/interfaces/ITokenizationSpoke.sol'; library Utils { + using SafeERC20 for *; + Vm internal constant vm = Vm(address(uint160(uint256(keccak256('hevm cheat code'))))); // hub @@ -215,10 +218,13 @@ library Utils { _approve(IERC20(hub.getAsset(assetId).underlying), owner, caller, amount); } + function approve(ITokenizationSpoke vault, address owner, uint256 amount) internal { + _approve(IERC20(vault.asset()), owner, address(vault), amount); + } + function _approve(IERC20 underlying, address owner, address spender, uint256 amount) private { vm.startPrank(owner); - underlying.approve(spender, 0); - underlying.approve(spender, amount); + underlying.forceApprove(spender, amount); vm.stopPrank(); } diff --git a/tests/gas/Gateways.Operations.gas.t.sol b/tests/gas/Gateways.Operations.gas.t.sol index c2ab0743f..3b123cee1 100644 --- a/tests/gas/Gateways.Operations.gas.t.sol +++ b/tests/gas/Gateways.Operations.gas.t.sol @@ -115,13 +115,13 @@ contract SignatureGateway_Gas_Tests is SignatureGatewayBaseTest { } function test_supplyWithSig() public { - EIP712Types.Supply memory p = EIP712Types.Supply({ + ISignatureGateway.Supply memory p = ISignatureGateway.Supply({ spoke: address(spoke1), reserveId: _wethReserveId(spoke1), amount: 100e18, onBehalfOf: alice, nonce: gateway.nonces(alice, nonceKey), - deadline: _warpBeforeRandomDeadline() + deadline: vm.getBlockTimestamp() }); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); Utils.approve(spoke1, p.reserveId, alice, address(gateway), p.amount); @@ -132,13 +132,13 @@ contract SignatureGateway_Gas_Tests is SignatureGatewayBaseTest { } function test_withdrawWithSig() public { - EIP712Types.Withdraw memory p = EIP712Types.Withdraw({ + ISignatureGateway.Withdraw memory p = ISignatureGateway.Withdraw({ spoke: address(spoke1), reserveId: _wethReserveId(spoke1), amount: 100e18, onBehalfOf: alice, nonce: gateway.nonces(alice, nonceKey), - deadline: _warpBeforeRandomDeadline() + deadline: vm.getBlockTimestamp() }); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); @@ -150,13 +150,13 @@ contract SignatureGateway_Gas_Tests is SignatureGatewayBaseTest { } function test_borrowWithSig() public { - EIP712Types.Borrow memory p = EIP712Types.Borrow({ + ISignatureGateway.Borrow memory p = ISignatureGateway.Borrow({ spoke: address(spoke1), reserveId: _wethReserveId(spoke1), amount: 100e18, onBehalfOf: alice, nonce: gateway.nonces(alice, nonceKey), - deadline: _warpBeforeRandomDeadline() + deadline: vm.getBlockTimestamp() }); Utils.supplyCollateral(spoke1, p.reserveId, alice, p.amount * 4, alice); Utils.borrow(spoke1, p.reserveId, alice, p.amount, alice); @@ -167,13 +167,13 @@ contract SignatureGateway_Gas_Tests is SignatureGatewayBaseTest { } function test_repayWithSig() public { - EIP712Types.Repay memory p = EIP712Types.Repay({ + ISignatureGateway.Repay memory p = ISignatureGateway.Repay({ spoke: address(spoke1), reserveId: _wethReserveId(spoke1), amount: 100e18, onBehalfOf: alice, nonce: gateway.nonces(alice, nonceKey), - deadline: _warpBeforeRandomDeadline() + deadline: vm.getBlockTimestamp() }); Utils.supplyCollateral(spoke1, p.reserveId, alice, p.amount * 10, alice); Utils.borrow(spoke1, p.reserveId, alice, p.amount * 3, alice); @@ -186,13 +186,13 @@ contract SignatureGateway_Gas_Tests is SignatureGatewayBaseTest { } function test_setUsingAsCollateralWithSig() public { - EIP712Types.SetUsingAsCollateral memory p = EIP712Types.SetUsingAsCollateral({ + ISignatureGateway.SetUsingAsCollateral memory p = ISignatureGateway.SetUsingAsCollateral({ spoke: address(spoke1), reserveId: _wethReserveId(spoke1), useAsCollateral: true, onBehalfOf: alice, nonce: gateway.nonces(alice, nonceKey), - deadline: _warpBeforeRandomDeadline() + deadline: vm.getBlockTimestamp() }); Utils.supply(spoke1, p.reserveId, alice, 1e18, alice); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); @@ -202,11 +202,11 @@ contract SignatureGateway_Gas_Tests is SignatureGatewayBaseTest { } function test_updateUserRiskPremiumWithSig() public { - EIP712Types.UpdateUserRiskPremium memory p = EIP712Types.UpdateUserRiskPremium({ + ISignatureGateway.UpdateUserRiskPremium memory p = ISignatureGateway.UpdateUserRiskPremium({ spoke: address(spoke1), - user: alice, + onBehalfOf: alice, nonce: gateway.nonces(alice, nonceKey), - deadline: _warpBeforeRandomDeadline() + deadline: vm.getBlockTimestamp() }); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); @@ -218,11 +218,11 @@ contract SignatureGateway_Gas_Tests is SignatureGatewayBaseTest { } function test_updateUserDynamicConfigWithSig() public { - EIP712Types.UpdateUserDynamicConfig memory p = EIP712Types.UpdateUserDynamicConfig({ + ISignatureGateway.UpdateUserDynamicConfig memory p = ISignatureGateway.UpdateUserDynamicConfig({ spoke: address(spoke1), - user: alice, + onBehalfOf: alice, nonce: gateway.nonces(alice, nonceKey), - deadline: _warpBeforeRandomDeadline() + deadline: vm.getBlockTimestamp() }); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); @@ -236,19 +236,27 @@ contract SignatureGateway_Gas_Tests is SignatureGatewayBaseTest { function test_setSelfAsUserPositionManagerWithSig() public { vm.prank(alice); spoke1.useNonce(nonceKey); - EIP712Types.SetUserPositionManager memory p = EIP712Types.SetUserPositionManager({ - positionManager: address(gateway), - user: alice, - approve: true, + ISpoke.PositionManagerUpdate[] memory updates = new ISpoke.PositionManagerUpdate[](1); + updates[0] = ISpoke.PositionManagerUpdate(address(gateway), true); + ISpoke.SetUserPositionManagers memory p = ISpoke.SetUserPositionManagers({ + onBehalfOf: alice, + updates: updates, nonce: spoke1.nonces(alice, nonceKey), // note: this typed sig is forwarded to spoke - deadline: _warpBeforeRandomDeadline() + deadline: vm.getBlockTimestamp() }); bytes memory signature = _sign(alicePk, _getTypedDataHash(spoke1, p)); vm.prank(alice); spoke1.setUserPositionManager(address(gateway), false); - gateway.setSelfAsUserPositionManagerWithSig(address(spoke1), p, signature); + gateway.setSelfAsUserPositionManagerWithSig({ + spoke: address(spoke1), + onBehalfOf: p.onBehalfOf, + approve: p.updates[0].approve, + nonce: p.nonce, + deadline: p.deadline, + signature: signature + }); vm.snapshotGasLastCall(NAMESPACE, 'setSelfAsUserPositionManagerWithSig'); } } diff --git a/tests/gas/Hub.Operations.gas.t.sol b/tests/gas/Hub.Operations.gas.t.sol index 6723966cd..a54761a0b 100644 --- a/tests/gas/Hub.Operations.gas.t.sol +++ b/tests/gas/Hub.Operations.gas.t.sol @@ -279,6 +279,8 @@ contract HubOperations_Gas_Tests is Base { type(uint256).max ); + grantDeficitEliminatorRole(hub1, address(spoke1)); + vm.prank(address(spoke1)); hub1.reportDeficit(daiAssetId, drawnDebt, premiumDelta); vm.snapshotGasLastCall('Hub.Operations', 'reportDeficit'); diff --git a/tests/gas/Spoke.Operations.gas.t.sol b/tests/gas/Spoke.Operations.gas.t.sol index aa6d2b8d9..904dd80fe 100644 --- a/tests/gas/Spoke.Operations.gas.t.sol +++ b/tests/gas/Spoke.Operations.gas.t.sol @@ -136,7 +136,7 @@ contract SpokeOperations_Gas_Tests is SpokeBase { } function test_liquidation_partial() public { - _liquidationSetup(); + _liquidationSetup(85_00); vm.startPrank(bob); spoke.liquidationCall(reserveId.usdx, reserveId.dai, alice, 100_000e18, false); @@ -145,7 +145,7 @@ contract SpokeOperations_Gas_Tests is SpokeBase { } function test_liquidation_full() public { - _liquidationSetup(); + _liquidationSetup(85_00); vm.startPrank(bob); spoke.liquidationCall(reserveId.usdx, reserveId.dai, alice, UINT256_MAX, false); @@ -155,7 +155,7 @@ contract SpokeOperations_Gas_Tests is SpokeBase { } function test_liquidation_receiveShares_partial() public { - _liquidationSetup(); + _liquidationSetup(85_00); vm.startPrank(bob); spoke.liquidationCall(reserveId.usdx, reserveId.dai, alice, 100_000e18, true); @@ -165,7 +165,7 @@ contract SpokeOperations_Gas_Tests is SpokeBase { } function test_liquidation_receiveShares_full() public { - _liquidationSetup(); + _liquidationSetup(85_00); vm.startPrank(bob); spoke.liquidationCall(reserveId.usdx, reserveId.dai, alice, UINT256_MAX, true); @@ -174,6 +174,16 @@ contract SpokeOperations_Gas_Tests is SpokeBase { vm.stopPrank(); } + function test_liquidation_reportDeficit_full() public { + _liquidationSetup(45_00); + + vm.startPrank(bob); + spoke.liquidationCall(reserveId.usdx, reserveId.dai, alice, UINT256_MAX, false); + vm.snapshotGasLastCall(NAMESPACE, 'liquidationCall (reportDeficit): full'); + + vm.stopPrank(); + } + function test_updateRiskPremium() public { vm.prank(bob); spoke.supply(reserveId.dai, 1000e18, bob); @@ -227,7 +237,6 @@ contract SpokeOperations_Gas_Tests is SpokeBase { // supplyWithPermit (dai) tokenList.dai.approve(address(spoke), 0); - (, uint256 bobPk) = makeAddrAndKey('bob'); EIP712Types.Permit memory permit = EIP712Types.Permit({ owner: bob, spender: address(spoke), @@ -267,7 +276,6 @@ contract SpokeOperations_Gas_Tests is SpokeBase { // supplyWithPermitAndEnableCollateral (wbtc) calls = new bytes[](3); tokenList.wbtc.approve(address(spoke), 0); - (, bobPk) = makeAddrAndKey('bob'); permit = EIP712Types.Permit({ owner: bob, spender: address(spoke), @@ -288,51 +296,38 @@ contract SpokeOperations_Gas_Tests is SpokeBase { vm.stopPrank(); } - function test_setUserPositionManagerWithSig() public { - (address user, uint256 userPk) = makeAddrAndKey(string(vm.randomBytes(32))); - vm.label(user, 'user'); - address positionManager = vm.randomAddress(); + function test_setUserPositionManagersWithSig() public { + (address user, uint256 userPk) = makeAddrAndKey('user'); + address positionManager = makeAddr('positionManager'); vm.prank(SPOKE_ADMIN); spoke.updatePositionManager(positionManager, true); - uint192 nonceKey = _randomNonceKey(); + uint192 nonceKey = 100; vm.prank(user); spoke.useNonce(nonceKey); - EIP712Types.SetUserPositionManager memory params = EIP712Types.SetUserPositionManager({ - positionManager: positionManager, - user: user, - approve: true, + ISpoke.PositionManagerUpdate[] memory updates = new ISpoke.PositionManagerUpdate[](1); + updates[0] = ISpoke.PositionManagerUpdate(positionManager, true); + + ISpoke.SetUserPositionManagers memory p = ISpoke.SetUserPositionManagers({ + onBehalfOf: user, + updates: updates, nonce: spoke.nonces(user, nonceKey), - deadline: vm.randomUint(vm.getBlockTimestamp(), MAX_SKIP_TIME) + deadline: vm.getBlockTimestamp() }); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPk, _getTypedDataHash(spoke, params)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPk, _getTypedDataHash(spoke, p)); bytes memory signature = abi.encodePacked(r, s, v); - spoke.setUserPositionManagerWithSig( - params.positionManager, - params.user, - params.approve, - params.nonce, - params.deadline, - signature - ); - vm.snapshotGasLastCall(NAMESPACE, 'setUserPositionManagerWithSig: enable'); + spoke.setUserPositionManagersWithSig(p, signature); + vm.snapshotGasLastCall(NAMESPACE, 'setUserPositionManagersWithSig: enable'); - params.approve = false; - params.nonce = spoke.nonces(user, nonceKey); - (v, r, s) = vm.sign(userPk, _getTypedDataHash(spoke, params)); + p.updates[0].approve = false; + p.nonce = spoke.nonces(user, nonceKey); + (v, r, s) = vm.sign(userPk, _getTypedDataHash(spoke, p)); signature = abi.encodePacked(r, s, v); - spoke.setUserPositionManagerWithSig( - params.positionManager, - params.user, - params.approve, - params.nonce, - params.deadline, - signature - ); - vm.snapshotGasLastCall(NAMESPACE, 'setUserPositionManagerWithSig: disable'); + spoke.setUserPositionManagersWithSig(p, signature); + vm.snapshotGasLastCall(NAMESPACE, 'setUserPositionManagersWithSig: disable'); } function _seed() internal { @@ -348,7 +343,7 @@ contract SpokeOperations_Gas_Tests is SpokeBase { vm.stopPrank(); } - function _liquidationSetup() internal { + function _liquidationSetup(uint256 pricePercentage) internal { _updateMaxLiquidationBonus(spoke, _usdxReserveId(spoke), 105_00); _updateLiquidationFee(spoke, _usdxReserveId(spoke), 10_00); @@ -366,7 +361,7 @@ contract SpokeOperations_Gas_Tests is SpokeBase { reserveId.dai, reserveId.usdx, 1.05e18, - 85_00 + pricePercentage ); skip(100); @@ -376,11 +371,6 @@ contract SpokeOperations_Gas_Tests is SpokeBase { } else { assertGt(userAccountData.riskPremium, 0); // rp after borrow should be non zero } - vm.mockCallRevert( - address(hub1), - abi.encodeWithSelector(IHubBase.reportDeficit.selector), - 'deficit' - ); } } diff --git a/tests/gas/TokenizationSpoke.Operations.gas.t.sol b/tests/gas/TokenizationSpoke.Operations.gas.t.sol new file mode 100644 index 000000000..457c985a0 --- /dev/null +++ b/tests/gas/TokenizationSpoke.Operations.gas.t.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; + +/// forge-config: default.isolate = true +contract TokenizationSpokeOperations_Gas_Tests is TokenizationSpokeBaseTest { + string internal constant NAMESPACE = 'TokenizationSpoke.Operations'; + ITokenizationSpoke internal vault; + uint192 internal nonceKey = 100; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + Utils.approve(vault, alice, 2100e18); + vm.startPrank(alice); + vault.deposit(100e18, alice); + vault.useNonce(nonceKey); + vault.usePermitNonce(); + vm.stopPrank(); + } + + function test_deposit() public { + vm.prank(alice); + vault.deposit(1000e18, alice); + vm.snapshotGasLastCall(NAMESPACE, 'deposit'); + } + + function test_mint() public { + uint256 shares = vault.previewMint(1000e18); + vm.prank(alice); + vault.mint(shares, alice); + vm.snapshotGasLastCall(NAMESPACE, 'mint'); + } + + function test_withdraw() public { + vm.startPrank(alice); + vault.deposit(1000e18, alice); + vault.withdraw(500e18, alice, alice); + vm.snapshotGasLastCall(NAMESPACE, 'withdraw: self, partial'); + + uint256 balance = vault.maxWithdraw(alice); + vault.withdraw(balance, alice, alice); + vm.snapshotGasLastCall(NAMESPACE, 'withdraw: self, full'); + + vault.deposit(1000e18, alice); + vault.approve(bob, 1000e18); + vm.stopPrank(); + + vm.startPrank(bob); + vault.withdraw(500e18, bob, alice); + vm.snapshotGasLastCall(NAMESPACE, 'withdraw: on behalf, partial'); + + balance = vault.maxWithdraw(alice); + vault.withdraw(balance, bob, alice); + vm.snapshotGasLastCall(NAMESPACE, 'withdraw: on behalf, full'); + vm.stopPrank(); + } + + function test_redeem() public { + vm.startPrank(alice); + vault.deposit(1000e18, alice); + uint256 shares = vault.balanceOf(alice); + vault.redeem(shares / 2, alice, alice); + vm.snapshotGasLastCall(NAMESPACE, 'redeem: self, partial'); + + shares = vault.maxRedeem(alice); + vault.redeem(shares, alice, alice); + vm.snapshotGasLastCall(NAMESPACE, 'redeem: self, full'); + + vault.deposit(1000e18, alice); + vault.approve(bob, 1000e18); + vm.stopPrank(); + + vm.startPrank(bob); + shares = vault.balanceOf(alice); + vault.redeem(shares / 2, bob, alice); + vm.snapshotGasLastCall(NAMESPACE, 'redeem: on behalf, partial'); + + shares = vault.maxRedeem(alice); + vault.redeem(shares, bob, alice); + vm.snapshotGasLastCall(NAMESPACE, 'redeem: on behalf, full'); + vm.stopPrank(); + } + + function test_depositWithSig() public { + ITokenizationSpoke.TokenizedDeposit memory p = ITokenizationSpoke.TokenizedDeposit({ + depositor: alice, + assets: 1000e18, + receiver: alice, + nonce: vault.nonces(alice, nonceKey), + deadline: vm.getBlockTimestamp() + }); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.assets); + + vault.depositWithSig(p, signature); + vm.snapshotGasLastCall(NAMESPACE, 'depositWithSig'); + } + + function test_mintWithSig() public { + ITokenizationSpoke.TokenizedMint memory p = ITokenizationSpoke.TokenizedMint({ + depositor: alice, + shares: vault.previewMint(1000e18), + receiver: alice, + nonce: vault.nonces(alice, nonceKey), + deadline: vm.getBlockTimestamp() + }); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.shares); + + vault.mintWithSig(p, signature); + vm.snapshotGasLastCall(NAMESPACE, 'mintWithSig'); + } + + function test_withdrawWithSig() public { + ITokenizationSpoke.TokenizedWithdraw memory p = ITokenizationSpoke.TokenizedWithdraw({ + owner: alice, + assets: 500e18, + receiver: alice, + nonce: vault.nonces(alice, nonceKey), + deadline: vm.getBlockTimestamp() + }); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.assets); + vm.prank(alice); + vault.deposit(p.assets, alice); + + vault.withdrawWithSig(p, signature); + vm.snapshotGasLastCall(NAMESPACE, 'withdrawWithSig'); + } + + function test_redeemWithSig() public { + ITokenizationSpoke.TokenizedRedeem memory p = ITokenizationSpoke.TokenizedRedeem({ + owner: alice, + shares: 1000e18, + receiver: alice, + nonce: vault.nonces(alice, nonceKey), + deadline: vm.getBlockTimestamp() + }); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.shares); + vm.prank(alice); + vault.mint(p.shares, alice); + + vault.redeemWithSig(p, signature); + vm.snapshotGasLastCall(NAMESPACE, 'redeemWithSig'); + } + + function test_permit() public { + EIP712Types.Permit memory p = EIP712Types.Permit({ + owner: alice, + spender: bob, + value: 1000e18, + nonce: vault.nonces(alice), + deadline: vm.getBlockTimestamp() + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectEmit(address(vault)); + emit IERC20.Approval(p.owner, p.spender, p.value); + + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + vm.snapshotGasLastCall(NAMESPACE, 'permit'); + + assertEq(vault.allowance(p.owner, p.spender), p.value); + } +} diff --git a/tests/misc/debt_to_liquidate.py b/tests/misc/debt_to_liquidate.py deleted file mode 100644 index 2651d68bf..000000000 --- a/tests/misc/debt_to_liquidate.py +++ /dev/null @@ -1,62 +0,0 @@ -# Highlights the fact that debtToLiquidate cannot exceed debtReserveBalance in liquidation logic. -from z3 import * - -WAD = IntVal(10**18) -PERCENTAGE_FACTOR = IntVal(10**4) - -DUST_LIQUIDATION_THRESHOLD = IntVal(1000 * 10**26) - -def mulDivDown(a, num, den): - return (a * num) / den - -def mulDivUp(a, num, den): - return (a * num + den - 1) / den - -s = Solver() - -debtAssetPrice = Int('debtAssetPrice') -s.add(1 <= debtAssetPrice, debtAssetPrice <= 10**30) -debtAssetDecimals = Int('debtAssetDecimals') -s.add(1 <= debtAssetDecimals, debtAssetDecimals <= 18) -debtAssetUnit = ToInt(10**debtAssetDecimals) - -collateralAssetPrice = Int('collateralAssetPrice') -s.add(1 <= collateralAssetPrice, collateralAssetPrice <= 10**30) -collateralAssetDecimals = Int('collateralAssetDecimals') -s.add(1 <= collateralAssetDecimals, collateralAssetDecimals <= 18) -collateralAssetUnit = ToInt(10**collateralAssetDecimals) - -liquidationBonus = Int('liquidationBonus') -s.add(PERCENTAGE_FACTOR <= liquidationBonus, liquidationBonus < PERCENTAGE_FACTOR * PERCENTAGE_FACTOR) - -debtReserveBalance = Int('debtReserveBalance') -s.add(0 <= debtReserveBalance, debtReserveBalance <= 10**30) -debtToLiquidate = Int('debtToLiquidate') -s.add(0 <= debtToLiquidate, debtToLiquidate <= debtReserveBalance) - -collateralReserveBalance = Int('collateralReserveBalance') -s.add(0 <= collateralReserveBalance, collateralReserveBalance <= 10**30) -collateralToLiquidate = Int('collateralToLiquidate') -s.add(collateralToLiquidate == mulDivDown(debtToLiquidate, debtAssetPrice * collateralAssetUnit * liquidationBonus, debtAssetUnit * collateralAssetPrice * PERCENTAGE_FACTOR)) - -s.add( - Or( - collateralToLiquidate > collateralReserveBalance, - And( - mulDivDown(collateralReserveBalance - collateralToLiquidate, collateralAssetPrice * WAD, collateralAssetUnit) < DUST_LIQUIDATION_THRESHOLD, - DUST_LIQUIDATION_THRESHOLD <= mulDivDown(debtReserveBalance - debtToLiquidate, debtAssetPrice * WAD, debtAssetUnit) - ) - ) -) - -s.add( - Not( - mulDivUp( - collateralReserveBalance, - collateralAssetPrice * debtAssetUnit * PERCENTAGE_FACTOR, - debtAssetPrice * collateralAssetUnit * liquidationBonus - ) <= debtReserveBalance - ) -) - -print(s.model() if s.check() == sat else 'no counterexample') diff --git a/tests/misc/risk_premium_threshold_overshoot.py b/tests/misc/risk_premium_threshold_overshoot.py new file mode 100644 index 000000000..ed8183842 --- /dev/null +++ b/tests/misc/risk_premium_threshold_overshoot.py @@ -0,0 +1,45 @@ +# Proves that the proposed RiskPremiumThreshold formula strictly bounds the aggregate risk premium +# for any number of users, given any individual risk premium <= MAX_COLLATERAL_RISK. +from z3 import * + +PERCENTAGE_FACTOR = IntVal(100_00) +MAX_COLLATERAL_RISK = IntVal(1000_00) + +def percentMulUp(value, percentage): + return (value * percentage + PERCENTAGE_FACTOR - 1) / PERCENTAGE_FACTOR + +# ∀ drawnShares_i ≥ 1, ∀ riskPremium_i ≤ MAX_COLLATERAL_RISK, +# we want to minimize RISK_PREMIUM_THRESHOLD such that: +# Σ ⌈(drawnShares_i × riskPremium_i) / PERCENTAGE_FACTOR⌉ ≤ ⌈((Σ drawnShares_i) × RISK_PREMIUM_THRESHOLD) / PERCENTAGE_FACTOR⌉ + +# maximize LHS using the property ⌈x⌉ ≤ x + 1, and minimize RHS using the property ⌈x⌉ ≥ x +# Σ ((drawnShares_i × riskPremium_i) / PERCENTAGE_FACTOR + 1) ≤ ((Σ drawnShares_i) × RISK_PREMIUM_THRESHOLD) / PERCENTAGE_FACTOR + +# for N as number of users, the above simplifies to: +# (1/PERCENTAGE_FACTOR) × Σ (drawnShares_i × riskPremium_i) + N ≤ (RISK_PREMIUM_THRESHOLD / PERCENTAGE_FACTOR) × Σ drawnShares_i + +# worst case occurs when riskPremium_i = MAX_COLLATERAL_RISK for all i and drawnShares_i = 1 for all i (to maximize N) +# which implies: Σ drawnShares_i = N, and Σ (drawnShares_i × riskPremium_i) = N × MAX_COLLATERAL_RISK +# substituting these values gives: +# (1/PERCENTAGE_FACTOR) × (N × MAX_COLLATERAL_RISK) + N ≤ (RISK_PREMIUM_THRESHOLD / PERCENTAGE_FACTOR) × N + +# MAX_COLLATERAL_RISK + PERCENTAGE_FACTOR ≤ RISK_PREMIUM_THRESHOLD + +RISK_PREMIUM_THRESHOLD = MAX_COLLATERAL_RISK + PERCENTAGE_FACTOR + +# N agnostic model for symbolic parameters to consider worst case average user +drawnShares = Int('drawnSharesPerUser') +riskPremium = Int('riskPremiumPerUser') +N = Int('numberOfUsers') + +s = Solver() + +s.add(1 <= N) +s.add(0 <= drawnShares, drawnShares <= 10 ** 30) +s.add(0 <= riskPremium, riskPremium <= MAX_COLLATERAL_RISK) + +totalDrawn = N * drawnShares +premiumShares = percentMulUp(drawnShares, riskPremium) +s.add(Not(N * premiumShares <= percentMulUp(totalDrawn, RISK_PREMIUM_THRESHOLD))) + +print(s.model() if s.check() == sat else 'no counterexample') diff --git a/tests/misc/risk_premium_weighted_average_bound.py b/tests/misc/risk_premium_weighted_average_bound.py new file mode 100644 index 000000000..086bb5432 --- /dev/null +++ b/tests/misc/risk_premium_weighted_average_bound.py @@ -0,0 +1,28 @@ +# Proves the maximum risk premium for a user computed by a spoke is bounded to MAX_ALLOWED_COLLATERAL_RISK +# divUp(sum(percentMulUp(w_i, rp_i)), sum(w_i)) <= rp_max when rp_i <= rp_max for all i. +from z3 import * + +MAX_RP = IntVal(1000_00) # MAX_ALLOWED_COLLATERAL_RISK +PERCENTAGE_FACTOR = IntVal(100_00) + +def divUp(numerator, denominator): + return (numerator + denominator - 1) / denominator + +def percentMulUp(value, percentage): + return (value * percentage + PERCENTAGE_FACTOR - 1) / PERCENTAGE_FACTOR + +s = Solver() + +# N-agnostic: represent sum(percentMulUp(w_i, rp_i)) as numerator, sum(w_i) as denominator +weightedSum = Int('weightedSum') +sumOfWeights = Int('sumOfWeights') + +s.add(sumOfWeights >= 1) +s.add(weightedSum >= 0) +# rp_i <= rp_max +# implies; percentMulUp(w_i * rp_i) <= percentMulUp(w_i * rp_max) +# implies; sum(percentMulUp(w_i * rp_i)) <= sum(percentMulUp(w_i * rp_max)) <= percentMulUp(sum(w_i) * rp_max) <= sum(w_i) * rp_max +s.add(weightedSum <= percentMulUp(sumOfWeights, MAX_RP)) + +s.add(Not(divUp(weightedSum, sumOfWeights) <= MAX_RP)) +print(s.model() if s.check() == sat else 'no counterexample') diff --git a/tests/misc/z3/commons.py b/tests/misc/z3/commons.py new file mode 100644 index 000000000..a2617772d --- /dev/null +++ b/tests/misc/z3/commons.py @@ -0,0 +1,143 @@ +from z3 import * + +WAD = IntVal(10**18) +RAY = IntVal(10**27) +PERCENTAGE_FACTOR = IntVal(10**4) + +VIRTUAL_SHARES = IntVal(10**6) +VIRTUAL_ASSETS = IntVal(10**6) + +MAX_PRICE = IntVal(10**16) +MAX_SUPPLY_AMOUNT = IntVal(10**30) + +MIN_DECIMALS = IntVal(6) +MAX_DECIMALS = IntVal(18) + +MIN_DRAWN_INDEX = RAY +MAX_DRAWN_INDEX = 100 * RAY +MAX_SUPPLY_PRICE = IntVal(100) + +MIN_LIQUIDATION_BONUS = PERCENTAGE_FACTOR +MAX_LIQUIDATION_BONUS = PERCENTAGE_FACTOR * PERCENTAGE_FACTOR - 1 +DUST_LIQUIDATION_THRESHOLD = IntVal(1000 * 10**26) + + +def mulDivDown(a, num, den): + return (a * num) / den + + +def mulDivUp(a, num, den): + return (a * num + den - 1) / den + + +def divUp(a, b): + return (a + b - 1) / b + + +def rayMulUp(a, b): + return (a * b + RAY - 1) / RAY + + +def rayMulDown(a, b): + return (a * b) / RAY + + +def fromRayDown(a): + return a / RAY + + +def fromRayUp(a): + return (a + RAY - 1) / RAY + + +def toRay(a): + return a * RAY + +def min(a, b): + return If(a <= b, a, b) + +def zeroFloorSub(a, b): + return If(a > b, a - b, 0) + +def toAddedSharesDown(assets, totalAddedAssets, addedShares): + return mulDivDown( + assets, addedShares + VIRTUAL_SHARES, totalAddedAssets + VIRTUAL_ASSETS + ) + + +def toAddedAssetsDown(shares, totalAddedAssets, addedShares): + return mulDivDown( + shares, totalAddedAssets + VIRTUAL_ASSETS, addedShares + VIRTUAL_SHARES + ) + + +def toAddedSharesUp(assets, totalAddedAssets, addedShares): + return mulDivUp( + assets, addedShares + VIRTUAL_SHARES, totalAddedAssets + VIRTUAL_ASSETS + ) + + +def toAddedAssetsUp(shares, totalAddedAssets, addedShares): + return mulDivUp( + shares, totalAddedAssets + VIRTUAL_ASSETS, addedShares + VIRTUAL_SHARES + ) + + +def previewAddByAssets(assets, totalAddedAssets, addedShares): + return toAddedSharesDown(assets, totalAddedAssets, addedShares) + + +def previewAddByShares(shares, totalAddedAssets, addedShares): + return toAddedAssetsUp(shares, totalAddedAssets, addedShares) + + +def previewRemoveByAssets(assets, totalAddedAssets, addedShares): + return toAddedSharesUp(assets, totalAddedAssets, addedShares) + + +def previewRemoveByShares(shares, totalAddedAssets, addedShares): + return toAddedAssetsDown(shares, totalAddedAssets, addedShares) + + +# Assumes the asset uses at most 18 decimals. +def toValue(amount, decimals, price): + return amount * (10 ** (18 - decimals)) * price + + +def proveValid(s, propertyDescription, property, assumptions=[], variables=[]): + propertyDescriptionOutput = f"-- VALID Property: {propertyDescription} --" + print("=" * len(propertyDescriptionOutput)) + print(propertyDescriptionOutput) + + result = s.check(Not(property), *assumptions) + if result == sat: + print("❌ Property is not valid:") + print(s.model()) + for variable, variableName in variables: + print(f"{variableName}: {s.model().eval(variable)}") + elif result == unsat: + print(f"✅ Property is valid.") + elif result == unknown: + print("❓ Timed out or unknown.") + + print("=" * len(propertyDescriptionOutput)) + + +def proveSatisfiable(s, propertyDescription, property, assumptions=[], variables=[]): + propertyDescriptionOutput = f"-- SATISFIABLE Property: {propertyDescription} --" + print("=" * len(propertyDescriptionOutput)) + print(propertyDescriptionOutput) + + result = s.check(property, *assumptions) + if result == sat: + print("✅ Property is satisfiable") + m = s.model() + print(m) + for variable, variableName in variables: + print(f"{variableName}: {m.eval(variable)}") + elif result == unsat: + print("❌ Property is unsatisfiable.") + elif result == unknown: + print("❓ Timed out or unknown.") + + print("=" * len(propertyDescriptionOutput)) diff --git a/tests/misc/z3/debt_to_liquidate.py b/tests/misc/z3/debt_to_liquidate.py new file mode 100644 index 000000000..5b5583dd8 --- /dev/null +++ b/tests/misc/z3/debt_to_liquidate.py @@ -0,0 +1,42 @@ +# Highlights the fact that debtToCover is enforced correctly when premiumDebtRayToLiquidate is calculated. +from commons import * + +s = Solver() + +debtToCover = Int("debtToCover") +s.add(0 <= debtToCover, debtToCover <= MAX_SUPPLY_AMOUNT) + +rawPremiumDebtRayToLiquidate = Int("rawPremiumDebtRayToLiquidate") +s.add( + 0 <= rawPremiumDebtRayToLiquidate, rawPremiumDebtRayToLiquidate <= MAX_SUPPLY_AMOUNT +) + +expectedPremiumDebtRayToLiquidate = If( + toRay(debtToCover) < rawPremiumDebtRayToLiquidate, + toRay(debtToCover), + rawPremiumDebtRayToLiquidate, +) + +actualPremiumDebtRayToLiquidate = If( + debtToCover <= fromRayDown(rawPremiumDebtRayToLiquidate), + toRay(debtToCover), + rawPremiumDebtRayToLiquidate, +) + +proveValid( + s, + "debtToCover is enforced correctly when premiumDebtRayToLiquidate is calculated", + actualPremiumDebtRayToLiquidate == expectedPremiumDebtRayToLiquidate, +) + +actualPremiumDebtRayToLiquidate2 = If( + debtToCover < fromRayUp(rawPremiumDebtRayToLiquidate), + toRay(debtToCover), + rawPremiumDebtRayToLiquidate, +) + +proveValid( + s, + "debtToCover is enforced correctly when premiumDebtRayToLiquidate is calculated", + actualPremiumDebtRayToLiquidate2 == expectedPremiumDebtRayToLiquidate, +) diff --git a/tests/misc/z3/liquidation_logic.py b/tests/misc/z3/liquidation_logic.py new file mode 100644 index 000000000..f72171e0a --- /dev/null +++ b/tests/misc/z3/liquidation_logic.py @@ -0,0 +1,147 @@ +# Highlights the fact that debtToLiquidate cannot exceed debtReserveBalance in liquidation logic. +from commons import * + +s = Solver() + +# Pricing of collateral asset +addedShares = Int("addedShares") +s.add(0 <= addedShares, addedShares <= MAX_SUPPLY_AMOUNT) +totalAddedAssets = Int("totalAddedAssets") +s.add( + (addedShares + VIRTUAL_SHARES) <= (totalAddedAssets + VIRTUAL_ASSETS), + (totalAddedAssets + VIRTUAL_ASSETS) + <= MAX_SUPPLY_PRICE * (addedShares + VIRTUAL_SHARES), +) +collateralAssetPrice = Int("collateralAssetPrice") +s.add(1 <= collateralAssetPrice, collateralAssetPrice <= MAX_PRICE) +collateralAssetDecimals = Int("collateralAssetDecimals") +s.add(MIN_DECIMALS <= collateralAssetDecimals, collateralAssetDecimals <= MAX_DECIMALS) +collateralAssetUnit = ToInt(10**collateralAssetDecimals) + +# Pricing of debt asset +drawnIndex = Int("drawnIndex") +s.add(MIN_DRAWN_INDEX <= drawnIndex, drawnIndex <= MAX_DRAWN_INDEX) +debtAssetPrice = Int("debtAssetPrice") +s.add(1 <= debtAssetPrice, debtAssetPrice <= MAX_PRICE) +debtAssetDecimals = Int("debtAssetDecimals") +s.add(MIN_DECIMALS <= debtAssetDecimals, debtAssetDecimals <= MAX_DECIMALS) +debtAssetUnit = ToInt(10**debtAssetDecimals) + +# Liquidatable user position +suppliedShares = Int("suppliedShares") +s.add(1 <= suppliedShares, suppliedShares <= addedShares) +drawnShares = Int("drawnShares") +s.add(1 <= drawnShares, drawnShares <= MAX_SUPPLY_AMOUNT) +premiumDebtRay = Int("premiumDebtRay") +s.add(0 <= premiumDebtRay, premiumDebtRay <= MAX_SUPPLY_AMOUNT) + +# Liquidation parameters +liquidationBonus = Int("liquidationBonus") +s.add( + MIN_LIQUIDATION_BONUS <= liquidationBonus, + liquidationBonus <= MAX_LIQUIDATION_BONUS, +) +premiumDebtRayToLiquidate = Int("premiumDebtRayToLiquidate") +s.add(0 <= premiumDebtRayToLiquidate, premiumDebtRayToLiquidate <= premiumDebtRay) +rawDrawnSharesToLiquidate = Int("rawDrawnSharesToLiquidate") +s.add(0 <= rawDrawnSharesToLiquidate, rawDrawnSharesToLiquidate <= drawnShares) +s.add(Or(rawDrawnSharesToLiquidate == 0, premiumDebtRayToLiquidate == premiumDebtRay)) + +# Enforce debt dust condition +debtRayRemaining = ( + (drawnShares - rawDrawnSharesToLiquidate) * drawnIndex + + premiumDebtRay + - premiumDebtRayToLiquidate +) +leavesDebtDust = And( + rawDrawnSharesToLiquidate < drawnShares, + toValue( + debtRayRemaining, + debtAssetDecimals, + debtAssetPrice, + ) + < DUST_LIQUIDATION_THRESHOLD * RAY, +) +drawnSharesToLiquidate = Int("drawnSharesToLiquidate") +s.add( + Or( + And(Not(leavesDebtDust), drawnSharesToLiquidate == rawDrawnSharesToLiquidate), + And( + leavesDebtDust, + drawnSharesToLiquidate == drawnShares, + premiumDebtRayToLiquidate == premiumDebtRay, + ), + ) +) + +# Calculate collateral shares to liquidate +collateralSharesToLiquidate = previewAddByAssets( + mulDivDown( + drawnSharesToLiquidate * drawnIndex + premiumDebtRayToLiquidate, + debtAssetPrice * collateralAssetUnit * liquidationBonus, + debtAssetUnit * collateralAssetPrice * PERCENTAGE_FACTOR * RAY, + ), + totalAddedAssets, + addedShares, +) + +# Enforce recalculation of debt to liquidate +leavesCollateralDust = And( + collateralSharesToLiquidate < suppliedShares, + toValue( + previewRemoveByShares( + suppliedShares - collateralSharesToLiquidate, + totalAddedAssets, + addedShares, + ), + collateralAssetDecimals, + collateralAssetPrice, + ) + < DUST_LIQUIDATION_THRESHOLD, +) +s.add( + Or( + collateralSharesToLiquidate > suppliedShares, + And( + leavesCollateralDust, + drawnSharesToLiquidate < drawnShares, + ), + ), +) + +# Recalculate debt to liquidate +debtRayToLiquidate = mulDivUp( + previewAddByShares(suppliedShares, totalAddedAssets, addedShares), + collateralAssetPrice * debtAssetUnit * PERCENTAGE_FACTOR * RAY, + debtAssetPrice * collateralAssetUnit * liquidationBonus, +) + +# Enforce premium debt is fully liquidated +s.add(premiumDebtRay < debtRayToLiquidate) +recalculatedDrawnSharesToLiquidate = divUp( + debtRayToLiquidate - premiumDebtRay, drawnIndex +) + +proveSatisfiable( + s, + "Recalculated drawnSharesToLiquidate can exceed user's drawn shares", + recalculatedDrawnSharesToLiquidate > drawnShares, +) + +# Enforce recalculation of collateralSharesToLiquidate +s.add(recalculatedDrawnSharesToLiquidate > drawnShares) +recalculatedCollateralSharesToLiquidate = previewAddByAssets( + mulDivDown( + drawnShares * drawnIndex + premiumDebtRay, + debtAssetPrice * collateralAssetUnit * liquidationBonus, + debtAssetUnit * collateralAssetPrice * PERCENTAGE_FACTOR * RAY, + ), + totalAddedAssets, + addedShares, +) + +proveSatisfiable( + s, + "Recalculated collateralSharesToLiquidate can exceed user's supplied shares", + recalculatedCollateralSharesToLiquidate > suppliedShares, +) diff --git a/tests/misc/z3/max_deposit_property.py b/tests/misc/z3/max_deposit_property.py new file mode 100644 index 000000000..9fdf6afdb --- /dev/null +++ b/tests/misc/z3/max_deposit_property.py @@ -0,0 +1,23 @@ +# Proves maxDeposit rounding: deposit(maxDeposit()) must satisfy Hub._validateAdd. +# _validateAdd checks: allowed >= toAddedAssetsUp(spokeShares) + depositAmount +from commons import * + +totalAddedAssets = Int("totalAddedAssets") +totalAddedShares = Int("totalAddedShares") +spokeShares = Int("spokeShares") +allowed = Int("allowed") + +s = Solver() +s.add(0 <= totalAddedAssets, totalAddedAssets <= 10**30) +s.add(0 <= totalAddedShares, totalAddedShares <= 10**30) +s.add(0 <= spokeShares, spokeShares <= totalAddedShares) +s.add(0 < allowed, allowed <= 10**30) + +balance = toAddedAssetsUp(spokeShares, totalAddedAssets, totalAddedShares) +depositAmount = zeroFloorSub(allowed, balance) +hubCheck = ( + toAddedAssetsUp(spokeShares, totalAddedAssets, totalAddedShares) + depositAmount +) +s.add(depositAmount > 0) + +proveValid(s, "deposit(maxDeposit()) satisfies _validateAdd", allowed >= hubCheck) diff --git a/tests/misc/z3/max_mint_property.py b/tests/misc/z3/max_mint_property.py new file mode 100644 index 000000000..eaf816971 --- /dev/null +++ b/tests/misc/z3/max_mint_property.py @@ -0,0 +1,38 @@ +# Proves that mint(maxMint()) satisfies Hub._validateAdd. +# maxMint = convertToShares(maxDeposit) = toAddedSharesDown(maxDeposit) +# When mint is called: assets = previewMint(maxMint) = toAddedAssetsUp(maxMint) +# _validateAdd checks: allowed >= toAddedAssetsUp(spokeShares) + assets +from commons import * + +def previewMint(shares, totalAddedAssets, totalAddedShares): + """Converts shares to assets, rounding up (previewAddByShares)""" + return previewAddByShares(shares, totalAddedAssets, totalAddedShares) + +def convertToShares(assets, totalAddedAssets, totalAddedShares): + """Converts assets to shares, rounding down (previewAddByAssets)""" + return previewAddByAssets(assets, totalAddedAssets, totalAddedShares) + +totalAddedAssets = Int("totalAddedAssets") +totalAddedShares = Int("totalAddedShares") +spokeShares = Int("spokeShares") +allowed = Int("allowed") + +s = Solver() +s.add(0 <= totalAddedAssets, totalAddedAssets <= 10**30) +s.add(0 <= totalAddedShares, totalAddedShares <= 10**30) +s.add(0 <= spokeShares, spokeShares <= totalAddedShares) +s.add(0 < allowed, allowed <= 10**30) + +# maxDeposit +balance = previewMint(spokeShares, totalAddedAssets, totalAddedShares) +maxDepositAmount = zeroFloorSub(allowed, balance) + +# maxMint = convertToShares(maxDeposit) +maxMintShares = convertToShares(maxDepositAmount, totalAddedAssets, totalAddedShares) + +# _validateAdd: allowed >= toAddedAssetsUp(spokeShares) + mintAssets +mintAssets = toAddedAssetsUp(maxMintShares, totalAddedAssets, totalAddedShares) +s.add(mintAssets > 0) +hubCheck = toAddedAssetsUp(spokeShares, totalAddedAssets, totalAddedShares) + mintAssets + +proveValid(s, "mint(maxMint()) satisfies _validateAdd", allowed >= hubCheck) diff --git a/tests/misc/z3/max_redeem_property.py b/tests/misc/z3/max_redeem_property.py new file mode 100644 index 000000000..65af1e90b --- /dev/null +++ b/tests/misc/z3/max_redeem_property.py @@ -0,0 +1,43 @@ +# Proves that in maxRedeem, we never have balance.toAssets > _maxRemovableAssets() +# where balance is the result from maxRedeem (balance.min(maxRemovableShares)) +# Specifically: previewRedeem(result) <= _maxRemovableAssets() +# +# Also proves redeem(maxRedeem()) is OK: +# toAddedSharesUp(previewRedeem(result)) <= balance — Hub.remove share deduction doesn't exceed spoke shares +from commons import * + +def previewRedeem(shares, totalAddedAssets, totalAddedShares): + """Converts shares to assets, rounding down (previewRemoveByShares)""" + return previewRemoveByShares(shares, totalAddedAssets, totalAddedShares) + +def convertToShares(assets, totalAddedAssets, totalAddedShares): + """Converts assets to shares, rounding down (previewAddByAssets)""" + return previewAddByAssets(assets, totalAddedAssets, totalAddedShares) + +s = Solver() + +totalAddedAssets = Int("totalAddedAssets") +totalAddedShares = Int("totalAddedShares") +maxRemovableAssets = Int("maxRemovableAssets") +balance = Int("balance") # balanceOf(owner) in shares + +s.add(0 <= totalAddedAssets, totalAddedAssets <= 10**30) +s.add(0 <= totalAddedShares, totalAddedShares <= 10**30) +s.add(0 <= maxRemovableAssets, maxRemovableAssets <= 10**30) +s.add(0 <= balance, balance <= 10**30) +# maxRemovableAssets is just liquidity, which is part of totalAddedAssets +s.add(maxRemovableAssets <= totalAddedAssets) + +maxRemovableShares = convertToShares( + maxRemovableAssets, totalAddedAssets, totalAddedShares +) + +result = min(balance, maxRemovableShares) +resultAssets = previewRedeem(result, totalAddedAssets, totalAddedShares) +hubRemoveShares = toAddedSharesUp(resultAssets, totalAddedAssets, totalAddedShares) + +# redeemed assets don't exceed liquidity +proveValid(s, "previewRedeem(balance.min(maxRemovableShares)) <= _maxRemovableAssets()", resultAssets <= maxRemovableAssets) + +# redeem(maxRedeem()) — Hub.remove share deduction doesn't exceed spoke shares +proveValid(s, "toAddedSharesUp(previewRedeem(maxRedeem())) <= balance", hubRemoveShares <= balance) diff --git a/tests/misc/z3/max_withdraw_property.py b/tests/misc/z3/max_withdraw_property.py new file mode 100644 index 000000000..7ed6c6737 --- /dev/null +++ b/tests/misc/z3/max_withdraw_property.py @@ -0,0 +1,39 @@ +# Proves that in maxWithdraw, we never have result > _maxRemovableAssets() +# where result = balance.min(maxRemovableAssets) and balance = previewRedeem(balanceOf(owner)) +# Specifically: result <= _maxRemovableAssets() +# +# Also proves withdraw(maxWithdraw()) is OK: +# previewWithdraw(result) <= balanceShares — shares burned don't exceed owner's balance +from commons import * + +def previewRedeem(shares, totalAddedAssets, totalAddedShares): + """Converts shares to assets, rounding down (previewRemoveByShares)""" + return previewRemoveByShares(shares, totalAddedAssets, totalAddedShares) + +def previewWithdraw(assets, totalAddedAssets, totalAddedShares): + """Converts assets to shares, rounding up (previewRemoveByAssets)""" + return previewRemoveByAssets(assets, totalAddedAssets, totalAddedShares) + +s = Solver() + +totalAddedAssets = Int("totalAddedAssets") +totalAddedShares = Int("totalAddedShares") +maxRemovableAssets = Int("maxRemovableAssets") +balanceShares = Int("balanceShares") # balanceOf(owner) in shares + +balanceAssets = previewRedeem(balanceShares, totalAddedAssets, totalAddedShares) +result = min(balanceAssets, maxRemovableAssets) +sharesBurned = previewWithdraw(result, totalAddedAssets, totalAddedShares) + +s.add(0 <= totalAddedAssets, totalAddedAssets <= 10**30) +s.add(0 <= totalAddedShares, totalAddedShares <= 10**30) +s.add(0 <= maxRemovableAssets, maxRemovableAssets <= 10**30) +s.add(0 <= balanceShares, balanceShares <= 10**30) +# maxRemovableAssets is just liquidity, which is part of totalAddedAssets +s.add(maxRemovableAssets <= totalAddedAssets) + +# maxWithdraw result does not exceed liquidity +proveValid(s, "min(previewRedeem(balanceShares), maxRemovableAssets) <= _maxRemovableAssets()", result <= maxRemovableAssets) + +# withdraw(maxWithdraw()) — shares burned don't exceed owner's balance +proveValid(s, "previewWithdraw(maxWithdraw()) <= balanceShares", sharesBurned <= balanceShares) diff --git a/tests/mocks/DeployWrapper.sol b/tests/mocks/DeployWrapper.sol new file mode 100644 index 000000000..49b3b673b --- /dev/null +++ b/tests/mocks/DeployWrapper.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {DeployUtils} from 'tests/DeployUtils.sol'; + +contract DeployWrapper { + function deploySpokeImplementation( + address oracle, + uint16 maxUserReservesLimit + ) external returns (address) { + return address(DeployUtils.deploySpokeImplementation(oracle, maxUserReservesLimit, '')); + } + + function deploySpoke( + address oracle, + uint16 maxUserReservesLimit, + address proxyAdminOwner, + bytes calldata initData + ) external returns (address) { + return + address(DeployUtils.deploySpoke(oracle, maxUserReservesLimit, proxyAdminOwner, initData)); + } + + function deployHub(address authority) external returns (address) { + return address(DeployUtils.deployHub(authority)); + } +} diff --git a/src/libraries/types/EIP712Types.sol b/tests/mocks/EIP712Types.sol similarity index 60% rename from src/libraries/types/EIP712Types.sol rename to tests/mocks/EIP712Types.sol index e647206a0..5b94a1605 100644 --- a/src/libraries/types/EIP712Types.sol +++ b/tests/mocks/EIP712Types.sol @@ -5,15 +5,21 @@ pragma solidity ^0.8.20; /// @title EIP712Types library /// @author Aave Labs /// @notice Defines type structs used in EIP712-typed signatures. +/// @dev Consolidated types to generate JsonBindings.sol using `forge bind-json` for vm.eip712* cheat-codes. library EIP712Types { - struct SetUserPositionManager { - address positionManager; - address user; - bool approve; + /// @dev Spoke Intents + struct SetUserPositionManagers { + address onBehalfOf; + PositionManagerUpdate[] updates; uint256 nonce; uint256 deadline; } + struct PositionManagerUpdate { + address positionManager; + bool approve; + } + struct Permit { address owner; address spender; @@ -22,6 +28,7 @@ library EIP712Types { uint256 deadline; } + /// @dev SignatureGateway Intents struct Supply { address spoke; uint256 reserveId; @@ -69,14 +76,47 @@ library EIP712Types { struct UpdateUserRiskPremium { address spoke; - address user; + address onBehalfOf; uint256 nonce; uint256 deadline; } struct UpdateUserDynamicConfig { address spoke; - address user; + address onBehalfOf; + uint256 nonce; + uint256 deadline; + } + + /// @dev TokenizationSpoke Intents + struct TokenizedDeposit { + address depositor; + uint256 assets; + address receiver; + uint256 nonce; + uint256 deadline; + } + + struct TokenizedMint { + address depositor; + uint256 shares; + address receiver; + uint256 nonce; + uint256 deadline; + } + + struct TokenizedWithdraw { + address owner; + uint256 assets; + address receiver; + uint256 nonce; + uint256 deadline; + } + + struct TokenizedRedeem { + address owner; + uint256 shares; + address receiver; uint256 nonce; uint256 deadline; } diff --git a/tests/mocks/ExtSloadWrapper.sol b/tests/mocks/ExtSloadWrapper.sol new file mode 100644 index 000000000..85adeb9a7 --- /dev/null +++ b/tests/mocks/ExtSloadWrapper.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity 0.8.28; + +import {ExtSload} from 'src/utils/ExtSload.sol'; + +contract ExtSloadWrapper is ExtSload {} diff --git a/tests/mocks/ISpokeInstance.sol b/tests/mocks/ISpokeInstance.sol new file mode 100644 index 000000000..2047d66dd --- /dev/null +++ b/tests/mocks/ISpokeInstance.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.20; + +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +interface ISpokeInstance is ISpoke { + function initialize(address _authority) external; + + function SPOKE_REVISION() external view returns (uint64); +} diff --git a/tests/mocks/JsonBindings.sol b/tests/mocks/JsonBindings.sol index fad61c114..6919c2c62 100644 --- a/tests/mocks/JsonBindings.sol +++ b/tests/mocks/JsonBindings.sol @@ -1,8 +1,9 @@ // Automatically generated by forge bind-json. + pragma solidity >=0.6.2 <0.9.0; pragma experimental ABIEncoderV2; -import {EIP712Types} from 'src/libraries/types/EIP712Types.sol'; +import {EIP712Types} from 'tests/mocks/EIP712Types.sol'; interface Vm { function parseJsonTypeArray( @@ -39,7 +40,9 @@ library JsonBindings { Vm constant vm = Vm(address(uint160(uint256(keccak256('hevm cheat code'))))); // prettier-ignore - string constant schema_SetUserPositionManager = "SetUserPositionManager(address positionManager,address user,bool approve,uint256 nonce,uint256 deadline)"; + string constant schema_SetUserPositionManagers = "SetUserPositionManagers(address onBehalfOf,PositionManagerUpdate[] updates,uint256 nonce,uint256 deadline)PositionManagerUpdate(address positionManager,bool approve)"; + // prettier-ignore + string constant schema_PositionManagerUpdate = "PositionManagerUpdate(address positionManager,bool approve)"; // prettier-ignore string constant schema_Permit = "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"; // prettier-ignore @@ -53,54 +56,62 @@ library JsonBindings { // prettier-ignore string constant schema_SetUsingAsCollateral = "SetUsingAsCollateral(address spoke,uint256 reserveId,bool useAsCollateral,address onBehalfOf,uint256 nonce,uint256 deadline)"; // prettier-ignore - string constant schema_UpdateUserRiskPremium = "UpdateUserRiskPremium(address spoke,address user,uint256 nonce,uint256 deadline)"; + string constant schema_UpdateUserRiskPremium = "UpdateUserRiskPremium(address spoke,address onBehalfOf,uint256 nonce,uint256 deadline)"; + // prettier-ignore + string constant schema_UpdateUserDynamicConfig = "UpdateUserDynamicConfig(address spoke,address onBehalfOf,uint256 nonce,uint256 deadline)"; + // prettier-ignore + string constant schema_TokenizedDeposit = "TokenizedDeposit(address depositor,uint256 assets,address receiver,uint256 nonce,uint256 deadline)"; // prettier-ignore - string constant schema_UpdateUserDynamicConfig = "UpdateUserDynamicConfig(address spoke,address user,uint256 nonce,uint256 deadline)"; + string constant schema_TokenizedMint = "TokenizedMint(address depositor,uint256 shares,address receiver,uint256 nonce,uint256 deadline)"; + // prettier-ignore + string constant schema_TokenizedWithdraw = "TokenizedWithdraw(address owner,uint256 assets,address receiver,uint256 nonce,uint256 deadline)"; + // prettier-ignore + string constant schema_TokenizedRedeem = "TokenizedRedeem(address owner,uint256 shares,address receiver,uint256 nonce,uint256 deadline)"; function serialize( - EIP712Types.SetUserPositionManager memory value + EIP712Types.SetUserPositionManagers memory value ) internal pure returns (string memory) { - return vm.serializeJsonType(schema_SetUserPositionManager, abi.encode(value)); + return vm.serializeJsonType(schema_SetUserPositionManagers, abi.encode(value)); } function serialize( - EIP712Types.SetUserPositionManager memory value, + EIP712Types.SetUserPositionManagers memory value, string memory objectKey, string memory valueKey ) internal returns (string memory) { return - vm.serializeJsonType(objectKey, valueKey, schema_SetUserPositionManager, abi.encode(value)); + vm.serializeJsonType(objectKey, valueKey, schema_SetUserPositionManagers, abi.encode(value)); } function deserializeSetUserPositionManager( string memory json - ) public pure returns (EIP712Types.SetUserPositionManager memory) { + ) public pure returns (EIP712Types.SetUserPositionManagers memory) { return abi.decode( - vm.parseJsonType(json, schema_SetUserPositionManager), - (EIP712Types.SetUserPositionManager) + vm.parseJsonType(json, schema_SetUserPositionManagers), + (EIP712Types.SetUserPositionManagers) ); } function deserializeSetUserPositionManager( string memory json, string memory path - ) public pure returns (EIP712Types.SetUserPositionManager memory) { + ) public pure returns (EIP712Types.SetUserPositionManagers memory) { return abi.decode( - vm.parseJsonType(json, path, schema_SetUserPositionManager), - (EIP712Types.SetUserPositionManager) + vm.parseJsonType(json, path, schema_SetUserPositionManagers), + (EIP712Types.SetUserPositionManagers) ); } function deserializeSetUserPositionManagerArray( string memory json, string memory path - ) public pure returns (EIP712Types.SetUserPositionManager[] memory) { + ) public pure returns (EIP712Types.SetUserPositionManagers[] memory) { return abi.decode( - vm.parseJsonTypeArray(json, path, schema_SetUserPositionManager), - (EIP712Types.SetUserPositionManager[]) + vm.parseJsonTypeArray(json, path, schema_SetUserPositionManagers), + (EIP712Types.SetUserPositionManagers[]) ); } @@ -396,4 +407,170 @@ library JsonBindings { (EIP712Types.UpdateUserDynamicConfig[]) ); } + + function serialize( + EIP712Types.TokenizedDeposit memory value + ) internal pure returns (string memory) { + return vm.serializeJsonType(schema_TokenizedDeposit, abi.encode(value)); + } + + function serialize( + EIP712Types.TokenizedDeposit memory value, + string memory objectKey, + string memory valueKey + ) internal returns (string memory) { + return vm.serializeJsonType(objectKey, valueKey, schema_TokenizedDeposit, abi.encode(value)); + } + + function deserializeTokenizedDeposit( + string memory json + ) public pure returns (EIP712Types.TokenizedDeposit memory) { + return + abi.decode(vm.parseJsonType(json, schema_TokenizedDeposit), (EIP712Types.TokenizedDeposit)); + } + + function deserializeTokenizedDeposit( + string memory json, + string memory path + ) public pure returns (EIP712Types.TokenizedDeposit memory) { + return + abi.decode( + vm.parseJsonType(json, path, schema_TokenizedDeposit), + (EIP712Types.TokenizedDeposit) + ); + } + + function deserializeTokenizedDepositArray( + string memory json, + string memory path + ) public pure returns (EIP712Types.TokenizedDeposit[] memory) { + return + abi.decode( + vm.parseJsonTypeArray(json, path, schema_TokenizedDeposit), + (EIP712Types.TokenizedDeposit[]) + ); + } + + function serialize(EIP712Types.TokenizedMint memory value) internal pure returns (string memory) { + return vm.serializeJsonType(schema_TokenizedMint, abi.encode(value)); + } + + function serialize( + EIP712Types.TokenizedMint memory value, + string memory objectKey, + string memory valueKey + ) internal returns (string memory) { + return vm.serializeJsonType(objectKey, valueKey, schema_TokenizedMint, abi.encode(value)); + } + + function deserializeTokenizedMint( + string memory json + ) public pure returns (EIP712Types.TokenizedMint memory) { + return abi.decode(vm.parseJsonType(json, schema_TokenizedMint), (EIP712Types.TokenizedMint)); + } + + function deserializeTokenizedMint( + string memory json, + string memory path + ) public pure returns (EIP712Types.TokenizedMint memory) { + return + abi.decode(vm.parseJsonType(json, path, schema_TokenizedMint), (EIP712Types.TokenizedMint)); + } + + function deserializeTokenizedMintArray( + string memory json, + string memory path + ) public pure returns (EIP712Types.TokenizedMint[] memory) { + return + abi.decode( + vm.parseJsonTypeArray(json, path, schema_TokenizedMint), + (EIP712Types.TokenizedMint[]) + ); + } + + function serialize( + EIP712Types.TokenizedWithdraw memory value + ) internal pure returns (string memory) { + return vm.serializeJsonType(schema_TokenizedWithdraw, abi.encode(value)); + } + + function serialize( + EIP712Types.TokenizedWithdraw memory value, + string memory objectKey, + string memory valueKey + ) internal returns (string memory) { + return vm.serializeJsonType(objectKey, valueKey, schema_TokenizedWithdraw, abi.encode(value)); + } + + function deserializeTokenizedWithdraw( + string memory json + ) public pure returns (EIP712Types.TokenizedWithdraw memory) { + return + abi.decode(vm.parseJsonType(json, schema_TokenizedWithdraw), (EIP712Types.TokenizedWithdraw)); + } + + function deserializeTokenizedWithdraw( + string memory json, + string memory path + ) public pure returns (EIP712Types.TokenizedWithdraw memory) { + return + abi.decode( + vm.parseJsonType(json, path, schema_TokenizedWithdraw), + (EIP712Types.TokenizedWithdraw) + ); + } + + function deserializeTokenizedWithdrawArray( + string memory json, + string memory path + ) public pure returns (EIP712Types.TokenizedWithdraw[] memory) { + return + abi.decode( + vm.parseJsonTypeArray(json, path, schema_TokenizedWithdraw), + (EIP712Types.TokenizedWithdraw[]) + ); + } + + function serialize( + EIP712Types.TokenizedRedeem memory value + ) internal pure returns (string memory) { + return vm.serializeJsonType(schema_TokenizedRedeem, abi.encode(value)); + } + + function serialize( + EIP712Types.TokenizedRedeem memory value, + string memory objectKey, + string memory valueKey + ) internal returns (string memory) { + return vm.serializeJsonType(objectKey, valueKey, schema_TokenizedRedeem, abi.encode(value)); + } + + function deserializeTokenizedRedeem( + string memory json + ) public pure returns (EIP712Types.TokenizedRedeem memory) { + return + abi.decode(vm.parseJsonType(json, schema_TokenizedRedeem), (EIP712Types.TokenizedRedeem)); + } + + function deserializeTokenizedRedeem( + string memory json, + string memory path + ) public pure returns (EIP712Types.TokenizedRedeem memory) { + return + abi.decode( + vm.parseJsonType(json, path, schema_TokenizedRedeem), + (EIP712Types.TokenizedRedeem) + ); + } + + function deserializeTokenizedRedeemArray( + string memory json, + string memory path + ) public pure returns (EIP712Types.TokenizedRedeem[] memory) { + return + abi.decode( + vm.parseJsonTypeArray(json, path, schema_TokenizedRedeem), + (EIP712Types.TokenizedRedeem[]) + ); + } } diff --git a/tests/mocks/LiquidationLogicWrapper.sol b/tests/mocks/LiquidationLogicWrapper.sol index 8a765e6be..adbc0192d 100644 --- a/tests/mocks/LiquidationLogicWrapper.sol +++ b/tests/mocks/LiquidationLogicWrapper.sol @@ -17,18 +17,17 @@ contract LiquidationLogicWrapper { using PositionStatusMap for ISpoke.PositionStatus; using ReserveFlagsMap for ReserveFlags; + mapping(uint256 reserveId => ISpoke.Reserve) internal _reserves; mapping(address user => mapping(uint256 reserveId => ISpoke.UserPosition)) internal _userPositions; - mapping(uint256 reserveId => ISpoke.Reserve) internal _reserves; mapping(address user => ISpoke.PositionStatus) internal _positionStatuses; + mapping(uint256 reserveId => mapping(uint32 dynamicConfigKey => ISpoke.DynamicReserveConfig)) + internal _dynamicConfig; address internal _borrower; address internal _liquidator; uint256 internal _collateralReserveId; uint256 internal _debtReserveId; - ISpoke.LiquidationConfig internal liquidationConfig; - ISpoke.DynamicReserveConfig internal dynamicCollateralConfig; - constructor(address borrower_, address liquidator_) { _borrower = borrower_; _liquidator = liquidator_; @@ -42,6 +41,10 @@ contract LiquidationLogicWrapper { _liquidator = liquidator; } + function setCollateralReserveId(uint256 reserveId) public { + _collateralReserveId = reserveId; + } + function setCollateralReserveHub(IHub hub) public { _reserves[_collateralReserveId].hub = hub; } @@ -54,30 +57,31 @@ contract LiquidationLogicWrapper { _reserves[_collateralReserveId].assetId = assetId.toUint16(); } - function setCollateralReserveId(uint256 reserveId) public { - _collateralReserveId = reserveId; + function setCollateralReserveFlags(ReserveFlags flags) public { + _reserves[_collateralReserveId].flags = flags; } - function setCollateralLiquidatable(bool status) public { - _reserves[_collateralReserveId].flags = _reserves[_collateralReserveId].flags.setLiquidatable( - status - ); + function setDynamicCollateralConfig( + ISpoke.DynamicReserveConfig memory newDynamicCollateralConfig + ) public { + uint32 dynamicConfigKey = _userPositions[_borrower][_collateralReserveId].dynamicConfigKey; + _dynamicConfig[_collateralReserveId][dynamicConfigKey] = newDynamicCollateralConfig; } function setCollateralPositionSuppliedShares(uint256 suppliedShares) public { _userPositions[_borrower][_collateralReserveId].suppliedShares = suppliedShares.toUint120(); } - function setLiquidatorPositionSuppliedShares(address liquidator, uint256 suppliedShares) public { - _userPositions[liquidator][_collateralReserveId].suppliedShares = suppliedShares.toUint120(); + function setCollateralPositionDynamicConfigKey(uint256 dynamicConfigKey) public { + _userPositions[_borrower][_collateralReserveId].dynamicConfigKey = dynamicConfigKey.toUint24(); } - function getCollateralReserve() public view returns (ISpoke.Reserve memory) { - return _reserves[_collateralReserveId]; + function setLiquidatorPositionSuppliedShares(address liquidator, uint256 suppliedShares) public { + _userPositions[liquidator][_collateralReserveId].suppliedShares = suppliedShares.toUint120(); } - function getCollateralPosition(address user) public view returns (ISpoke.UserPosition memory) { - return _userPositions[user][_collateralReserveId]; + function setDebtReserveId(uint256 reserveId) public { + _debtReserveId = reserveId; } function setDebtReserveHub(IHub hub) public { @@ -92,14 +96,14 @@ contract LiquidationLogicWrapper { _reserves[_debtReserveId].assetId = assetId.toUint16(); } - function setDebtReserveId(uint256 reserveId) public { - _debtReserveId = reserveId; - } - function setDebtReserveUnderlying(address underlying) public { _reserves[_debtReserveId].underlying = underlying; } + function setDebtReserveFlags(ReserveFlags flags) public { + _reserves[_debtReserveId].flags = flags; + } + function setDebtPositionDrawnShares(uint256 drawnShares) public { _userPositions[_borrower][_debtReserveId].drawnShares = drawnShares.toUint120(); } @@ -128,6 +132,60 @@ contract LiquidationLogicWrapper { _positionStatuses[_liquidator].setBorrowing(reserveId, status); } + function liquidateCollateral( + LiquidationLogic.LiquidateCollateralParams memory params + ) public returns (LiquidationLogic.LiquidateCollateralResult memory) { + return + LiquidationLogic._liquidateCollateral( + _userPositions[_borrower][_collateralReserveId], + _userPositions[_liquidator][_collateralReserveId], + params + ); + } + + function liquidateDebt( + LiquidationLogic.LiquidateDebtParams memory params + ) public returns (LiquidationLogic.LiquidateDebtResult memory) { + return + LiquidationLogic._liquidateDebt( + _userPositions[_borrower][_debtReserveId], + _positionStatuses[_borrower], + params + ); + } + + function executeLiquidation( + LiquidationLogic.ExecuteLiquidationParams memory params + ) public returns (bool) { + return + LiquidationLogic._executeLiquidation( + _userPositions[_borrower][_collateralReserveId], + _userPositions[_borrower][_debtReserveId], + _userPositions[_liquidator][_collateralReserveId], + _positionStatuses[_borrower], + params + ); + } + + function liquidateUser(LiquidationLogic.LiquidateUserParams memory params) public returns (bool) { + return + LiquidationLogic.liquidateUser( + _reserves, + _userPositions, + _positionStatuses, + _dynamicConfig, + params + ); + } + + function getCollateralReserve() public view returns (ISpoke.Reserve memory) { + return _reserves[_collateralReserveId]; + } + + function getCollateralPosition(address user) public view returns (ISpoke.UserPosition memory) { + return _userPositions[user][_collateralReserveId]; + } + function getDebtReserve() public view returns (ISpoke.Reserve memory) { return _reserves[_debtReserveId]; } @@ -152,14 +210,10 @@ contract LiquidationLogicWrapper { return _positionStatuses[_liquidator].isBorrowing(reserveId); } - function setLiquidationConfig(ISpoke.LiquidationConfig memory newLiquidationConfig) public { - liquidationConfig = newLiquidationConfig; - } - - function setDynamicCollateralConfig( - ISpoke.DynamicReserveConfig memory newDynamicCollateralConfig - ) public { - dynamicCollateralConfig = newDynamicCollateralConfig; + function calculateLiquidationAmounts( + LiquidationLogic.CalculateLiquidationAmountsParams memory params + ) public view returns (LiquidationLogic.LiquidationAmounts memory) { + return LiquidationLogic._calculateLiquidationAmounts(params); } function calculateLiquidationBonus( @@ -191,65 +245,28 @@ contract LiquidationLogicWrapper { function calculateDebtToLiquidate( LiquidationLogic.CalculateDebtToLiquidateParams memory params - ) public pure returns (uint256) { + ) public pure returns (uint256, uint256) { return LiquidationLogic._calculateDebtToLiquidate(params); } - function calculateLiquidationAmounts( - LiquidationLogic.CalculateLiquidationAmountsParams memory params - ) public pure returns (LiquidationLogic.LiquidationAmounts memory) { - return LiquidationLogic._calculateLiquidationAmounts(params); + function calculateCollateralToLiquidate( + LiquidationLogic.CalculateCollateralToLiquidateParams memory params + ) public view returns (uint256) { + return LiquidationLogic._calculateCollateralToLiquidate(params); } function evaluateDeficit( bool isCollateralPositionEmpty, bool isDebtPositionEmpty, uint256 activeCollateralCount, - uint256 borrowedCount + uint256 borrowCount ) public pure returns (bool) { return LiquidationLogic._evaluateDeficit( isCollateralPositionEmpty, isDebtPositionEmpty, activeCollateralCount, - borrowedCount - ); - } - - function liquidateCollateral( - LiquidationLogic.LiquidateCollateralParams memory params - ) public returns (uint256, uint256, bool) { - return - LiquidationLogic._liquidateCollateral( - _reserves[_collateralReserveId], - _userPositions[_borrower][_collateralReserveId], - _userPositions[_liquidator][_collateralReserveId], - params - ); - } - - function liquidateDebt( - LiquidationLogic.LiquidateDebtParams memory params - ) public returns (uint256, IHubBase.PremiumDelta memory, bool) { - return - LiquidationLogic._liquidateDebt( - _reserves[_debtReserveId], - _userPositions[_borrower][_debtReserveId], - _positionStatuses[_borrower], - params - ); - } - - function liquidateUser(LiquidationLogic.LiquidateUserParams memory params) public returns (bool) { - return - LiquidationLogic.liquidateUser( - _reserves[_collateralReserveId], - _reserves[_debtReserveId], - _userPositions, - _positionStatuses, - liquidationConfig, - dynamicCollateralConfig, - params + borrowCount ); } } diff --git a/tests/mocks/MockReentrantCaller.sol b/tests/mocks/MockReentrantCaller.sol new file mode 100644 index 000000000..a82fba418 --- /dev/null +++ b/tests/mocks/MockReentrantCaller.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {Address} from 'src/dependencies/openzeppelin/Address.sol'; + +contract MockReentrantCaller { + using Address for address; + + address public immutable TARGET; + bytes4 public immutable TARGET_SELECTOR; + + constructor(address target, bytes4 targetSelector) { + TARGET = target; + TARGET_SELECTOR = targetSelector; + } + + fallback() external { + TARGET.functionCall(bytes.concat(TARGET_SELECTOR, new bytes(1000))); + } +} diff --git a/tests/mocks/MockSpoke.sol b/tests/mocks/MockSpoke.sol index cffe2bf3f..452f7e706 100644 --- a/tests/mocks/MockSpoke.sol +++ b/tests/mocks/MockSpoke.sol @@ -4,10 +4,12 @@ pragma solidity ^0.8.0; import {Spoke, ISpoke, IHubBase, SafeCast, PositionStatusMap} from 'src/spoke/Spoke.sol'; import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; +import {SpokeUtils} from 'src/spoke/libraries/SpokeUtils.sol'; import {Test} from 'forge-std/Test.sol'; /// @dev inherit from Test to exclude contract from forge size check contract MockSpoke is Spoke, Test { + using SpokeUtils for *; using SafeCast for *; using PositionStatusMap for *; @@ -24,7 +26,10 @@ contract MockSpoke is Spoke, Test { uint256[] accruedPremiumAmounts; } - constructor(address oracle_) Spoke(oracle_) {} + constructor( + address oracle_, + uint16 maxUserReservesLimit_ + ) Spoke(oracle_, maxUserReservesLimit_) {} function initialize(address) external override {} @@ -33,22 +38,26 @@ contract MockSpoke is Spoke, Test { uint256 reserveId, uint256 amount, address onBehalfOf - ) external onlyPositionManager(onBehalfOf) { - Reserve storage reserve = _reserves[reserveId]; + ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; - uint256 assetId = reserve.assetId; + _validateBorrow(reserve.flags); IHubBase hub = reserve.hub; - uint256 drawnShares = hub.draw(assetId, amount, msg.sender); - + uint256 drawnShares = hub.draw(reserve.assetId, amount, msg.sender); userPosition.drawnShares += drawnShares.toUint120(); - positionStatus.setBorrowing(reserveId, true); + if (!positionStatus.isBorrowing(reserveId)) { + positionStatus.setBorrowing(reserveId, true); + } - ISpoke.UserAccountData memory userAccountData = _processUserAccountData(onBehalfOf, true); - _notifyRiskPremiumUpdate(onBehalfOf, userAccountData.riskPremium); + uint256 newRiskPremium = _processUserAccountData(onBehalfOf, true).riskPremium; + emit RefreshAllUserDynamicConfig(onBehalfOf); + _notifyRiskPremiumUpdate(onBehalfOf, newRiskPremium); emit Borrow(reserveId, msg.sender, onBehalfOf, drawnShares, amount); + + return (drawnShares, amount); } // Mock the user account data @@ -64,7 +73,7 @@ contract MockSpoke is Spoke, Test { _userPositions[user][info.collateralReserveIds[i]].dynamicConfigKey = info .collateralDynamicConfigKeys[i] - .toUint16(); + .toUint32(); } for (uint256 i = 0; i < info.suppliedAssetsReserveIds.length; i++) { @@ -108,7 +117,7 @@ contract MockSpoke is Spoke, Test { return _positionStatus[user].riskPremium; } - function setReserveDynamicConfigKey(uint256 reserveId, uint24 configKey) external { + function setReserveDynamicConfigKey(uint256 reserveId, uint32 configKey) external { _reserves[reserveId].dynamicConfigKey = configKey; } } diff --git a/tests/mocks/MockSpokeInstance.sol b/tests/mocks/MockSpokeInstance.sol index 7e17614b5..0dd435d03 100644 --- a/tests/mocks/MockSpokeInstance.sol +++ b/tests/mocks/MockSpokeInstance.sol @@ -14,15 +14,21 @@ contract MockSpokeInstance is Spoke { * @dev It sets the spoke revision and disables the initializers. * @param spokeRevision_ The revision of the spoke contract. * @param oracle_ The address of the oracle. + * @param maxUserReservesLimit_ The maximum number of reserves a user can have (both collaterals and borrows). */ - constructor(uint64 spokeRevision_, address oracle_) Spoke(oracle_) { + constructor( + uint64 spokeRevision_, + address oracle_, + uint16 maxUserReservesLimit_ + ) Spoke(oracle_, maxUserReservesLimit_) { SPOKE_REVISION = spokeRevision_; _disableInitializers(); } /// @inheritdoc Spoke function initialize(address _authority) external override reinitializer(SPOKE_REVISION) { - emit UpdateOracle(ORACLE); + emit SetSpokeImmutables(ORACLE, MAX_USER_RESERVES_LIMIT); + require(_authority != address(0), InvalidAddress()); __AccessManaged_init(_authority); if (_liquidationConfig.targetHealthFactor == 0) { diff --git a/tests/mocks/MockTokenizationSpokeInstance.sol b/tests/mocks/MockTokenizationSpokeInstance.sol new file mode 100644 index 000000000..35e81585e --- /dev/null +++ b/tests/mocks/MockTokenizationSpokeInstance.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {TokenizationSpoke} from 'src/spoke/TokenizationSpoke.sol'; + +contract MockTokenizationSpokeInstance is TokenizationSpoke { + bool public constant IS_TEST = true; + + uint64 public immutable SPOKE_REVISION; + + /** + * @dev Constructor. + * @dev It sets the vault spoke revision and disables the initializers. + * @param spokeRevision_ The revision of the vault spoke contract. + * @param hub_ The address of the hub. + * @param assetId_ The ID of the asset. + */ + constructor( + uint64 spokeRevision_, + address hub_, + uint256 assetId_ + ) TokenizationSpoke(hub_, assetId_) { + SPOKE_REVISION = spokeRevision_; + _disableInitializers(); + } + + /// @inheritdoc TokenizationSpoke + function initialize( + string memory shareName, + string memory shareSymbol + ) external override reinitializer(SPOKE_REVISION) { + __TokenizationSpoke_init(shareName, shareSymbol); + } +} diff --git a/tests/mocks/PositionStatusMapWrapper.sol b/tests/mocks/PositionStatusMapWrapper.sol index 9d4028451..0774235ac 100644 --- a/tests/mocks/PositionStatusMapWrapper.sol +++ b/tests/mocks/PositionStatusMapWrapper.sol @@ -42,6 +42,10 @@ contract PositionStatusMapWrapper { return _p.collateralCount(reserveCount); } + function borrowCount(uint256 reserveCount) external view returns (uint256) { + return _p.borrowCount(reserveCount); + } + function getBucketWord(uint256 reserveId) external view returns (uint256) { return _p.getBucketWord(reserveId); } diff --git a/tests/mocks/ReserveFlagsMapWrapper.sol b/tests/mocks/ReserveFlagsMapWrapper.sol index 62212ca2d..336a55c5f 100644 --- a/tests/mocks/ReserveFlagsMapWrapper.sol +++ b/tests/mocks/ReserveFlagsMapWrapper.sol @@ -11,7 +11,6 @@ contract ReserveFlagsMapWrapper { bool initPaused, bool initFrozen, bool initBorrowable, - bool initLiquidatable, bool initReceiveSharesEnabled ) external pure returns (ReserveFlags) { return @@ -19,7 +18,6 @@ contract ReserveFlagsMapWrapper { initPaused: initPaused, initFrozen: initFrozen, initBorrowable: initBorrowable, - initLiquidatable: initLiquidatable, initReceiveSharesEnabled: initReceiveSharesEnabled }); } @@ -36,10 +34,6 @@ contract ReserveFlagsMapWrapper { return ReserveFlagsMap.setBorrowable(flags, status); } - function setLiquidatable(ReserveFlags flags, bool status) external pure returns (ReserveFlags) { - return ReserveFlagsMap.setLiquidatable(flags, status); - } - function setReceiveSharesEnabled( ReserveFlags flags, bool status @@ -59,10 +53,6 @@ contract ReserveFlagsMapWrapper { return ReserveFlagsMap.borrowable(flags); } - function liquidatable(ReserveFlags flags) external pure returns (bool) { - return ReserveFlagsMap.liquidatable(flags); - } - function receiveSharesEnabled(ReserveFlags flags) external pure returns (bool) { return ReserveFlagsMap.receiveSharesEnabled(flags); } @@ -79,10 +69,6 @@ contract ReserveFlagsMapWrapper { return ReserveFlagsMap.BORROWABLE_MASK; } - function LIQUIDATABLE_MASK() external pure returns (uint8) { - return ReserveFlagsMap.LIQUIDATABLE_MASK; - } - function RECEIVE_SHARES_ENABLED_MASK() external pure returns (uint8) { return ReserveFlagsMap.RECEIVE_SHARES_ENABLED_MASK; } diff --git a/tests/mocks/SpokeUtilsWrapper.sol b/tests/mocks/SpokeUtilsWrapper.sol new file mode 100644 index 000000000..a1b9032f5 --- /dev/null +++ b/tests/mocks/SpokeUtilsWrapper.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {SpokeUtils} from 'src/spoke/libraries/SpokeUtils.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +/// @title SpokeUtilsWrapper +/// @author Aave Labs +/// @notice Wrapper for the SpokeUtils library to be used in tests. +contract SpokeUtilsWrapper { + mapping(uint256 reserveId => ISpoke.Reserve) internal _reserves; + + function setReserve(uint256 reserveId, ISpoke.Reserve memory reserve) external { + _reserves[reserveId] = reserve; + } + + function get(uint256 reserveId) external view returns (ISpoke.Reserve memory) { + return SpokeUtils.get(_reserves, reserveId); + } + + function toValue( + uint256 amount, + uint256 decimals, + uint256 price + ) external pure returns (uint256) { + return SpokeUtils.toValue(amount, decimals, price); + } +} diff --git a/tests/mocks/UserPositionDebtWrapper.sol b/tests/mocks/UserPositionDebtWrapper.sol index 2f3ebedcb..dd9e9e0ef 100644 --- a/tests/mocks/UserPositionDebtWrapper.sol +++ b/tests/mocks/UserPositionDebtWrapper.sol @@ -4,9 +4,9 @@ pragma solidity ^0.8.0; import {IHubBase} from 'src/hub/interfaces/IHubBase.sol'; import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; -import {UserPositionDebt} from 'src/spoke/libraries/UserPositionDebt.sol'; +import {UserPositionUtils} from 'src/spoke/libraries/UserPositionUtils.sol'; -contract UserPositionDebtWrapper { +contract UserPositionUtilsWrapper { ISpoke.UserPosition internal _userPosition; function setUserPosition(ISpoke.UserPosition memory userPosition) external { @@ -18,17 +18,17 @@ contract UserPositionDebtWrapper { } function applyPremiumDelta(IHubBase.PremiumDelta memory premiumDelta) external { - UserPositionDebt.applyPremiumDelta(_userPosition, premiumDelta); + UserPositionUtils.applyPremiumDelta(_userPosition, premiumDelta); } - function getPremiumDelta( + function calculatePremiumDelta( uint256 drawnSharesTaken, uint256 drawnIndex, uint256 riskPremium, uint256 restoredPremiumRay ) external view returns (IHubBase.PremiumDelta memory) { return - UserPositionDebt.getPremiumDelta( + UserPositionUtils.calculatePremiumDelta( _userPosition, drawnSharesTaken, drawnIndex, @@ -38,21 +38,21 @@ contract UserPositionDebtWrapper { } function getDebt(IHubBase hub, uint256 assetId) external view returns (uint256, uint256) { - return UserPositionDebt.getDebt(_userPosition, hub, assetId); + return UserPositionUtils.getDebt(_userPosition, hub, assetId); } function getDebt(uint256 drawnIndex) external view returns (uint256, uint256) { - return UserPositionDebt.getDebt(_userPosition, drawnIndex); + return UserPositionUtils.getDebt(_userPosition, drawnIndex); } function calculateRestoreAmount( uint256 drawnIndex, uint256 amount ) external view returns (uint256, uint256) { - return UserPositionDebt.calculateRestoreAmount(_userPosition, drawnIndex, amount); + return UserPositionUtils.calculateRestoreAmount(_userPosition, drawnIndex, amount); } function calculatePremiumRay(uint256 drawnIndex) external view returns (uint256) { - return UserPositionDebt._calculatePremiumRay(_userPosition, drawnIndex); + return UserPositionUtils._calculatePremiumRay(_userPosition, drawnIndex); } } diff --git a/tests/mocks/UserPositionUtilsWrapper.sol b/tests/mocks/UserPositionUtilsWrapper.sol new file mode 100644 index 000000000..dd9e9e0ef --- /dev/null +++ b/tests/mocks/UserPositionUtilsWrapper.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {IHubBase} from 'src/hub/interfaces/IHubBase.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {UserPositionUtils} from 'src/spoke/libraries/UserPositionUtils.sol'; + +contract UserPositionUtilsWrapper { + ISpoke.UserPosition internal _userPosition; + + function setUserPosition(ISpoke.UserPosition memory userPosition) external { + _userPosition = userPosition; + } + + function getUserPosition() external view returns (ISpoke.UserPosition memory) { + return _userPosition; + } + + function applyPremiumDelta(IHubBase.PremiumDelta memory premiumDelta) external { + UserPositionUtils.applyPremiumDelta(_userPosition, premiumDelta); + } + + function calculatePremiumDelta( + uint256 drawnSharesTaken, + uint256 drawnIndex, + uint256 riskPremium, + uint256 restoredPremiumRay + ) external view returns (IHubBase.PremiumDelta memory) { + return + UserPositionUtils.calculatePremiumDelta( + _userPosition, + drawnSharesTaken, + drawnIndex, + riskPremium, + restoredPremiumRay + ); + } + + function getDebt(IHubBase hub, uint256 assetId) external view returns (uint256, uint256) { + return UserPositionUtils.getDebt(_userPosition, hub, assetId); + } + + function getDebt(uint256 drawnIndex) external view returns (uint256, uint256) { + return UserPositionUtils.getDebt(_userPosition, drawnIndex); + } + + function calculateRestoreAmount( + uint256 drawnIndex, + uint256 amount + ) external view returns (uint256, uint256) { + return UserPositionUtils.calculateRestoreAmount(_userPosition, drawnIndex, amount); + } + + function calculatePremiumRay(uint256 drawnIndex) external view returns (uint256) { + return UserPositionUtils._calculatePremiumRay(_userPosition, drawnIndex); + } +} diff --git a/tests/mocks/WadRayMathWrapper.sol b/tests/mocks/WadRayMathWrapper.sol index 955e2d883..45f03d578 100644 --- a/tests/mocks/WadRayMathWrapper.sol +++ b/tests/mocks/WadRayMathWrapper.sol @@ -5,6 +5,10 @@ pragma solidity ^0.8.0; import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; contract WadRayMathWrapper { + function WAD_DECIMALS() public pure returns (uint256) { + return WadRayMath.WAD_DECIMALS; + } + function WAD() public pure returns (uint256) { return WadRayMath.WAD; } @@ -72,4 +76,8 @@ contract WadRayMathWrapper { function bpsToRay(uint256 a) public pure returns (uint256) { return WadRayMath.bpsToRay(a); } + + function roundRayUp(uint256 a) public pure returns (uint256) { + return WadRayMath.roundRayUp(a); + } } diff --git a/tests/unit/AaveOracle.t.sol b/tests/unit/AaveOracle.t.sol index b5803807a..b7411f963 100644 --- a/tests/unit/AaveOracle.t.sol +++ b/tests/unit/AaveOracle.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.0; import 'tests/Base.t.sol'; +/// forge-config: default.allow_internal_expect_revert = true contract AaveOracleTest is Base { using SafeCast for uint256; @@ -12,6 +13,8 @@ contract AaveOracleTest is Base { uint8 private constant _oracleDecimals = 8; string private constant _description = 'Spoke 1 (USD)'; + address public deployer = makeAddr('DEPLOYER'); + address private _source1 = makeAddr('SOURCE1'); address private _source2 = makeAddr('SOURCE2'); @@ -22,36 +25,40 @@ contract AaveOracleTest is Base { function setUp() public override { deployFixtures(); - oracle = new AaveOracle(address(spoke1), _oracleDecimals, _description); - } - function test_deploy_revertsWith_InvalidAddress() public { - vm.expectRevert(IAaveOracle.InvalidAddress.selector); - new AaveOracle(address(0), uint8(vm.randomUint()), string(vm.randomBytes(64))); + vm.startPrank(deployer); + oracle = new AaveOracle(_oracleDecimals, _description); + spoke1 = ISpoke( + address( + DeployUtils.deploySpokeImplementation( + address(oracle), + Constants.MAX_ALLOWED_USER_RESERVES_LIMIT + ) + ) + ); + oracle.setSpoke(address(spoke1)); + vm.stopPrank(); } function test_constructor() public { - oracle = new AaveOracle(address(spoke1), _oracleDecimals, _description); + vm.prank(deployer); + oracle = new AaveOracle(_oracleDecimals, _description); - test_spoke(); - testDECIMALS(); + assertEq(oracle.SPOKE(), address(0)); + test_DECIMALS(); test_description(); } function test_fuzz_constructor(uint8 decimals) public { decimals = bound(decimals, 0, 18).toUint8(); - oracle = new AaveOracle(address(spoke1), decimals, _description); + oracle = new AaveOracle(decimals, _description); - test_spoke(); + assertEq(oracle.SPOKE(), address(0)); assertEq(oracle.DECIMALS(), decimals); test_description(); } - function test_spoke() public view { - assertEq(oracle.SPOKE(), address(spoke1)); - } - - function testDECIMALS() public view { + function test_DECIMALS() public view { assertEq(oracle.DECIMALS(), _oracleDecimals); } @@ -59,6 +66,47 @@ contract AaveOracleTest is Base { assertEq(oracle.DESCRIPTION(), _description); } + function test_setSpoke_revertsWith_OnlyDeployer(address setter) public { + vm.assume(setter != deployer); + + vm.expectRevert(IAaveOracle.OnlyDeployer.selector); + vm.prank(setter); + oracle.setSpoke(address(spoke1)); + } + + function test_setSpoke_revertsWith_InvalidAddress() public { + vm.expectRevert(IAaveOracle.InvalidAddress.selector); + + vm.prank(deployer); + oracle.setSpoke(address(0)); + } + + function test_setSpoke_revertsWith_SpokeAlreadySet() public { + vm.expectRevert(IAaveOracle.SpokeAlreadySet.selector); + vm.prank(deployer); + oracle.setSpoke(address(spoke1)); + } + + function test_setSpoke() public { + vm.startPrank(deployer); + oracle = new AaveOracle(_oracleDecimals, _description); + + address newSpoke = address( + DeployUtils.deploySpokeImplementation( + address(oracle), + Constants.MAX_ALLOWED_USER_RESERVES_LIMIT + ) + ); + + vm.expectEmit(address(oracle)); + emit IAaveOracle.SetSpoke(address(newSpoke)); + + oracle.setSpoke(address(newSpoke)); + vm.stopPrank(); + + assertEq(oracle.SPOKE(), address(newSpoke)); + } + function test_setReserveSource_revertsWith_OnlySpoke() public { vm.expectRevert(IPriceOracle.OnlySpoke.selector); @@ -102,6 +150,23 @@ contract AaveOracleTest is Base { oracle.setReserveSource(reserveId1, _source1); } + function test_setReserveSource_revertsWith_OracleMismatch() public { + vm.startPrank(deployer); + IAaveOracle newOracle = IAaveOracle(new AaveOracle(_oracleDecimals, _description)); + + // set new spoke to a separate oracle + address mismatchOracle = address(new AaveOracle(_oracleDecimals, _description)); + address newSpoke = address( + DeployUtils.deploySpokeImplementation( + mismatchOracle, + Constants.MAX_ALLOWED_USER_RESERVES_LIMIT + ) + ); + + vm.expectRevert(IAaveOracle.OracleMismatch.selector); + newOracle.setSpoke(newSpoke); + } + function test_setReserveSource() public { _mockSourceDecimals(_source1, _oracleDecimals); _mockSourceLatestRoundData(_source1, 1e8); diff --git a/tests/unit/AccessManagerEnumerable.t.sol b/tests/unit/AccessManagerEnumerable.t.sol index e84eaee92..89cdc80e0 100644 --- a/tests/unit/AccessManagerEnumerable.t.sol +++ b/tests/unit/AccessManagerEnumerable.t.sol @@ -8,12 +8,26 @@ import {AccessManagerEnumerable} from 'src/access/AccessManagerEnumerable.sol'; contract AccessManagerEnumerableTest is Test { using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.UintSet; address internal ADMIN = makeAddr('ADMIN'); + // Defult Roles : + uint64 constant ADMIN_ROLE = 0; + + // Custom Roles : + uint64 constant NEW_ADMIN_ROLE = 1; + uint64 constant NEW_ADMIN_ROLE_2 = 2; + uint64 constant GUARDIAN_ADMIN_ROLE = 3; + uint64 constant GUARDIAN_ROLE_1 = 111111111; + uint64 constant GUARDIAN_ROLE_2 = 222222222; + AccessManagerEnumerable internal accessManagerEnumerable; EnumerableSet.AddressSet members; + EnumerableSet.UintSet internalRoles; + EnumerableSet.UintSet internalAdminRoles; + mapping(uint64 => EnumerableSet.UintSet) internalAdminOfRoles; function setUp() public virtual { accessManagerEnumerable = new AccessManagerEnumerable(ADMIN); @@ -39,6 +53,12 @@ contract AccessManagerEnumerableTest is Test { assertEq(roleMembers.length, 1); assertEq(roleMembers[0], user1); + assertEq(accessManagerEnumerable.getRole(0), roleId); + assertEq(accessManagerEnumerable.getRoleCount(), 1); + uint64[] memory roles = accessManagerEnumerable.getRoles(0, 2); + assertEq(roles.length, 1); + assertEq(roles[0], roleId); + accessManagerEnumerable.grantRole(roleId, user2, 0); assertEq(accessManagerEnumerable.getRoleMember(roleId, 1), user2); assertEq(accessManagerEnumerable.getRoleMemberCount(roleId), 2); @@ -50,7 +70,12 @@ contract AccessManagerEnumerableTest is Test { assertEq(roleMembers.length, 2); assertEq(roleMembers[0], user1); assertEq(roleMembers[1], user2); - vm.stopPrank(); + + assertEq(accessManagerEnumerable.getRole(0), roleId); + assertEq(accessManagerEnumerable.getRoleCount(), 1); + roles = accessManagerEnumerable.getRoles(0, 1); + assertEq(roles.length, 1); + assertEq(roles[0], roleId); } function test_grantRole_fuzz(uint64 roleId, uint256 membersCount) public { @@ -86,6 +111,316 @@ contract AccessManagerEnumerableTest is Test { assertEq(roleMembers[i], members.at(i)); assertEq(accessManagerEnumerable.getRoleMember(roleId, i), members.at(i)); } + + assertEq(accessManagerEnumerable.getRole(0), roleId); + assertEq(accessManagerEnumerable.getRoleCount(), 1); + uint64[] memory roles = accessManagerEnumerable.getRoles(0, 1); + assertEq(roles.length, 1); + assertEq(roles[0], roleId); + } + + function test_setRoleAdmin_trackRolesAndTrackAdminRoles() public { + assertEq(accessManagerEnumerable.getRoleCount(), 0); + assertEq(accessManagerEnumerable.getAdminRoleCount(), 0); + + vm.startPrank(ADMIN); + accessManagerEnumerable.setRoleAdmin(GUARDIAN_ROLE_1, GUARDIAN_ADMIN_ROLE); + accessManagerEnumerable.setRoleAdmin(GUARDIAN_ROLE_2, GUARDIAN_ADMIN_ROLE); + vm.stopPrank(); + + uint64[] memory roleList = accessManagerEnumerable.getRoles(0, 2); + assertEq(accessManagerEnumerable.getRoleCount(), 2); + assertEq(roleList.length, 2); + assertEq(roleList[0], GUARDIAN_ROLE_1); + assertEq(roleList[1], GUARDIAN_ROLE_2); + assertEq(accessManagerEnumerable.getRole(0), GUARDIAN_ROLE_1); + assertEq(accessManagerEnumerable.getRole(1), GUARDIAN_ROLE_2); + + uint64[] memory adminRoleList = accessManagerEnumerable.getAdminRoles(0, 1); + assertEq(accessManagerEnumerable.getAdminRoleCount(), 1); + assertEq(adminRoleList.length, 1); + assertEq(adminRoleList[0], GUARDIAN_ADMIN_ROLE); + assertEq(accessManagerEnumerable.getAdminRole(0), GUARDIAN_ADMIN_ROLE); + assertEq(accessManagerEnumerable.getRoleOfAdminRole(GUARDIAN_ADMIN_ROLE, 0), GUARDIAN_ROLE_1); + assertEq(accessManagerEnumerable.getRoleOfAdminRole(GUARDIAN_ADMIN_ROLE, 1), GUARDIAN_ROLE_2); + } + + function test_setRoleAdmin_trackAdminRoles() public { + uint64 newRole1 = 111; + uint64 newRole2 = 222; + + assertEq(accessManagerEnumerable.getAdminRoleCount(), 0); + + vm.startPrank(ADMIN); + accessManagerEnumerable.setRoleAdmin(GUARDIAN_ROLE_1, NEW_ADMIN_ROLE); + accessManagerEnumerable.setRoleAdmin(GUARDIAN_ROLE_2, ADMIN_ROLE); + accessManagerEnumerable.setRoleAdmin(newRole1, NEW_ADMIN_ROLE); + accessManagerEnumerable.setRoleAdmin(newRole2, NEW_ADMIN_ROLE_2); + vm.stopPrank(); + + uint64[] memory adminRoleList = accessManagerEnumerable.getAdminRoles(0, 2); + assertEq(accessManagerEnumerable.getAdminRoleCount(), 2); + assertEq(adminRoleList.length, 2); + assertEq(adminRoleList[0], NEW_ADMIN_ROLE); + assertEq(adminRoleList[1], NEW_ADMIN_ROLE_2); + assertEq(accessManagerEnumerable.getAdminRole(0), NEW_ADMIN_ROLE); + assertEq(accessManagerEnumerable.getAdminRole(1), NEW_ADMIN_ROLE_2); + } + + function test_setRoleAdmin_trackAdminOfRoles() public { + uint64 newRole1 = 111; + uint64 newRole2 = 222; + uint64 newRole3 = 333; + + assertEq(accessManagerEnumerable.getAdminRoleCount(), 0); + + vm.startPrank(ADMIN); + accessManagerEnumerable.setRoleAdmin(GUARDIAN_ROLE_1, ADMIN_ROLE); + accessManagerEnumerable.setRoleAdmin(GUARDIAN_ROLE_2, ADMIN_ROLE); + accessManagerEnumerable.setRoleAdmin(newRole1, NEW_ADMIN_ROLE); + accessManagerEnumerable.setRoleAdmin(newRole2, NEW_ADMIN_ROLE); + accessManagerEnumerable.setRoleAdmin(newRole3, NEW_ADMIN_ROLE); + vm.stopPrank(); + + uint64[] memory adminRoleList = accessManagerEnumerable.getAdminRoles(0, 1); + assertEq(accessManagerEnumerable.getAdminRoleCount(), 1); + assertEq(adminRoleList.length, 1); + assertEq(adminRoleList[0], NEW_ADMIN_ROLE); + assertEq(accessManagerEnumerable.getAdminRole(0), NEW_ADMIN_ROLE); + + uint64[] memory adminOfRolesList = accessManagerEnumerable.getRolesOfAdminRole( + NEW_ADMIN_ROLE, + 0, + accessManagerEnumerable.getRoleOfAdminRoleCount(NEW_ADMIN_ROLE) + ); + assertEq(accessManagerEnumerable.getRoleOfAdminRoleCount(NEW_ADMIN_ROLE), 3); + assertEq(adminOfRolesList.length, 3); + assertEq(adminOfRolesList[0], newRole1); + assertEq(adminOfRolesList[1], newRole2); + assertEq(adminOfRolesList[2], newRole3); + assertEq(accessManagerEnumerable.getRoleOfAdminRole(NEW_ADMIN_ROLE, 0), newRole1); + assertEq(accessManagerEnumerable.getRoleOfAdminRole(NEW_ADMIN_ROLE, 1), newRole2); + assertEq(accessManagerEnumerable.getRoleOfAdminRole(NEW_ADMIN_ROLE, 2), newRole3); + + // should not track ADMIN_ROLE + adminOfRolesList = accessManagerEnumerable.getRolesOfAdminRole( + ADMIN_ROLE, + 0, + accessManagerEnumerable.getRoleOfAdminRoleCount(ADMIN_ROLE) + ); + assertEq(accessManagerEnumerable.getRoleOfAdminRoleCount(ADMIN_ROLE), 0); + assertEq(adminOfRolesList.length, 0); + } + + function test_setRoleAdmin_trackAdminOfRoles_changeAdminRole() public { + uint64 newRole1 = 111; + uint64 newRole2 = 222; + uint64 newRole3 = 333; + + assertEq(accessManagerEnumerable.getAdminRoleCount(), 0); + + vm.startPrank(ADMIN); + accessManagerEnumerable.setRoleAdmin(GUARDIAN_ROLE_1, ADMIN_ROLE); + accessManagerEnumerable.setRoleAdmin(GUARDIAN_ROLE_2, ADMIN_ROLE); + accessManagerEnumerable.setRoleAdmin(newRole1, NEW_ADMIN_ROLE); + accessManagerEnumerable.setRoleAdmin(newRole2, NEW_ADMIN_ROLE); + accessManagerEnumerable.setRoleAdmin(newRole3, NEW_ADMIN_ROLE); + vm.stopPrank(); + + uint64[] memory adminRoleList = accessManagerEnumerable.getAdminRoles(0, 1); + assertEq(accessManagerEnumerable.getAdminRoleCount(), 1); + assertEq(adminRoleList.length, 1); + assertEq(adminRoleList[0], NEW_ADMIN_ROLE); + assertEq(accessManagerEnumerable.getAdminRole(0), NEW_ADMIN_ROLE); + + uint64[] memory adminOfRolesList = accessManagerEnumerable.getRolesOfAdminRole( + NEW_ADMIN_ROLE, + 0, + accessManagerEnumerable.getRoleOfAdminRoleCount(NEW_ADMIN_ROLE) + ); + assertEq(accessManagerEnumerable.getRoleOfAdminRoleCount(NEW_ADMIN_ROLE), 3); + assertEq(adminOfRolesList.length, 3); + assertEq(adminOfRolesList[0], newRole1); + assertEq(adminOfRolesList[1], newRole2); + assertEq(adminOfRolesList[2], newRole3); + assertEq(accessManagerEnumerable.getRoleOfAdminRole(NEW_ADMIN_ROLE, 0), newRole1); + assertEq(accessManagerEnumerable.getRoleOfAdminRole(NEW_ADMIN_ROLE, 1), newRole2); + assertEq(accessManagerEnumerable.getRoleOfAdminRole(NEW_ADMIN_ROLE, 2), newRole3); + + // should not track ADMIN_ROLE + adminOfRolesList = accessManagerEnumerable.getRolesOfAdminRole( + ADMIN_ROLE, + 0, + accessManagerEnumerable.getRoleOfAdminRoleCount(ADMIN_ROLE) + ); + assertEq(accessManagerEnumerable.getRoleOfAdminRoleCount(ADMIN_ROLE), 0); + assertEq(adminOfRolesList.length, 0); + + vm.startPrank(ADMIN); + accessManagerEnumerable.setRoleAdmin(newRole2, ADMIN_ROLE); + vm.stopPrank(); + + adminRoleList = accessManagerEnumerable.getAdminRoles(0, 1); + assertEq(accessManagerEnumerable.getAdminRoleCount(), 1); + assertEq(adminRoleList.length, 1); + assertEq(adminRoleList[0], NEW_ADMIN_ROLE); + assertEq(accessManagerEnumerable.getAdminRole(0), NEW_ADMIN_ROLE); + + adminOfRolesList = accessManagerEnumerable.getRolesOfAdminRole( + NEW_ADMIN_ROLE, + 0, + accessManagerEnumerable.getRoleOfAdminRoleCount(NEW_ADMIN_ROLE) + ); + assertEq(accessManagerEnumerable.getRoleOfAdminRoleCount(NEW_ADMIN_ROLE), 2); + assertEq(adminOfRolesList.length, 2); + assertEq(adminOfRolesList[0], newRole1); + assertEq(adminOfRolesList[1], newRole3); + assertEq(accessManagerEnumerable.getRoleOfAdminRole(NEW_ADMIN_ROLE, 0), newRole1); + assertEq(accessManagerEnumerable.getRoleOfAdminRole(NEW_ADMIN_ROLE, 1), newRole3); + + // should not track ADMIN_ROLE + adminOfRolesList = accessManagerEnumerable.getRolesOfAdminRole( + ADMIN_ROLE, + 0, + accessManagerEnumerable.getRoleOfAdminRoleCount(ADMIN_ROLE) + ); + assertEq(accessManagerEnumerable.getRoleOfAdminRoleCount(ADMIN_ROLE), 0); + assertEq(adminOfRolesList.length, 0); + } + + function test_setRoleGuardian_trackRoles() public { + uint64 newRole1 = 111; + uint64 newRole2 = 222; + uint64 newRole3 = 333; + assertEq(accessManagerEnumerable.getAdminRoleCount(), 0); + + vm.startPrank(ADMIN); + accessManagerEnumerable.setRoleGuardian(newRole1, GUARDIAN_ROLE_1); + accessManagerEnumerable.setRoleGuardian(newRole2, GUARDIAN_ROLE_2); + accessManagerEnumerable.setRoleGuardian(newRole3, GUARDIAN_ROLE_1); + vm.stopPrank(); + + uint64[] memory roleList = accessManagerEnumerable.getRoles(0, 3); + assertEq(accessManagerEnumerable.getRoleCount(), 3); + assertEq(roleList.length, 3); + assertEq(roleList[0], newRole1); + assertEq(roleList[1], newRole2); + assertEq(roleList[2], newRole3); + assertEq(accessManagerEnumerable.getRole(0), newRole1); + assertEq(accessManagerEnumerable.getRole(1), newRole2); + assertEq(accessManagerEnumerable.getRole(2), newRole3); + } + + function test_setRoleAdmin_fuzz_trackRolesAndTrackAdminRoles_multipleRoles( + uint256 rolesCount + ) public { + rolesCount = bound(rolesCount, 1, 15); + + vm.startPrank(ADMIN); + + for (uint256 i = 0; i < rolesCount; i++) { + uint64 roleId = _getRandomRoleId(); + internalRoles.add(roleId); + internalAdminOfRoles[NEW_ADMIN_ROLE].add(uint256(roleId)); + accessManagerEnumerable.setRoleAdmin(roleId, NEW_ADMIN_ROLE); + } + vm.stopPrank(); + + uint64[] memory roleList = accessManagerEnumerable.getRoles( + 0, + accessManagerEnumerable.getRoleCount() + ); + assertEq(accessManagerEnumerable.getRoleCount(), rolesCount); + assertEq(roleList.length, rolesCount); + + for (uint256 i = 0; i < rolesCount; i++) { + assertEq(roleList[i], internalRoles.at(i)); + assertEq(accessManagerEnumerable.getRole(i), internalRoles.at(i)); + } + + uint64[] memory adminOfRolesList = accessManagerEnumerable.getRolesOfAdminRole( + NEW_ADMIN_ROLE, + 0, + accessManagerEnumerable.getRoleOfAdminRoleCount(NEW_ADMIN_ROLE) + ); + assertEq( + accessManagerEnumerable.getRoleOfAdminRoleCount(NEW_ADMIN_ROLE), + internalAdminOfRoles[NEW_ADMIN_ROLE].length() + ); + assertEq(adminOfRolesList.length, internalAdminOfRoles[NEW_ADMIN_ROLE].length()); + for (uint256 i = 0; i < internalAdminOfRoles[NEW_ADMIN_ROLE].length(); i++) { + assertEq(adminOfRolesList[i], uint64(internalAdminOfRoles[NEW_ADMIN_ROLE].at(i))); + assertEq( + accessManagerEnumerable.getRoleOfAdminRole(NEW_ADMIN_ROLE, i), + uint64(internalAdminOfRoles[NEW_ADMIN_ROLE].at(i)) + ); + } + } + + function test_setRoleAdmin_fuzz_trackAdminRoles_multipleRoles_multipleAdmins( + uint256 rolesCount + ) public { + rolesCount = bound(rolesCount, 1, 15); + + vm.startPrank(ADMIN); + + for (uint256 i = 0; i < rolesCount; i++) { + uint64 roleId = _getRandomRoleId(); + uint64 adminRoleId = _getRandomAdminRoleId(); + internalRoles.add(roleId); + if (adminRoleId != ADMIN_ROLE) { + internalAdminRoles.add(adminRoleId); + internalAdminOfRoles[adminRoleId].add(uint256(roleId)); + } + accessManagerEnumerable.setRoleAdmin(roleId, adminRoleId); + } + vm.stopPrank(); + + uint64[] memory roleList = accessManagerEnumerable.getRoles( + 0, + accessManagerEnumerable.getRoleCount() + ); + assertEq(accessManagerEnumerable.getRoleCount(), rolesCount); + assertEq(roleList.length, rolesCount); + + for (uint256 i = 0; i < rolesCount; i++) { + assertEq(roleList[i], internalRoles.at(i)); + assertEq(accessManagerEnumerable.getRole(i), internalRoles.at(i)); + } + + uint64[] memory adminRoleList = accessManagerEnumerable.getAdminRoles( + 0, + accessManagerEnumerable.getAdminRoleCount() + ); + assertEq(accessManagerEnumerable.getAdminRoleCount(), internalAdminRoles.length()); + assertEq(adminRoleList.length, internalAdminRoles.length()); + + for (uint256 i = 0; i < internalAdminRoles.length(); i++) { + uint64 adminRoleId = uint64(internalAdminRoles.at(i)); + assertEq(adminRoleList[i], adminRoleId); + assertEq(accessManagerEnumerable.getAdminRole(i), adminRoleId); + + uint64[] memory adminOfRolesList = accessManagerEnumerable.getRolesOfAdminRole( + adminRoleId, + 0, + accessManagerEnumerable.getRoleOfAdminRoleCount(adminRoleId) + ); + assertEq( + accessManagerEnumerable.getRoleOfAdminRoleCount(adminRoleId), + internalAdminOfRoles[adminRoleId].length() + ); + assertEq(adminOfRolesList.length, internalAdminOfRoles[adminRoleId].length()); + for (uint256 j = 0; j < internalAdminOfRoles[adminRoleId].length(); j++) { + assertEq(adminOfRolesList[j], uint64(internalAdminOfRoles[adminRoleId].at(j))); + assertEq( + accessManagerEnumerable.getRoleOfAdminRole(adminRoleId, j), + uint64(internalAdminOfRoles[adminRoleId].at(j)) + ); + } + } + + // should not track ADMIN_ROLE + assertEq(accessManagerEnumerable.getRoleOfAdminRoleCount(ADMIN_ROLE), 0); } function test_revokeRole() public { @@ -119,6 +454,74 @@ contract AccessManagerEnumerableTest is Test { assertEq(roleMembers[1], user3); } + function test_renounceRole() public { + uint64 roleId = 1; + address user1 = makeAddr('user1'); + address user2 = makeAddr('user2'); + address user3 = makeAddr('user3'); + + vm.startPrank(ADMIN); + accessManagerEnumerable.labelRole(roleId, 'test_role'); + accessManagerEnumerable.setGrantDelay(roleId, 0); + accessManagerEnumerable.grantRole(roleId, user1, 0); + accessManagerEnumerable.grantRole(roleId, user2, 0); + accessManagerEnumerable.grantRole(roleId, user3, 0); + vm.stopPrank(); + + assertEq(accessManagerEnumerable.getRoleMemberCount(roleId), 3); + + vm.prank(user2); + accessManagerEnumerable.renounceRole(roleId, user2); + + assertEq(accessManagerEnumerable.getRoleMemberCount(roleId), 2); + assertEq(accessManagerEnumerable.getRoleMember(roleId, 0), user1); + assertEq(accessManagerEnumerable.getRoleMember(roleId, 1), user3); + address[] memory roleMembers = accessManagerEnumerable.getRoleMembers( + roleId, + 0, + accessManagerEnumerable.getRoleMemberCount(roleId) + ); + assertEq(roleMembers.length, 2); + assertEq(roleMembers[0], user1); + assertEq(roleMembers[1], user3); + } + + function test_revokeRole_shouldNotTrack() public { + uint64 roleId = 1; + address user1 = makeAddr('user1'); + + (bool isMember, ) = accessManagerEnumerable.hasRole(roleId, user1); + assertFalse(isMember); + assertEq(accessManagerEnumerable.getRoleMemberCount(roleId), 0); + + vm.prank(ADMIN); + accessManagerEnumerable.revokeRole(roleId, user1); + + assertEq(accessManagerEnumerable.getRoleMemberCount(roleId), 0); + assertEq(accessManagerEnumerable.getRoleMembers(roleId, 0, 1).length, 0); + + (isMember, ) = accessManagerEnumerable.hasRole(roleId, user1); + assertFalse(isMember); + } + + function test_renounceRole_shouldNotTrack() public { + uint64 roleId = 1; + address user1 = makeAddr('user1'); + + (bool isMember, ) = accessManagerEnumerable.hasRole(roleId, user1); + assertFalse(isMember); + assertEq(accessManagerEnumerable.getRoleMemberCount(roleId), 0); + + vm.prank(user1); + accessManagerEnumerable.renounceRole(roleId, user1); + + assertEq(accessManagerEnumerable.getRoleMemberCount(roleId), 0); + assertEq(accessManagerEnumerable.getRoleMembers(roleId, 0, 1).length, 0); + + (isMember, ) = accessManagerEnumerable.hasRole(roleId, user1); + assertFalse(isMember); + } + function test_setTargetFunctionRole() public { uint64 roleId = 1; address target = makeAddr('target'); @@ -137,20 +540,30 @@ contract AccessManagerEnumerableTest is Test { accessManagerEnumerable.setTargetFunctionRole(target, selectors, roleId); vm.stopPrank(); - assertEq(accessManagerEnumerable.getRoleTargetFunctionCount(roleId, target), 3); - assertEq(accessManagerEnumerable.getRoleTargetFunction(roleId, target, 0), selector1); - assertEq(accessManagerEnumerable.getRoleTargetFunction(roleId, target, 1), selector2); - assertEq(accessManagerEnumerable.getRoleTargetFunction(roleId, target, 2), selector3); - bytes4[] memory roleSelectors = accessManagerEnumerable.getRoleTargetFunctions( + assertEq(accessManagerEnumerable.getRoleTargetSelectorCount(roleId, target), 3); + assertEq(accessManagerEnumerable.getRoleTargetSelector(roleId, target, 0), selector1); + assertEq(accessManagerEnumerable.getRoleTargetSelector(roleId, target, 1), selector2); + assertEq(accessManagerEnumerable.getRoleTargetSelector(roleId, target, 2), selector3); + bytes4[] memory roleSelectors = accessManagerEnumerable.getRoleTargetSelectors( roleId, target, 0, - accessManagerEnumerable.getRoleTargetFunctionCount(roleId, target) + accessManagerEnumerable.getRoleTargetSelectorCount(roleId, target) ); assertEq(roleSelectors.length, 3); assertEq(roleSelectors[0], selector1); assertEq(roleSelectors[1], selector2); assertEq(roleSelectors[2], selector3); + + assertEq(accessManagerEnumerable.getRoleTargetCount(roleId), 1); + assertEq(accessManagerEnumerable.getRoleTarget(roleId, 0), target); + address[] memory roleTargets = accessManagerEnumerable.getRoleTargets( + roleId, + 0, + accessManagerEnumerable.getRoleTargetCount(roleId) + ); + assertEq(roleTargets.length, 1); + assertEq(roleTargets[0], target); } function test_setTargetFunctionRole_withReplace() public { @@ -174,11 +587,11 @@ contract AccessManagerEnumerableTest is Test { accessManagerEnumerable.setTargetFunctionRole(target, selectors, roleId); - assertEq(accessManagerEnumerable.getRoleTargetFunctionCount(roleId, target), 3); - assertEq(accessManagerEnumerable.getRoleTargetFunction(roleId, target, 0), selector1); - assertEq(accessManagerEnumerable.getRoleTargetFunction(roleId, target, 1), selector2); - assertEq(accessManagerEnumerable.getRoleTargetFunction(roleId, target, 2), selector3); - bytes4[] memory roleSelectors = accessManagerEnumerable.getRoleTargetFunctions( + assertEq(accessManagerEnumerable.getRoleTargetSelectorCount(roleId, target), 3); + assertEq(accessManagerEnumerable.getRoleTargetSelector(roleId, target, 0), selector1); + assertEq(accessManagerEnumerable.getRoleTargetSelector(roleId, target, 1), selector2); + assertEq(accessManagerEnumerable.getRoleTargetSelector(roleId, target, 2), selector3); + bytes4[] memory roleSelectors = accessManagerEnumerable.getRoleTargetSelectors( roleId, target, 0, @@ -189,31 +602,148 @@ contract AccessManagerEnumerableTest is Test { assertEq(roleSelectors[1], selector2); assertEq(roleSelectors[2], selector3); + assertEq(accessManagerEnumerable.getRoleTargetCount(roleId), 1); + assertEq(accessManagerEnumerable.getRoleTarget(roleId, 0), target); + address[] memory roleTargets = accessManagerEnumerable.getRoleTargets( + roleId, + 0, + accessManagerEnumerable.getRoleTargetCount(roleId) + ); + assertEq(roleTargets.length, 1); + assertEq(roleTargets[0], target); + accessManagerEnumerable.setTargetFunctionRole(target, updatedSelectors, roleId2); vm.stopPrank(); - assertEq(accessManagerEnumerable.getRoleTargetFunctionCount(roleId, target), 2); - assertEq(accessManagerEnumerable.getRoleTargetFunctionCount(roleId2, target), 1); - assertEq(accessManagerEnumerable.getRoleTargetFunction(roleId, target, 0), selector1); - assertEq(accessManagerEnumerable.getRoleTargetFunction(roleId, target, 1), selector3); - assertEq(accessManagerEnumerable.getRoleTargetFunction(roleId2, target, 0), selector2); - bytes4[] memory roleSelectors1 = accessManagerEnumerable.getRoleTargetFunctions( + assertEq(accessManagerEnumerable.getRoleTargetSelectorCount(roleId, target), 2); + assertEq(accessManagerEnumerable.getRoleTargetSelectorCount(roleId2, target), 1); + assertEq(accessManagerEnumerable.getRoleTargetSelector(roleId, target, 0), selector1); + assertEq(accessManagerEnumerable.getRoleTargetSelector(roleId, target, 1), selector3); + assertEq(accessManagerEnumerable.getRoleTargetSelector(roleId2, target, 0), selector2); + { + bytes4[] memory roleSelectors1 = accessManagerEnumerable.getRoleTargetSelectors( + roleId, + target, + 0, + 3 + ); + bytes4[] memory roleSelectors2 = accessManagerEnumerable.getRoleTargetSelectors( + roleId2, + target, + 0, + 3 + ); + assertEq(roleSelectors1.length, 2); + assertEq(roleSelectors2.length, 1); + assertEq(roleSelectors1[0], selector1); + assertEq(roleSelectors1[1], selector3); + assertEq(roleSelectors2[0], selector2); + } + + assertEq(accessManagerEnumerable.getRoleTargetCount(roleId), 1); + assertEq(accessManagerEnumerable.getRoleTarget(roleId, 0), target); + roleTargets = accessManagerEnumerable.getRoleTargets( roleId, - target, 0, - 3 + accessManagerEnumerable.getRoleTargetCount(roleId) ); - bytes4[] memory roleSelectors2 = accessManagerEnumerable.getRoleTargetFunctions( - roleId2, - target, + assertEq(roleTargets.length, 1); + assertEq(roleTargets[0], target); + } + + function test_setTargetFunctionRole_multipleTargets() public { + uint64 roleId = 1; + address target1 = makeAddr('target1'); + address target2 = makeAddr('target2'); + address target3 = makeAddr('target3'); + bytes4 selector1 = bytes4(keccak256('functionOne()')); + bytes4 selector2 = bytes4(keccak256('functionTwo()')); + bytes4 selector3 = bytes4(keccak256('functionThree()')); + + address[] memory targets = new address[](3); + targets[0] = target1; + targets[1] = target2; + targets[2] = target3; + + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = selector1; + selectors[1] = selector2; + selectors[2] = selector3; + + vm.startPrank(ADMIN); + accessManagerEnumerable.setTargetFunctionRole(target1, selectors, roleId); + accessManagerEnumerable.setTargetFunctionRole(target2, selectors, roleId); + accessManagerEnumerable.setTargetFunctionRole(target3, selectors, roleId); + vm.stopPrank(); + + assertEq(accessManagerEnumerable.getRoleTargetCount(roleId), 3); + assertEq(accessManagerEnumerable.getRoleTarget(roleId, 0), target1); + assertEq(accessManagerEnumerable.getRoleTarget(roleId, 1), target2); + assertEq(accessManagerEnumerable.getRoleTarget(roleId, 2), target3); + address[] memory roleTargets = accessManagerEnumerable.getRoleTargets( + roleId, 0, - 3 + accessManagerEnumerable.getRoleTargetCount(roleId) + ); + assertEq(roleTargets.length, 3); + assertEq(roleTargets[0], target1); + assertEq(roleTargets[1], target2); + assertEq(roleTargets[2], target3); + } + + function test_setTargetFunctionRole_removeTarget() public { + uint64 roleId = 1; + uint64 otherRoleId = 2; + address target1 = makeAddr('target1'); + address target2 = makeAddr('target2'); + address target3 = makeAddr('target3'); + bytes4 selector1 = bytes4(keccak256('functionOne()')); + bytes4 selector2 = bytes4(keccak256('functionTwo()')); + + address[] memory targets = new address[](3); + targets[0] = target1; + targets[1] = target2; + targets[2] = target3; + + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = selector1; + selectors[1] = selector2; + + vm.startPrank(ADMIN); + accessManagerEnumerable.setTargetFunctionRole(target1, selectors, roleId); + accessManagerEnumerable.setTargetFunctionRole(target2, selectors, roleId); + accessManagerEnumerable.setTargetFunctionRole(target3, selectors, roleId); + vm.stopPrank(); + + assertEq(accessManagerEnumerable.getRoleTargetCount(roleId), 3); + assertEq(accessManagerEnumerable.getRoleTarget(roleId, 0), target1); + assertEq(accessManagerEnumerable.getRoleTarget(roleId, 1), target2); + assertEq(accessManagerEnumerable.getRoleTarget(roleId, 2), target3); + address[] memory roleTargets = accessManagerEnumerable.getRoleTargets( + roleId, + 0, + accessManagerEnumerable.getRoleTargetCount(roleId) ); - assertEq(roleSelectors1.length, 2); - assertEq(roleSelectors2.length, 1); - assertEq(roleSelectors1[0], selector1); - assertEq(roleSelectors1[1], selector3); - assertEq(roleSelectors2[0], selector2); + assertEq(roleTargets.length, 3); + assertEq(roleTargets[0], target1); + assertEq(roleTargets[1], target2); + assertEq(roleTargets[2], target3); + + vm.startPrank(ADMIN); + accessManagerEnumerable.setTargetFunctionRole(target2, selectors, otherRoleId); + vm.stopPrank(); + + assertEq(accessManagerEnumerable.getRoleTargetCount(roleId), 2); + assertEq(accessManagerEnumerable.getRoleTarget(roleId, 0), target1); + assertEq(accessManagerEnumerable.getRoleTarget(roleId, 1), target3); + roleTargets = accessManagerEnumerable.getRoleTargets( + roleId, + 0, + accessManagerEnumerable.getRoleTargetCount(roleId) + ); + assertEq(roleTargets.length, 2); + assertEq(roleTargets[0], target1); + assertEq(roleTargets[1], target3); } function test_setTargetFunctionRole_skipAddToAdminRole() public { @@ -228,7 +758,7 @@ contract AccessManagerEnumerableTest is Test { accessManagerEnumerable.setTargetFunctionRole(target, selectors, roleId); // should not track selectors for ADMIN_ROLE - assertEq(accessManagerEnumerable.getRoleTargetFunctionCount(roleId, target), 0); + assertEq(accessManagerEnumerable.getRoleTargetSelectorCount(roleId, target), 0); } function test_getRoleMembers_fuzz(uint256 startIndex, uint256 endIndex) public { @@ -261,7 +791,7 @@ contract AccessManagerEnumerableTest is Test { } } - function test_getRoleTargetFunctions_fuzz(uint256 startIndex, uint256 endIndex) public { + function test_getRoleTargetSelectors_fuzz(uint256 startIndex, uint256 endIndex) public { startIndex = bound(startIndex, 0, 14); endIndex = bound(endIndex, startIndex + 1, 15); uint64 roleId = 1; @@ -278,7 +808,7 @@ contract AccessManagerEnumerableTest is Test { accessManagerEnumerable.setTargetFunctionRole(target, selectors, roleId); vm.stopPrank(); - bytes4[] memory roleSelectors = accessManagerEnumerable.getRoleTargetFunctions( + bytes4[] memory roleSelectors = accessManagerEnumerable.getRoleTargetSelectors( roleId, target, startIndex, @@ -288,5 +818,25 @@ contract AccessManagerEnumerableTest is Test { for (uint256 i = startIndex; i < endIndex; i++) { assertEq(roleSelectors[i - startIndex], selectors[i]); } + + assertEq(accessManagerEnumerable.getRoleTargetCount(roleId), 1); + assertEq(accessManagerEnumerable.getRoleTarget(roleId, 0), target); + address[] memory roleTargets = accessManagerEnumerable.getRoleTargets( + roleId, + 0, + accessManagerEnumerable.getRoleTargetCount(roleId) + ); + assertEq(roleTargets.length, 1); + assertEq(roleTargets[0], target); + } + + function _getRandomAdminRoleId() internal returns (uint64) { + uint256 adminRoleId = vm.randomUint(0, 4); + return uint64(adminRoleId); + } + + function _getRandomRoleId() internal returns (uint64) { + uint256 roleId = vm.randomUint(5, type(uint64).max - 1); + return uint64(roleId); } } diff --git a/tests/unit/AssetInterestRateStrategy.t.sol b/tests/unit/AssetInterestRateStrategy.t.sol index 63777d97b..4f1ac38aa 100644 --- a/tests/unit/AssetInterestRateStrategy.t.sol +++ b/tests/unit/AssetInterestRateStrategy.t.sol @@ -107,8 +107,7 @@ contract AssetInterestRateStrategyTest is Base { function test_setInterestRateData_revertsWith_InvalidMaxRate() public { rateData.baseVariableBorrowRate = rateData.variableRateSlope1 = rateData.variableRateSlope2 = - rateStrategy.MAX_BORROW_RATE().toUint32() / - 3 + + rateStrategy.MAX_BORROW_RATE().toUint32() / 3 + 1; encodedRateData = abi.encode(rateData); vm.expectRevert(IAssetInterestRateStrategy.InvalidMaxRate.selector); diff --git a/tests/unit/Hub/Hub.Access.t.sol b/tests/unit/Hub/Hub.Access.t.sol index ca9a7af03..866bd735d 100644 --- a/tests/unit/Hub/Hub.Access.t.sol +++ b/tests/unit/Hub/Hub.Access.t.sol @@ -16,7 +16,7 @@ contract HubAccessTest is HubBase { }); IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: 1000, drawCap: 1000, riskPremiumThreshold: 1000_00 @@ -71,6 +71,12 @@ contract HubAccessTest is HubBase { // Hub Admin can update spoke config vm.prank(HUB_ADMIN); hub1.updateSpokeConfig(assetAId, address(spoke1), spokeConfig); + + // Only registered spoke with Deficit Eliminator or Hub Admin role can eliminate deficit + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, address(this)) + ); + hub1.eliminateDeficit(daiAssetId, 1000, address(spoke1)); } function test_setInterestRateData_access() public { @@ -139,7 +145,7 @@ contract HubAccessTest is HubBase { address(spoke1), IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: 1000, drawCap: 1000, riskPremiumThreshold: 1000_00 @@ -256,7 +262,7 @@ contract HubAccessTest is HubBase { }); IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: 1000, drawCap: 1000, riskPremiumThreshold: 1000_00 diff --git a/tests/unit/Hub/Hub.Add.t.sol b/tests/unit/Hub/Hub.Add.t.sol index 0a462710b..24f00144c 100644 --- a/tests/unit/Hub/Hub.Add.t.sol +++ b/tests/unit/Hub/Hub.Add.t.sol @@ -19,7 +19,7 @@ contract HubAddTest is HubBase { /// @dev add a minimum decimal asset to test add cap rounding IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK @@ -65,18 +65,18 @@ contract HubAddTest is HubBase { vm.stopPrank(); } - function test_add_revertsWith_SpokePaused() public { - _updateSpokePaused(hub1, daiAssetId, address(spoke1), true); + function test_add_revertsWith_SpokeHalted() public { + _updateSpokeHalted(hub1, daiAssetId, address(spoke1), true); vm.startPrank(address(spoke1)); tokenList.dai.transferFrom(alice, address(hub1), 100e18); - vm.expectRevert(IHub.SpokePaused.selector); + vm.expectRevert(IHub.SpokeHalted.selector); hub1.add(daiAssetId, 100e18); vm.stopPrank(); } function test_add_revertsWith_SpokeNotActive() public { - updateSpokeActive(hub1, daiAssetId, address(spoke1), false); + _updateSpokeActive(hub1, daiAssetId, address(spoke1), false); vm.startPrank(address(spoke1)); tokenList.dai.transferFrom(alice, address(hub1), 100e18); diff --git a/tests/unit/Hub/Hub.Config.t.sol b/tests/unit/Hub/Hub.Config.t.sol index 8428a31c7..42cfde53b 100644 --- a/tests/unit/Hub/Hub.Config.t.sol +++ b/tests/unit/Hub/Hub.Config.t.sol @@ -23,9 +23,11 @@ contract HubConfigTest is HubBase { ); } - function test_hub_deploy_revertsWith_InvalidAddress() public { - vm.expectRevert(IHub.InvalidAddress.selector, address(hub1)); - new Hub(address(0)); + function test_hub_deploy_reverts_on_InvalidConstructorInput() public { + DeployWrapper deployer = new DeployWrapper(); + + vm.expectRevert(); + deployer.deployHub(address(0)); } function test_hub_max_riskPremium() public view { @@ -313,7 +315,7 @@ contract HubConfigTest is HubBase { // feeReceiver risk premium threshold defaults to 0 IHub.SpokeConfig memory expectedSpokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: 0, riskPremiumThreshold: 0 @@ -347,6 +349,34 @@ contract HubConfigTest is HubBase { assertEq(hub1.getAssetConfig(assetId), expectedConfig); assertEq(hub1.getAsset(assetId).reinvestmentController, address(0)); // should init to addr(0) assertEq(hub1.getSpokeConfig(assetId, feeReceiver), expectedSpokeConfig); + assertEq(hub1.getAssetId(underlying), expectedAssetId); + } + + function test_isUnderlyingListed() public { + address underlying = address(new TestnetERC20('USDA', 'USDA', 18)); + address feeReceiver = makeAddr('feeReceiver'); + address interestRateStrategy = address(new AssetInterestRateStrategy(address(hub1))); + + assertFalse(hub1.isUnderlyingListed(underlying)); + + Utils.addAsset(hub1, ADMIN, underlying, 18, feeReceiver, interestRateStrategy, encodedIrData); + + assertTrue(hub1.isUnderlyingListed(underlying)); + } + + function test_getAssetId() public view { + assertEq(hub1.getAssetId(address(tokenList.weth)), wethAssetId); + assertEq(hub1.getAssetId(address(tokenList.usdx)), usdxAssetId); + assertEq(hub1.getAssetId(address(tokenList.dai)), daiAssetId); + assertEq(hub1.getAssetId(address(tokenList.wbtc)), wbtcAssetId); + assertEq(hub1.getAssetId(address(tokenList.usdy)), usdyAssetId); + assertEq(hub1.getAssetId(address(tokenList.usdz)), usdzAssetId); + } + + function test_getAssetId_fuzz_revertsWith_AssetNotListed(address underlying) public { + assumeUnusedAddress(underlying); + vm.expectRevert(IHub.AssetNotListed.selector, address(hub1)); + hub1.getAssetId(underlying); } function test_updateAssetConfig_fuzz_revertsWith_InvalidLiquidityFee( @@ -410,7 +440,7 @@ contract HubConfigTest is HubBase { 'custom revert' ); - vm.expectRevert(newConfig.irStrategy); + vm.expectRevert('custom revert', newConfig.irStrategy); vm.prank(HUB_ADMIN); hub1.updateAssetConfig(assetId, newConfig, encodedIrData); } @@ -422,6 +452,7 @@ contract HubConfigTest is HubBase { assetId = bound(assetId, 0, hub1.getAssetCount() - 1); _assumeValidAssetConfig(newConfig); assumeUnusedAddress(newConfig.irStrategy); + newConfig.feeReceiver = hub1.getAssetConfig(assetId).feeReceiver; // retain fee receiver vm.mockCallRevert( newConfig.irStrategy, @@ -429,7 +460,7 @@ contract HubConfigTest is HubBase { 'custom revert' ); - vm.expectRevert(address(newConfig.irStrategy)); + vm.expectRevert('custom revert', newConfig.irStrategy); vm.prank(HUB_ADMIN); hub1.updateAssetConfig(assetId, newConfig, encodedIrData); } @@ -469,7 +500,7 @@ contract HubConfigTest is HubBase { oldFeeReceiver, IHub.SpokeConfig({ active: oldFeeReceiverConfig.active, - paused: oldFeeReceiverConfig.paused, + halted: oldFeeReceiverConfig.halted, addCap: 0, drawCap: 0, riskPremiumThreshold: 0 @@ -484,7 +515,7 @@ contract HubConfigTest is HubBase { newConfig.feeReceiver, IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: 0, riskPremiumThreshold: 0 @@ -583,28 +614,28 @@ contract HubConfigTest is HubBase { assertEq(spokeConfig.drawCap, 0, 'old fee receiver draw cap'); } - /// Updates the fee receiver to a new spoke; old fee receiver active/paused flags are preserved + /// Updates the fee receiver to a new spoke; old fee receiver active/halted flags are preserved function test_updateAssetConfig_oldFeeReceiver_flags() public { - _test_updateAssetConfig_oldFeeReceiver_flags({active: true, paused: true}); - _test_updateAssetConfig_oldFeeReceiver_flags({active: true, paused: false}); - _test_updateAssetConfig_oldFeeReceiver_flags({active: false, paused: true}); - _test_updateAssetConfig_oldFeeReceiver_flags({active: false, paused: false}); + _test_updateAssetConfig_oldFeeReceiver_flags({active: true, halted: true}); + _test_updateAssetConfig_oldFeeReceiver_flags({active: true, halted: false}); + _test_updateAssetConfig_oldFeeReceiver_flags({active: false, halted: true}); + _test_updateAssetConfig_oldFeeReceiver_flags({active: false, halted: false}); } - function _test_updateAssetConfig_oldFeeReceiver_flags(bool active, bool paused) internal { + function _test_updateAssetConfig_oldFeeReceiver_flags(bool active, bool halted) internal { uint256 assetId = _randomAssetId(hub1); address oldFeeReceiver = _getFeeReceiver(hub1, assetId); IHub.SpokeConfig memory oldFeeReceiverConfig = hub1.getSpokeConfig(assetId, oldFeeReceiver); oldFeeReceiverConfig.active = active; - oldFeeReceiverConfig.paused = paused; + oldFeeReceiverConfig.halted = halted; // update old fee receiver config flags Utils.updateSpokeConfig(hub1, ADMIN, assetId, oldFeeReceiver, oldFeeReceiverConfig); assertEq(hub1.getSpokeConfig(assetId, oldFeeReceiver).active, active); - assertEq(hub1.getSpokeConfig(assetId, oldFeeReceiver).paused, paused); + assertEq(hub1.getSpokeConfig(assetId, oldFeeReceiver).halted, halted); - // update asset config to new fee receiver; old fee receiver paused/active flags should be unchanged + // update asset config to new fee receiver; old fee receiver halted/active flags should be unchanged IHub.AssetConfig memory config = hub1.getAssetConfig(assetId); config.feeReceiver = makeAddr('newFeeReceiver'); test_updateAssetConfig_fuzz(assetId, config); @@ -616,9 +647,9 @@ contract HubConfigTest is HubBase { 'old fee receiver active' ); assertEq( - hub1.getSpokeConfig(assetId, oldFeeReceiver).paused, - paused, - 'old fee receiver paused' + hub1.getSpokeConfig(assetId, oldFeeReceiver).halted, + halted, + 'old fee receiver halted' ); } @@ -631,7 +662,7 @@ contract HubConfigTest is HubBase { _drawLiquidity(assetId, amount, true); skip(365 days); - updateSpokeActive(hub1, assetId, _getFeeReceiver(hub1, assetId), false); + _updateSpokeActive(hub1, assetId, _getFeeReceiver(hub1, assetId), false); IHub.AssetConfig memory config = hub1.getAssetConfig(assetId); config.feeReceiver = makeAddr('newFeeReceiver'); @@ -650,7 +681,7 @@ contract HubConfigTest is HubBase { Utils.mintFeeShares(hub1, assetId, ADMIN); - updateSpokeActive(hub1, assetId, _getFeeReceiver(hub1, assetId), false); + _updateSpokeActive(hub1, assetId, _getFeeReceiver(hub1, assetId), false); IHub.AssetConfig memory config = hub1.getAssetConfig(assetId); config.feeReceiver = makeAddr('newFeeReceiver'); @@ -685,21 +716,6 @@ contract HubConfigTest is HubBase { assertEq(hub1.getSpokeAddedShares(assetId, newFeeReceiver), newFees); } - /// Updates the fee receiver to an existing spoke of the hub1, so ends up with existing supplied shares plus accrued fees - function test_updateAssetConfig_fuzz_UseExistingSpokeAsFeeReceiver_revertsWith_SpokeAlreadyListed( - uint256 assetId - ) public { - assetId = vm.randomUint(0, hub1.getAssetCount() - 1); - address newFeeReceiver = address(spoke1); - - IHub.AssetConfig memory config = hub1.getAssetConfig(assetId); - config.feeReceiver = newFeeReceiver; - - vm.expectRevert(IHub.SpokeAlreadyListed.selector, address(hub1)); - vm.prank(HUB_ADMIN); - hub1.updateAssetConfig(assetId, config, new bytes(0)); - } - /// Updates the fee receiver to an existing spoke of the hub1 which is already listed on the asset function test_updateAssetConfig_UseExistingSpokeAndListedAsFeeReceiver_revertsWith_SpokeAlreadyListed() public @@ -806,7 +822,7 @@ contract HubConfigTest is HubBase { function _assumeValidAssetConfig(IHub.AssetConfig memory newConfig) internal pure { newConfig.liquidityFee = bound(newConfig.liquidityFee, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); - vm.assume(address(newConfig.feeReceiver) != address(0) || newConfig.liquidityFee == 0); + assumeNotZeroAddress(newConfig.feeReceiver); assumeNotPrecompile(newConfig.feeReceiver); assumeNotForgeAddress(newConfig.feeReceiver); assumeNotZeroAddress(newConfig.irStrategy); diff --git a/tests/unit/Hub/Hub.Draw.t.sol b/tests/unit/Hub/Hub.Draw.t.sol index 3771bef6f..1f7cdf63e 100644 --- a/tests/unit/Hub/Hub.Draw.t.sol +++ b/tests/unit/Hub/Hub.Draw.t.sol @@ -140,7 +140,7 @@ contract HubDrawTest is HubBase { emit IHubBase.Draw(assetId, address(spoke1), shares, amount); vm.prank(address(spoke1)); - hub1.draw(assetId, amount, alice); + uint256 drawnShares = hub1.draw(assetId, amount, alice); assertEq( hub1.getAsset(assetId).liquidity, @@ -152,20 +152,21 @@ contract HubDrawTest is HubBase { assetBefore.drawnShares + shares, 'drawnShares after draw' ); + assertGe(hub1.previewDrawByShares(assetId, drawnShares), amount); _assertBorrowRateSynced(hub1, assetId, 'hub1.draw'); _assertHubLiquidity(hub1, assetId, 'hub1.draw'); } - function test_draw_revertsWith_SpokePaused() public { - _updateSpokePaused(hub1, daiAssetId, address(spoke1), true); - vm.expectRevert(IHub.SpokePaused.selector); + function test_draw_revertsWith_SpokeHalted() public { + _updateSpokeHalted(hub1, daiAssetId, address(spoke1), true); + vm.expectRevert(IHub.SpokeHalted.selector); vm.prank(address(spoke1)); hub1.draw(daiAssetId, 100e18, alice); } function test_draw_revertsWith_SpokeNotActive() public { - updateSpokeActive(hub1, daiAssetId, address(spoke1), false); + _updateSpokeActive(hub1, daiAssetId, address(spoke1), false); vm.expectRevert(IHub.SpokeNotActive.selector); vm.prank(address(spoke1)); hub1.draw(daiAssetId, 100e18, alice); diff --git a/tests/unit/Hub/Hub.EliminateDeficit.t.sol b/tests/unit/Hub/Hub.EliminateDeficit.t.sol index dfb253b6a..962b90bc6 100644 --- a/tests/unit/Hub/Hub.EliminateDeficit.t.sol +++ b/tests/unit/Hub/Hub.EliminateDeficit.t.sol @@ -5,8 +5,8 @@ pragma solidity ^0.8.0; import 'tests/unit/Hub/HubBase.t.sol'; contract HubEliminateDeficitTest is HubBase { - using WadRayMath for uint256; - using MathUtils for uint256; + using WadRayMath for *; + using MathUtils for *; using SafeCast for uint256; uint256 internal _assetId; @@ -22,6 +22,8 @@ contract HubEliminateDeficitTest is HubBase { _callerSpoke = address(spoke2); _coveredSpoke = address(spoke1); _otherSpoke = address(spoke3); + + grantDeficitEliminatorRole(hub1, address(_callerSpoke)); } function test_eliminateDeficit_revertsWith_InvalidAmount_ZeroAmountNoDeficit() public { @@ -32,12 +34,29 @@ contract HubEliminateDeficitTest is HubBase { function test_eliminateDeficit_revertsWith_InvalidAmount_ZeroAmountWithDeficit() public { _createDeficit(_assetId, _coveredSpoke, _deficitAmountRay); - assertEq(hub1.getSpokeDeficitRay(_assetId, _coveredSpoke), _deficitAmountRay); vm.expectRevert(IHub.InvalidAmount.selector); vm.prank(_callerSpoke); hub1.eliminateDeficit(_assetId, 0, _coveredSpoke); } + function test_eliminateDeficit_revertsWith_SpokeNotActive_on_UnregisteredAsset() public { + _createDeficit(_assetId, _coveredSpoke, _deficitAmountRay); + assertEq(hub1.getSpokeDeficitRay(_assetId, _coveredSpoke), _deficitAmountRay); + + uint256 invalidAssetId = vm.randomUint(hub1.getAssetCount() + 1, UINT256_MAX); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(_callerSpoke); + hub1.eliminateDeficit(invalidAssetId, vm.randomUint(1, UINT256_MAX), vm.randomAddress()); + } + + function test_eliminateDeficit_revertsWith_InvalidAmount_on_UnregisteredCoveredSpoke() public { + // since amount is bounded to covered spoke deficit, deficit to be eliminated bounds to 0 + vm.expectRevert(IHub.InvalidAmount.selector); + vm.prank(_callerSpoke); + hub1.eliminateDeficit(_assetId, vm.randomUint(1, UINT256_MAX), alice); // alice is not a spoke + } + // Caller spoke does not have funds function test_eliminateDeficit_fuzz_revertsWith_ArithmeticUnderflow_CallerSpokeNoFunds( uint256 @@ -48,23 +67,28 @@ contract HubEliminateDeficitTest is HubBase { hub1.eliminateDeficit(_assetId, vm.randomUint(_deficitAmountRay, UINT256_MAX), _coveredSpoke); } - function test_eliminateDeficit_fuzz_revertsWith_callerSpokeNotActive(address caller) public { - vm.assume(!hub1.getSpoke(_assetId, caller).active); - vm.expectRevert(IHub.SpokeNotActive.selector); + function test_eliminateDeficit_fuzz_revertsWith_AccessManagedUnauthorized(address caller) public { + (bool immediate, uint32 delay) = IAccessManager(hub1.authority()).canCall( + caller, + address(hub1), + IHub.eliminateDeficit.selector + ); + vm.assume(!immediate || delay > 0); + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, caller) + ); vm.prank(caller); hub1.eliminateDeficit(_assetId, vm.randomUint(), _coveredSpoke); } - /// @dev paused but active spokes are allowed to eliminate deficit - function test_eliminateDeficit_allowSpokePaused() public { - _createDeficit(_assetId, _coveredSpoke, _deficitAmountRay); - Utils.add(hub1, _assetId, _callerSpoke, _deficitAmountRay.fromRayUp() + 1, alice); - - updateSpokeActive(hub1, _assetId, _callerSpoke, true); - _updateSpokePaused(hub1, _assetId, _callerSpoke, true); + function test_eliminateDeficit_revertsWith_callerSpokeNotActive() public { + address caller = address(spoke1); + _updateSpokeActive(hub1, _assetId, caller, false); + grantDeficitEliminatorRole(hub1, caller); - vm.prank(_callerSpoke); - hub1.eliminateDeficit(_assetId, _deficitAmountRay.fromRayUp(), _coveredSpoke); + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(caller); + hub1.eliminateDeficit(_assetId, vm.randomUint(), _coveredSpoke); } function test_eliminateDeficit(uint256) public { @@ -139,7 +163,11 @@ contract HubEliminateDeficitTest is HubBase { restoredPremiumRay: amountRay }); + uint256 deficitBeforeRay = hub1.getSpokeDeficitRay(assetId, spoke); + vm.prank(spoke); hub1.reportDeficit(assetId, 0, premiumDelta); + + assertEq(hub1.getSpokeDeficitRay(assetId, spoke), deficitBeforeRay + amountRay); } } diff --git a/tests/unit/Hub/Hub.MintFeeShares.t.sol b/tests/unit/Hub/Hub.MintFeeShares.t.sol index 581756ba3..443adb4a1 100644 --- a/tests/unit/Hub/Hub.MintFeeShares.t.sol +++ b/tests/unit/Hub/Hub.MintFeeShares.t.sol @@ -26,11 +26,17 @@ contract HubMintFeeSharesTest is HubBase { skipTime: 365 days }); - updateSpokeActive(hub1, daiAssetId, _getFeeReceiver(hub1, daiAssetId), false); + _updateSpokeActive(hub1, daiAssetId, _getFeeReceiver(hub1, daiAssetId), false); vm.expectRevert(IHub.SpokeNotActive.selector, address(hub1)); Utils.mintFeeShares(hub1, daiAssetId, ADMIN); } + function test_mintFeeShares_revertsWith_AssetNotListed() public { + uint256 invalidAssetId = hub1.getAssetCount(); + vm.expectRevert(IHub.AssetNotListed.selector); + Utils.mintFeeShares(hub1, invalidAssetId, ADMIN); + } + function test_mintFeeShares() public { // Create debt to build up fees on the existing treasury spoke _addAndDrawLiquidity({ @@ -99,7 +105,7 @@ contract HubMintFeeSharesTest is HubBase { IHub.Asset memory asset = hub1.getAsset(daiAssetId); // pausing the fee receiver does not revert the action since no shares are minted - updateSpokeActive(hub1, daiAssetId, _getFeeReceiver(hub1, daiAssetId), false); + _updateSpokeActive(hub1, daiAssetId, _getFeeReceiver(hub1, daiAssetId), false); vm.expectEmit(address(hub1)); emit IHub.UpdateAsset(daiAssetId, asset.drawnIndex, asset.drawnRate, 0); diff --git a/tests/unit/Hub/Hub.PayFee.t.sol b/tests/unit/Hub/Hub.PayFee.t.sol index 2a286557f..bf9e93fab 100644 --- a/tests/unit/Hub/Hub.PayFee.t.sol +++ b/tests/unit/Hub/Hub.PayFee.t.sol @@ -12,7 +12,7 @@ contract HubPayFeeTest is HubBase { } function test_payFee_revertsWith_SpokeNotActive() public { - updateSpokeActive(hub1, daiAssetId, address(spoke1), false); + _updateSpokeActive(hub1, daiAssetId, address(spoke1), false); vm.expectRevert(IHub.SpokeNotActive.selector, address(hub1)); vm.prank(address(spoke1)); hub1.payFeeShares(daiAssetId, 1); diff --git a/tests/unit/Hub/Hub.Reclaim.t.sol b/tests/unit/Hub/Hub.Reclaim.t.sol index d5c7f2c4d..cfe4cf40b 100644 --- a/tests/unit/Hub/Hub.Reclaim.t.sol +++ b/tests/unit/Hub/Hub.Reclaim.t.sol @@ -5,6 +5,8 @@ pragma solidity ^0.8.0; import 'tests/unit/Hub/HubBase.t.sol'; contract HubReclaimTest is HubBase { + using SafeERC20 for *; + function test_reclaim_revertsWith_AssetNotListed() public { uint256 assetId = _randomInvalidAssetId(hub1); vm.expectRevert(IHub.AssetNotListed.selector); @@ -36,14 +38,33 @@ contract HubReclaimTest is HubBase { hub1.reclaim(daiAssetId, 0); } - function test_reclaim_revertsWith_underflow_exceedsSwept() public { + function test_reclaim_revertsWith_InsufficientTransferred() public { + uint256 supplyAmount = 1000e18; + uint256 sweepAmount = 500e18; + + address reinvestmentController = makeAddr('reinvestmentController'); + updateAssetReinvestmentController(hub1, daiAssetId, reinvestmentController); + + _addLiquidity(daiAssetId, supplyAmount); + + vm.prank(reinvestmentController); + hub1.sweep(daiAssetId, sweepAmount); + + uint256 reclaimAmount = 200e18; + + vm.expectRevert(abi.encodeWithSelector(IHub.InsufficientTransferred.selector, reclaimAmount)); + vm.prank(reinvestmentController); + hub1.reclaim(daiAssetId, reclaimAmount); + } + + function test_reclaim_revertsWith_InsufficientTransferred_noSwept() public { address reinvestmentController = makeAddr('reinvestmentController'); updateAssetReinvestmentController(hub1, daiAssetId, reinvestmentController); assertEq(hub1.getAssetSwept(daiAssetId), 0); + vm.expectRevert(abi.encodeWithSelector(IHub.InsufficientTransferred.selector, 1)); vm.prank(reinvestmentController); - vm.expectRevert(stdError.arithmeticError); hub1.reclaim(daiAssetId, 1); } @@ -61,6 +82,10 @@ contract HubReclaimTest is HubBase { assertEq(hub1.getAssetSwept(daiAssetId), sweepAmount); + deal(address(tokenList.dai), reinvestmentController, sweepAmount + 1); + vm.prank(reinvestmentController); + tokenList.dai.safeTransfer(address(hub1), sweepAmount + 1); + vm.prank(reinvestmentController); vm.expectRevert(stdError.arithmeticError); hub1.reclaim(daiAssetId, sweepAmount + 1); @@ -97,10 +122,7 @@ contract HubReclaimTest is HubBase { deal(address(tokenList.dai), reinvestmentController, reclaimAmount); vm.prank(reinvestmentController); - tokenList.dai.approve(address(hub1), reclaimAmount); - - vm.expectEmit(address(tokenList.dai)); - emit IERC20.Transfer(reinvestmentController, address(hub1), reclaimAmount); + tokenList.dai.safeTransfer(address(hub1), reclaimAmount); vm.expectEmit(address(hub1)); emit IHub.Reclaim(daiAssetId, reinvestmentController, reclaimAmount); @@ -130,7 +152,7 @@ contract HubReclaimTest is HubBase { deal(address(tokenList.dai), reinvestmentController, sweepAmount); vm.prank(reinvestmentController); - tokenList.dai.approve(address(hub1), sweepAmount); + tokenList.dai.safeTransfer(address(hub1), sweepAmount); vm.prank(reinvestmentController); hub1.reclaim(daiAssetId, sweepAmount); @@ -166,7 +188,7 @@ contract HubReclaimTest is HubBase { uint256 firstReclaim = 100e18; deal(address(tokenList.dai), reinvestmentController, firstReclaim); vm.prank(reinvestmentController); - tokenList.dai.approve(address(hub1), firstReclaim); + tokenList.dai.safeTransfer(address(hub1), firstReclaim); vm.prank(reinvestmentController); hub1.reclaim(daiAssetId, firstReclaim); @@ -178,7 +200,7 @@ contract HubReclaimTest is HubBase { uint256 secondReclaim = 150e18; deal(address(tokenList.dai), reinvestmentController, secondReclaim); vm.prank(reinvestmentController); - tokenList.dai.approve(address(hub1), secondReclaim); + tokenList.dai.safeTransfer(address(hub1), secondReclaim); vm.prank(reinvestmentController); hub1.reclaim(daiAssetId, secondReclaim); diff --git a/tests/unit/Hub/Hub.RefreshPremium.t.sol b/tests/unit/Hub/Hub.RefreshPremium.t.sol index 1b9405b21..d195c9354 100644 --- a/tests/unit/Hub/Hub.RefreshPremium.t.sol +++ b/tests/unit/Hub/Hub.RefreshPremium.t.sol @@ -17,20 +17,20 @@ contract HubRefreshPremiumTest is HubBase { function test_refreshPremium_revertsWith_SpokeNotActive() public { IHubBase.PremiumDelta memory premiumDelta; - updateSpokeActive(hub1, daiAssetId, address(spoke1), false); + _updateSpokeActive(hub1, daiAssetId, address(spoke1), false); vm.expectRevert(IHub.SpokeNotActive.selector); vm.prank(address(spoke1)); hub1.refreshPremium(daiAssetId, premiumDelta); } function _createDrawnSharesAndPremiumData() internal { - Utils.supplyCollateral(spoke1, _wbtcReserveId(spoke1), bob, MAX_SUPPLY_AMOUNT, bob); + Utils.supplyCollateral(spoke1, _wbtcReserveId(spoke1), bob, MAX_SUPPLY_AMOUNT_WBTC, bob); - uint256 amount1 = vm.randomUint(1, MAX_SUPPLY_AMOUNT / 2); - uint256 amount2 = vm.randomUint(1, MAX_SUPPLY_AMOUNT - amount1); + uint256 amount1 = vm.randomUint(1, MAX_SUPPLY_AMOUNT_DAI / 2); + uint256 amount2 = vm.randomUint(1, MAX_SUPPLY_AMOUNT_DAI - amount1); // create drawn shares and premium data - _addLiquidity(daiAssetId, MAX_SUPPLY_AMOUNT); + _addLiquidity(daiAssetId, MAX_SUPPLY_AMOUNT_DAI); Utils.borrow(spoke1, _daiReserveId(spoke1), bob, amount1, bob); skip(322 days); Utils.borrow(spoke1, _daiReserveId(spoke1), bob, amount2, bob); @@ -164,10 +164,10 @@ contract HubRefreshPremiumTest is HubBase { hub1.refreshPremium(daiAssetId, premiumDelta); } - /// @dev paused but active spokes are allowed to refresh premium - function test_refreshPremium_pausedSpokesAllowed() public { - updateSpokeActive(hub1, daiAssetId, address(spoke1), true); - _updateSpokePaused(hub1, daiAssetId, address(spoke1), true); + /// @dev halted but active spokes are allowed to refresh premium + function test_refreshPremium_haltedSpokesAllowed() public { + _updateSpokeActive(hub1, daiAssetId, address(spoke1), true); + _updateSpokeHalted(hub1, daiAssetId, address(spoke1), true); vm.expectEmit(address(hub1)); emit IHubBase.RefreshPremium(daiAssetId, address(spoke1), ZERO_PREMIUM_DELTA); @@ -227,6 +227,18 @@ contract HubRefreshPremiumTest is HubBase { restoredPremiumRay: 0 }); + if (borrowAmount > 0) { + // set max risk premium threshold to allow borrow to occur + _updateSpokeRiskPremiumThreshold( + hub1, + daiAssetId, + address(spoke1), + Constants.MAX_RISK_PREMIUM_THRESHOLD + ); + Utils.supplyCollateral(spoke1, _daiReserveId(spoke1), bob, borrowAmount * 2, bob); + Utils.borrow(spoke1, _daiReserveId(spoke1), bob, borrowAmount, bob); + } + uint24 riskPremiumThreshold = vm .randomUint(0, Constants.MAX_RISK_PREMIUM_THRESHOLD - 1) .toUint24(); @@ -236,11 +248,6 @@ contract HubRefreshPremiumTest is HubBase { } _updateSpokeRiskPremiumThreshold(hub1, daiAssetId, address(spoke1), riskPremiumThreshold); - if (borrowAmount > 0) { - Utils.supplyCollateral(spoke1, _daiReserveId(spoke1), bob, borrowAmount * 2, bob); - Utils.borrow(spoke1, _daiReserveId(spoke1), bob, borrowAmount, bob); - } - PremiumDataLocal memory premiumDataBefore = _loadAssetPremiumData(hub1, daiAssetId); (, uint256 premiumBefore) = hub1.getAssetOwed(daiAssetId); bool reverting; @@ -265,7 +272,7 @@ contract HubRefreshPremiumTest is HubBase { } else if ( riskPremiumThreshold != Constants.MAX_RISK_PREMIUM_THRESHOLD && asset.drawnShares.percentMulUp(riskPremiumThreshold) < - asset.premiumShares + sharesDelta.toUint256() + asset.premiumShares + sharesDelta.toUint256() ) { reverting = true; vm.expectRevert(IHub.InvalidPremiumChange.selector); @@ -381,7 +388,6 @@ contract HubRefreshPremiumTest is HubBase { Utils.supplyCollateral(spoke1, _daiReserveId(spoke1), bob, MAX_SUPPLY_AMOUNT, bob); Utils.borrow(spoke1, _daiReserveId(spoke1), bob, borrowAmount, bob); skip(skipTime); - Utils.borrow(spoke1, _daiReserveId(spoke1), bob, 1e18, bob); IHub.Asset memory asset = hub1.getAsset(assetId); PremiumDataLocal memory premiumDataBefore = _loadAssetPremiumData(hub1, assetId); diff --git a/tests/unit/Hub/Hub.Remove.t.sol b/tests/unit/Hub/Hub.Remove.t.sol index 328c76f46..0eac78169 100644 --- a/tests/unit/Hub/Hub.Remove.t.sol +++ b/tests/unit/Hub/Hub.Remove.t.sol @@ -507,15 +507,15 @@ contract HubRemoveTest is HubBase { hub1.remove(daiAssetId, 0, alice); } - function test_remove_revertsWith_SpokePaused() public { - _updateSpokePaused(hub1, daiAssetId, address(spoke1), true); - vm.expectRevert(IHub.SpokePaused.selector); + function test_remove_revertsWith_SpokeHalted() public { + _updateSpokeHalted(hub1, daiAssetId, address(spoke1), true); + vm.expectRevert(IHub.SpokeHalted.selector); vm.prank(address(spoke1)); hub1.remove(daiAssetId, 100e18, alice); } function test_remove_revertsWith_SpokeNotActive() public { - updateSpokeActive(hub1, daiAssetId, address(spoke1), false); + _updateSpokeActive(hub1, daiAssetId, address(spoke1), false); vm.expectRevert(IHub.SpokeNotActive.selector); vm.prank(address(spoke1)); hub1.remove(daiAssetId, 100e18, alice); diff --git a/tests/unit/Hub/Hub.ReportDeficit.t.sol b/tests/unit/Hub/Hub.ReportDeficit.t.sol index e4849e07d..fab37484e 100644 --- a/tests/unit/Hub/Hub.ReportDeficit.t.sol +++ b/tests/unit/Hub/Hub.ReportDeficit.t.sol @@ -28,9 +28,9 @@ contract HubReportDeficitTest is HubBase { super.setUp(); // deploy borrowable liquidity - _addLiquidity(daiAssetId, MAX_SUPPLY_AMOUNT); - _addLiquidity(wethAssetId, MAX_SUPPLY_AMOUNT); - _addLiquidity(usdxAssetId, MAX_SUPPLY_AMOUNT); + _addLiquidity(daiAssetId, MAX_SUPPLY_AMOUNT_DAI); + _addLiquidity(wethAssetId, MAX_SUPPLY_AMOUNT_WETH); + _addLiquidity(usdxAssetId, MAX_SUPPLY_AMOUNT_USDX); } function test_reportDeficit_revertsWith_SpokeNotActive(address caller) public { @@ -39,7 +39,7 @@ contract HubReportDeficitTest is HubBase { vm.expectRevert(IHub.SpokeNotActive.selector); vm.prank(caller); - hub1.reportDeficit(usdxAssetId, 0, ZERO_PREMIUM_DELTA); + hub1.reportDeficit(usdxAssetId, 1, ZERO_PREMIUM_DELTA); } function test_reportDeficit_revertsWith_InvalidAmount() public { @@ -52,10 +52,16 @@ contract HubReportDeficitTest is HubBase { function test_reportDeficit_fuzz_revertsWith_SurplusDrawnDeficitReported( uint256 drawnAmount ) public { - drawnAmount = bound(drawnAmount, 1, MAX_SUPPLY_AMOUNT); + drawnAmount = bound(drawnAmount, 1, MAX_SUPPLY_AMOUNT_USDX); // draw usdx liquidity to be restored - _drawLiquidity(usdxAssetId, drawnAmount, true, true, address(spoke1)); + _drawLiquidity({ + assetId: usdxAssetId, + amount: drawnAmount, + withPremium: true, + skipTime: true, + spoke: address(spoke1) + }); (uint256 drawn, uint256 premium) = hub1.getSpokeOwed(usdxAssetId, address(spoke1)); assertGt(drawn, 0); @@ -94,7 +100,7 @@ contract HubReportDeficitTest is HubBase { function test_reportDeficit_fuzz_revertsWith_SurplusPremiumRayDeficitReported( uint256 drawnAmount ) public { - drawnAmount = bound(drawnAmount, 1, MAX_SUPPLY_AMOUNT); + drawnAmount = bound(drawnAmount, 1, MAX_SUPPLY_AMOUNT_USDX); // draw usdx liquidity to be restored _drawLiquidity(usdxAssetId, drawnAmount, true, true, address(spoke1)); @@ -112,7 +118,7 @@ contract HubReportDeficitTest is HubBase { ); uint256 drawnDeficit = vm.randomUint(0, drawn); - uint256 premiumDeficitRay = vm.randomUint(spokePremiumRay + 1, UINT256_MAX); + uint256 premiumDeficitRay = vm.randomUint(spokePremiumRay + 1, 2 ** 255 - 1); vm.expectRevert( abi.encodeWithSelector(IHub.SurplusPremiumRayDeficitReported.selector, spokePremiumRay) @@ -130,6 +136,24 @@ contract HubReportDeficitTest is HubBase { ); } + /// @dev halted spoke can still report deficit + function test_reportDeficit_halted() public { + // draw usdx liquidity to be restored + _drawLiquidity({ + assetId: usdxAssetId, + amount: 1, + withPremium: true, + skipTime: true, + spoke: address(spoke1) + }); + + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + + // even if spoke is halted, it can report deficit + vm.prank(address(spoke1)); + hub1.reportDeficit(usdxAssetId, 1, ZERO_PREMIUM_DELTA); + } + function test_reportDeficit_with_premium() public { uint256 drawnAmount = 10_000e6; test_reportDeficit_fuzz_with_premium({ @@ -146,7 +170,7 @@ contract HubReportDeficitTest is HubBase { uint256 premiumAmountRay, uint256 skipTime ) public { - drawnAmount = bound(drawnAmount, 1, MAX_SUPPLY_AMOUNT); + drawnAmount = bound(drawnAmount, 1, MAX_SUPPLY_AMOUNT_USDX); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); ReportDeficitTestParams memory params; diff --git a/tests/unit/Hub/Hub.Rescue.t.sol b/tests/unit/Hub/Hub.Rescue.t.sol index e345c8beb..099b6fd1b 100644 --- a/tests/unit/Hub/Hub.Rescue.t.sol +++ b/tests/unit/Hub/Hub.Rescue.t.sol @@ -14,7 +14,7 @@ contract HubRescueTest is HubBase { IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK diff --git a/tests/unit/Hub/Hub.Restore.t.sol b/tests/unit/Hub/Hub.Restore.t.sol index 996c0a64e..8c8f804e3 100644 --- a/tests/unit/Hub/Hub.Restore.t.sol +++ b/tests/unit/Hub/Hub.Restore.t.sol @@ -11,17 +11,13 @@ contract HubRestoreTest is HubBase { using SafeCast for *; HubConfigurator public hubConfigurator; - address public HUB_CONFIGURATOR_ADMIN = makeAddr('HUB_CONFIGURATOR_ADMIN'); function setUp() public override { super.setUp(); - // Set up a hub configurator to test freezing and pausing assets - hubConfigurator = new HubConfigurator(HUB_CONFIGURATOR_ADMIN); - IAccessManager accessManager = IAccessManager(hub1.authority()); - // Grant hubConfigurator hub admin role with 0 delay - vm.prank(ADMIN); - accessManager.grantRole(Roles.HUB_ADMIN_ROLE, address(hubConfigurator), 0); + // Set up a hub configurator to test resetting asset caps and pausing assets + hubConfigurator = new HubConfigurator(hub1.authority()); + setUpHubConfiguratorRoles(address(hubConfigurator), hub1.authority()); } function test_restore_revertsWith_SurplusDrawnRestored() public { @@ -123,7 +119,7 @@ contract HubRestoreTest is HubBase { } function test_restore_revertsWith_SpokeNotActive_whenPaused() public { - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.deactivateAsset(address(hub1), daiAssetId); IHubBase.PremiumDelta memory premiumDelta = _getExpectedPremiumDelta( @@ -138,8 +134,8 @@ contract HubRestoreTest is HubBase { hub1.restore(daiAssetId, 1, premiumDelta); } - function test_restore_revertsWith_SpokePaused() public { - _updateSpokePaused(hub1, daiAssetId, address(spoke1), true); + function test_restore_revertsWith_SpokeHalted() public { + _updateSpokeHalted(hub1, daiAssetId, address(spoke1), true); IHubBase.PremiumDelta memory premiumDelta = _getExpectedPremiumDelta( spoke1, @@ -148,7 +144,7 @@ contract HubRestoreTest is HubBase { 0 ); - vm.expectRevert(IHub.SpokePaused.selector); + vm.expectRevert(IHub.SpokeHalted.selector); vm.prank(address(spoke1)); hub1.restore(daiAssetId, 1, premiumDelta); } @@ -187,8 +183,8 @@ contract HubRestoreTest is HubBase { vm.stopPrank(); } - /// @dev It's possible to restore even when asset is frozen - function test_restore_when_asset_frozen() public { + /// @dev It's possible to restore even when asset caps are reset + function test_restore_when_asset_caps_reset() public { uint256 daiAmount = 100e18; uint256 drawAmount = daiAmount / 2; @@ -210,9 +206,9 @@ contract HubRestoreTest is HubBase { amount: drawAmount }); - // Freeze asset - vm.prank(HUB_CONFIGURATOR_ADMIN); - hubConfigurator.freezeAsset(address(hub1), daiAssetId); + // Reset asset caps + vm.prank(HUB_CONFIGURATOR); + hubConfigurator.resetAssetCaps(address(hub1), daiAssetId); (uint256 drawn, uint256 premium) = hub1.getSpokeOwed(daiAssetId, address(spoke1)); uint256 drawnRestored = drawn / 2; diff --git a/tests/unit/Hub/Hub.Skim.t.sol b/tests/unit/Hub/Hub.Skim.t.sol index 474ffaa50..b046aad81 100644 --- a/tests/unit/Hub/Hub.Skim.t.sol +++ b/tests/unit/Hub/Hub.Skim.t.sol @@ -18,7 +18,7 @@ contract HubSkimTest is HubBase { /// @dev add a minimum decimal asset to test add cap rounding IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK diff --git a/tests/unit/Hub/Hub.SpokeConfig.t.sol b/tests/unit/Hub/Hub.SpokeConfig.t.sol new file mode 100644 index 000000000..c79d7cc1f --- /dev/null +++ b/tests/unit/Hub/Hub.SpokeConfig.t.sol @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/Hub/HubBase.t.sol'; + +contract HubSpokeConfigTest is HubBase { + function setUp() public override { + super.setUp(); + + // deploy borrowable liquidity + _addLiquidity(usdxAssetId, MAX_SUPPLY_AMOUNT); + } + + function test_mintFeeShares_active_halted_scenarios() public { + address feeReceiver = _getFeeReceiver(hub1, usdxAssetId); + + // set spoke to active / halted; reverts + _accrueLiquidityFees(hub1, spoke1, usdxAssetId); + _updateSpokeHalted(hub1, usdxAssetId, feeReceiver, true); + _updateSpokeActive(hub1, usdxAssetId, feeReceiver, true); + + vm.prank(HUB_ADMIN); + hub1.mintFeeShares(usdxAssetId); + + // set spoke to inactive / halted; reverts + _accrueLiquidityFees(hub1, spoke1, usdxAssetId); + _updateSpokeHalted(hub1, usdxAssetId, feeReceiver, true); + _updateSpokeActive(hub1, usdxAssetId, feeReceiver, false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(HUB_ADMIN); + hub1.mintFeeShares(usdxAssetId); + + // set spoke to active / not halted; succeeds + _accrueLiquidityFees(hub1, spoke1, usdxAssetId); + _updateSpokeHalted(hub1, usdxAssetId, feeReceiver, false); + _updateSpokeActive(hub1, usdxAssetId, feeReceiver, true); + + vm.prank(HUB_ADMIN); + hub1.mintFeeShares(usdxAssetId); + + // set spoke to inactive / not halted; reverts + _accrueLiquidityFees(hub1, spoke1, usdxAssetId); + _updateSpokeHalted(hub1, usdxAssetId, feeReceiver, false); + _updateSpokeActive(hub1, usdxAssetId, feeReceiver, false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(HUB_ADMIN); + hub1.mintFeeShares(usdxAssetId); + } + + function test_add_active_halted_scenarios() public { + // set spoke to active / halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + vm.expectRevert(IHub.SpokeHalted.selector); + vm.prank(address(spoke1)); + hub1.add(usdxAssetId, 1); + + // set spoke to inactive / halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.add(usdxAssetId, 1); + + // set spoke to active / not halted; succeeds + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + Utils.add(hub1, usdxAssetId, address(spoke1), 1, alice); + + // set spoke to inactive / not halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.add(usdxAssetId, 1); + } + + function test_remove_active_halted_scenarios() public { + Utils.add(hub1, usdxAssetId, address(spoke1), 100, alice); + + // set spoke to active / halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + vm.expectRevert(IHub.SpokeHalted.selector); + vm.prank(address(spoke1)); + hub1.remove(usdxAssetId, 1, alice); + + // set spoke to inactive / halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.remove(usdxAssetId, 1, alice); + + // set spoke to active / not halted; succeeds + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + Utils.remove(hub1, usdxAssetId, address(spoke1), 1, alice); + + // set spoke to inactive / not halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.remove(usdxAssetId, 1, alice); + } + + function test_draw_active_halted_scenarios() public { + // set spoke to active / halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + vm.expectRevert(IHub.SpokeHalted.selector); + vm.prank(address(spoke1)); + hub1.draw(usdxAssetId, 1, alice); + + // set spoke to inactive / halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.draw(usdxAssetId, 1, alice); + + // set spoke to active / not halted; succeeds + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + Utils.draw(hub1, usdxAssetId, address(spoke1), alice, 1); + + // set spoke to inactive / not halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.draw(usdxAssetId, 1, alice); + } + + function test_restore_active_halted_scenarios() public { + Utils.draw(hub1, usdxAssetId, address(spoke1), alice, 100); + + // set spoke to active / halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + vm.expectRevert(IHub.SpokeHalted.selector); + vm.prank(address(spoke1)); + hub1.restore(usdxAssetId, 1, ZERO_PREMIUM_DELTA); + + // set spoke to inactive / halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.restore(usdxAssetId, 1, ZERO_PREMIUM_DELTA); + + // set spoke to active / not halted; succeeds + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + Utils.restoreDrawn(hub1, usdxAssetId, address(spoke1), 1, alice); + + // set spoke to inactive / not halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.restore(usdxAssetId, 1, ZERO_PREMIUM_DELTA); + } + + function test_reportDeficit_active_halted_scenarios() public { + // draw usdx liquidity to be restored + _drawLiquidity({ + assetId: usdxAssetId, + amount: 1, + withPremium: true, + skipTime: true, + spoke: address(spoke1) + }); + + // set spoke to active / halted; succeeds + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + vm.prank(address(spoke1)); + hub1.reportDeficit(usdxAssetId, 1, ZERO_PREMIUM_DELTA); + + // set spoke to inactive and halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.reportDeficit(usdxAssetId, 1, ZERO_PREMIUM_DELTA); + + // set spoke to active and not halted; succeeds + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + vm.prank(address(spoke1)); + hub1.reportDeficit(usdxAssetId, 1, ZERO_PREMIUM_DELTA); + + // set spoke to inactive and not halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.reportDeficit(usdxAssetId, 1, ZERO_PREMIUM_DELTA); + } + + function test_eliminateDeficit_active_halted_scenarios() public { + address coveredSpoke = address(spoke1); + address callerSpoke = address(spoke2); + grantDeficitEliminatorRole(hub1, callerSpoke); + + // create reported deficit on spoke1 + _createReportedDeficit(hub1, coveredSpoke, usdxAssetId); + Utils.add(hub1, usdxAssetId, callerSpoke, 1e18, alice); + + // covered spoke status does not matter + _updateSpokeHalted(hub1, usdxAssetId, coveredSpoke, true); + _updateSpokeActive(hub1, usdxAssetId, coveredSpoke, false); + + // set caller spoke to active / halted; succeeds + _updateSpokeHalted(hub1, usdxAssetId, callerSpoke, true); + _updateSpokeActive(hub1, usdxAssetId, callerSpoke, true); + + vm.prank(callerSpoke); + hub1.eliminateDeficit(usdxAssetId, 1, coveredSpoke); + + // set spoke to inactive / halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, callerSpoke, true); + _updateSpokeActive(hub1, usdxAssetId, callerSpoke, false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(callerSpoke); + hub1.eliminateDeficit(usdxAssetId, 1, coveredSpoke); + + // set spoke to active / not halted; succeeds + _updateSpokeHalted(hub1, usdxAssetId, callerSpoke, false); + _updateSpokeActive(hub1, usdxAssetId, callerSpoke, true); + + vm.prank(callerSpoke); + hub1.eliminateDeficit(usdxAssetId, 1, coveredSpoke); + + // set spoke to inactive / not halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + grantDeficitEliminatorRole(hub1, address(spoke1)); + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.eliminateDeficit(usdxAssetId, 1, coveredSpoke); + } + + function test_refreshPremium_active_halted_scenarios() public { + // set spoke to active / halted; succeeds + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + vm.prank(address(spoke1)); + hub1.refreshPremium(usdxAssetId, ZERO_PREMIUM_DELTA); + + // set spoke to inactive / halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.refreshPremium(usdxAssetId, ZERO_PREMIUM_DELTA); + + // set spoke to active / not halted; succeeds + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + vm.prank(address(spoke1)); + hub1.refreshPremium(usdxAssetId, ZERO_PREMIUM_DELTA); + + // set spoke to inactive / not halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.refreshPremium(usdxAssetId, ZERO_PREMIUM_DELTA); + } + + function test_payFeeShares_active_halted_scenarios() public { + address feeReceiver = _getFeeReceiver(hub1, usdxAssetId); + Utils.add(hub1, usdxAssetId, address(spoke1), 1e18, alice); + + // set fee receiver to inactive / halted; does not matter + _updateSpokeHalted(hub1, usdxAssetId, feeReceiver, true); + _updateSpokeActive(hub1, usdxAssetId, feeReceiver, false); + + // set spoke to active / halted; succeeds + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + vm.prank(address(spoke1)); + hub1.payFeeShares(usdxAssetId, 1); + + // set spoke to inactive / halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), true); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.payFeeShares(usdxAssetId, 1); + + // set spoke to active / not halted; succeeds + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), true); + + vm.prank(address(spoke1)); + hub1.payFeeShares(usdxAssetId, 1); + + // set spoke to inactive / not halted; reverts + _updateSpokeHalted(hub1, usdxAssetId, address(spoke1), false); + _updateSpokeActive(hub1, usdxAssetId, address(spoke1), false); + + vm.expectRevert(IHub.SpokeNotActive.selector); + vm.prank(address(spoke1)); + hub1.payFeeShares(usdxAssetId, 1); + } + + function test_transferShares_fuzz_active_halted_scenarios( + bool senderPaused, + bool receiverPaused, + bool senderActive, + bool receiverActive + ) public { + address sender = address(spoke1); + address receiver = address(spoke2); + Utils.add(hub1, usdxAssetId, sender, 1e18, alice); + + // set sender + _updateSpokeHalted(hub1, usdxAssetId, sender, senderPaused); + _updateSpokeActive(hub1, usdxAssetId, sender, senderActive); + // set receiver + _updateSpokeHalted(hub1, usdxAssetId, receiver, receiverPaused); + _updateSpokeActive(hub1, usdxAssetId, receiver, receiverActive); + + if (!senderActive || !receiverActive) { + vm.expectRevert(IHub.SpokeNotActive.selector); + } else if (senderPaused || receiverPaused) { + vm.expectRevert(IHub.SpokeHalted.selector); + } + vm.prank(sender); + hub1.transferShares(usdxAssetId, 1, receiver); + } + + function _accrueLiquidityFees(IHub hub, ISpoke spoke, uint256 assetId) internal { + Utils.add(hub, wbtcAssetId, address(spoke), 1e18, alice); + Utils.draw(hub, assetId, address(spoke), alice, 1e18); + + skip(365 days); + Utils.add(hub, assetId, address(spoke), 1e18, alice); + + assertGt(hub.getAsset(assetId).realizedFees, 0); + } + + function _createReportedDeficit(IHub hub, address spoke, uint256 assetId) internal { + Utils.add(hub, wbtcAssetId, spoke, 1e18, alice); + Utils.draw(hub, assetId, spoke, alice, 1e18); + + skip(365 days); + Utils.add(hub, assetId, spoke, 1e18, alice); + + vm.prank(spoke); + hub.reportDeficit(assetId, 1e18, ZERO_PREMIUM_DELTA); + + assertGt(hub.getAssetDeficitRay(assetId), 0); + } +} diff --git a/tests/unit/Hub/Hub.Sweep.t.sol b/tests/unit/Hub/Hub.Sweep.t.sol index a25ca2685..c142f833a 100644 --- a/tests/unit/Hub/Hub.Sweep.t.sol +++ b/tests/unit/Hub/Hub.Sweep.t.sol @@ -85,7 +85,7 @@ contract HubSweepTest is HubBase { abi.encodeWithSelector(IHub.InsufficientLiquidity.selector, initialLiquidity - swept) ); vm.prank(address(spoke1)); - hub1.remove(daiAssetId, swept + 1, alice); + hub1.remove(daiAssetId, initialLiquidity - swept + 1, alice); } function test_sweep_does_not_impact_utilization(uint256 supplyAmount, uint256 drawAmount) public { @@ -103,6 +103,7 @@ contract HubSweepTest is HubBase { hub1.sweep(daiAssetId, swept); assertEq(hub1.getAssetDrawnRate(daiAssetId), drawnRate, 'drawnRate'); + assertEq(hub1.getAsset(daiAssetId).drawnRate, drawnRate, 'drawnRate'); _assertBorrowRateSynced(hub1, daiAssetId, 'swept'); _assertHubLiquidity(hub1, daiAssetId, 'sweep'); (uint256 drawn, ) = hub1.getAssetOwed(daiAssetId); diff --git a/tests/unit/Hub/Hub.TransferShares.t.sol b/tests/unit/Hub/Hub.TransferShares.t.sol index 4010fdded..8003b1f96 100644 --- a/tests/unit/Hub/Hub.TransferShares.t.sol +++ b/tests/unit/Hub/Hub.TransferShares.t.sol @@ -82,23 +82,19 @@ contract HubTransferSharesTest is HubBase { hub1.transferShares(daiAssetId, suppliedShares, address(spoke2)); } - function test_transferShares_revertsWith_SpokePaused() public { + function test_transferShares_revertsWith_SpokeHalted() public { uint256 supplyAmount = 1000e18; Utils.add(hub1, daiAssetId, address(spoke1), supplyAmount, bob); - // pause spoke1 - IHub.SpokeConfig memory spokeConfig = hub1.getSpokeConfig(daiAssetId, address(spoke1)); - spokeConfig.paused = true; - vm.prank(HUB_ADMIN); - hub1.updateSpokeConfig(daiAssetId, address(spoke1), spokeConfig); - assertTrue(hub1.getSpokeConfig(daiAssetId, address(spoke1)).paused); + // halt spoke1 + _updateSpokeHalted(hub1, daiAssetId, address(spoke1), true); uint256 suppliedShares = hub1.getSpokeAddedShares(daiAssetId, address(spoke1)); assertEq(suppliedShares, hub1.previewRemoveByShares(daiAssetId, supplyAmount)); - // try to transfer supplied shares from paused spoke1 + // try to transfer supplied shares from halted spoke1 vm.prank(address(spoke1)); - vm.expectRevert(IHub.SpokePaused.selector); + vm.expectRevert(IHub.SpokeHalted.selector); hub1.transferShares(daiAssetId, suppliedShares, address(spoke2)); } diff --git a/tests/unit/Hub/HubAccrueInterest.t.sol b/tests/unit/Hub/HubAccrueInterest.t.sol index 0a5aaa5e0..317bad651 100644 --- a/tests/unit/Hub/HubAccrueInterest.t.sol +++ b/tests/unit/Hub/HubAccrueInterest.t.sol @@ -6,6 +6,7 @@ import 'tests/Base.t.sol'; contract HubAccrueInterestTest is Base { using SafeCast for uint256; + using WadRayMath for uint256; struct Timestamps { uint40 t0; @@ -333,4 +334,46 @@ contract HubAccrueInterestTest is Base { ); assertEq(getAssetDrawnDebt(daiAssetId), expectedDrawnDebt2, 'drawn t2'); } + + function test_getAssetDrawnRate_MatchesStoredAfterAction() public { + uint256 addAmount = 1000e18; + uint256 borrowAmount = 100e18; + + Utils.add(hub1, daiAssetId, address(spoke1), addAmount, address(spoke1)); + Utils.draw(hub1, daiAssetId, address(spoke1), address(spoke1), borrowAmount); + + uint256 storedRate = hub1.getAsset(daiAssetId).drawnRate; + uint256 computedRate = hub1.getAssetDrawnRate(daiAssetId); + assertEq(storedRate, computedRate); + } + + function test_getAssetDrawnRate_fuzz_DiffersAfterTimePasses(uint40 elapsed) public { + elapsed = bound(elapsed, 1, type(uint40).max / 3).toUint40(); + + uint256 addAmount = 1000e18; + uint256 borrowAmount = 100e18; + + Utils.add(hub1, daiAssetId, address(spoke1), addAmount, address(spoke1)); + Utils.draw(hub1, daiAssetId, address(spoke1), address(spoke1), borrowAmount); + + uint256 storedRateBefore = hub1.getAsset(daiAssetId).drawnRate; + + skip(elapsed); + + // Stored rate remains unchanged + assertEq(hub1.getAsset(daiAssetId).drawnRate, storedRateBefore); + + uint256 computedRate = hub1.getAssetDrawnRate(daiAssetId); + IHub.Asset memory asset = hub1.getAsset(daiAssetId); + uint256 currentDrawnIndex = hub1.getAssetDrawnIndex(daiAssetId); + uint256 currentDrawn = uint256(asset.drawnShares).rayMulUp(currentDrawnIndex); + uint256 expectedRate = IBasicInterestRateStrategy(asset.irStrategy).calculateInterestRate({ + assetId: daiAssetId, + liquidity: asset.liquidity, + drawn: currentDrawn, + deficit: uint256(asset.deficitRay).fromRayUp(), + swept: asset.swept + }); + assertEq(computedRate, expectedRate); + } } diff --git a/tests/unit/Hub/HubBase.t.sol b/tests/unit/Hub/HubBase.t.sol index 1fe787157..c502e12c3 100644 --- a/tests/unit/Hub/HubBase.t.sol +++ b/tests/unit/Hub/HubBase.t.sol @@ -104,7 +104,7 @@ contract HubBase is Base { tempSpoke, IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK @@ -208,7 +208,7 @@ contract HubBase is Base { tempSpoke, IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK diff --git a/tests/unit/HubConfigurator.GranularAccessControl.t.sol b/tests/unit/HubConfigurator.GranularAccessControl.t.sol new file mode 100644 index 000000000..899e266a9 --- /dev/null +++ b/tests/unit/HubConfigurator.GranularAccessControl.t.sol @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/Hub/HubBase.t.sol'; + +contract HubConfiguratorGranularAccessControlTest is HubBase { + using SafeCast for uint256; + + // Granular role constants + uint64 constant ASSET_MANAGER_ROLE = 100; + uint64 constant SPOKE_MANAGER_ROLE = 101; + + // Role holders + address ASSET_MANAGER = makeAddr('ASSET_MANAGER'); + address SPOKE_MANAGER = makeAddr('SPOKE_MANAGER'); + + HubConfigurator hubConfigurator; + IAccessManager manager; + + uint256 assetId; + address spokeAddr; + bytes encodedIrData; + + // Arrays storing calldata for each role's functions + bytes[] internal assetManagerCalldata; + bytes[] internal spokeManagerCalldata; + + function setUp() public virtual override { + super.setUp(); + + manager = IAccessManager(hub1.authority()); + hubConfigurator = new HubConfigurator(address(manager)); + + // Grant HUB_ADMIN_ROLE to hubConfigurator so it can call hub functions + vm.startPrank(ADMIN); + manager.grantRole(Roles.HUB_ADMIN_ROLE, address(hubConfigurator), 0); + + // Grant granular roles to role holders + manager.grantRole(ASSET_MANAGER_ROLE, ASSET_MANAGER, 0); + manager.grantRole(SPOKE_MANAGER_ROLE, SPOKE_MANAGER, 0); + + // Set up ASSET_MANAGER_ROLE permissions (11 functions) + bytes4[] memory assetSelectors = new bytes4[](11); + assetSelectors[0] = IHubConfigurator.addAsset.selector; + assetSelectors[1] = IHubConfigurator.addAssetWithDecimals.selector; + assetSelectors[2] = IHubConfigurator.updateLiquidityFee.selector; + assetSelectors[3] = IHubConfigurator.updateFeeReceiver.selector; + assetSelectors[4] = IHubConfigurator.updateFeeConfig.selector; + assetSelectors[5] = IHubConfigurator.updateInterestRateStrategy.selector; + assetSelectors[6] = IHubConfigurator.updateReinvestmentController.selector; + assetSelectors[7] = IHubConfigurator.updateInterestRateData.selector; + assetSelectors[8] = IHubConfigurator.resetAssetCaps.selector; + assetSelectors[9] = IHubConfigurator.deactivateAsset.selector; + assetSelectors[10] = IHubConfigurator.haltAsset.selector; + manager.setTargetFunctionRole(address(hubConfigurator), assetSelectors, ASSET_MANAGER_ROLE); + + // Set up SPOKE_MANAGER_ROLE permissions (11 functions) + bytes4[] memory spokeSelectors = new bytes4[](11); + spokeSelectors[0] = IHubConfigurator.addSpoke.selector; + spokeSelectors[1] = IHubConfigurator.addSpokeToAssets.selector; + spokeSelectors[2] = IHubConfigurator.updateSpokeActive.selector; + spokeSelectors[3] = IHubConfigurator.updateSpokeHalted.selector; + spokeSelectors[4] = IHubConfigurator.updateSpokeSupplyCap.selector; + spokeSelectors[5] = IHubConfigurator.updateSpokeDrawCap.selector; + spokeSelectors[6] = IHubConfigurator.updateSpokeRiskPremiumThreshold.selector; + spokeSelectors[7] = IHubConfigurator.updateSpokeCaps.selector; + spokeSelectors[8] = IHubConfigurator.deactivateSpoke.selector; + spokeSelectors[9] = IHubConfigurator.haltSpoke.selector; + spokeSelectors[10] = IHubConfigurator.resetSpokeCaps.selector; + manager.setTargetFunctionRole(address(hubConfigurator), spokeSelectors, SPOKE_MANAGER_ROLE); + + vm.stopPrank(); + + // Set up test data + assetId = daiAssetId; + spokeAddr = address(spoke1); + encodedIrData = abi.encode( + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 90_00, + baseVariableBorrowRate: 5_00, + variableRateSlope1: 5_00, + variableRateSlope2: 5_00 + }) + ); + + // Build calldata arrays for testing + _buildAssetManagerCalldata(); + _buildSpokeManagerCalldata(); + } + + function _buildAssetManagerCalldata() internal { + address newFeeReceiver = makeAddr('NEW_FEE_RECEIVER'); + address newIrStrategy = address(new AssetInterestRateStrategy(address(hub1))); + address newController = makeAddr('NEW_REINVESTMENT_CONTROLLER'); + + // Note: Skipping addAsset overloads as they require more complex setup + assetManagerCalldata.push( + abi.encodeCall(IHubConfigurator.updateLiquidityFee, (address(hub1), assetId, 10_00)) + ); + assetManagerCalldata.push( + abi.encodeCall(IHubConfigurator.updateFeeReceiver, (address(hub1), assetId, newFeeReceiver)) + ); + assetManagerCalldata.push( + abi.encodeCall( + IHubConfigurator.updateFeeConfig, + (address(hub1), assetId, 5_00, newFeeReceiver) + ) + ); + assetManagerCalldata.push( + abi.encodeCall( + IHubConfigurator.updateInterestRateStrategy, + (address(hub1), assetId, newIrStrategy, encodedIrData) + ) + ); + assetManagerCalldata.push( + abi.encodeCall( + IHubConfigurator.updateReinvestmentController, + (address(hub1), assetId, newController) + ) + ); + assetManagerCalldata.push( + abi.encodeCall( + IHubConfigurator.updateInterestRateData, + (address(hub1), assetId, encodedIrData) + ) + ); + assetManagerCalldata.push( + abi.encodeCall(IHubConfigurator.resetAssetCaps, (address(hub1), assetId)) + ); + assetManagerCalldata.push( + abi.encodeCall(IHubConfigurator.deactivateAsset, (address(hub1), assetId)) + ); + assetManagerCalldata.push(abi.encodeCall(IHubConfigurator.haltAsset, (address(hub1), assetId))); + } + + function _buildSpokeManagerCalldata() internal { + address newSpoke = makeAddr('NEW_SPOKE'); + IHub.SpokeConfig memory config = IHub.SpokeConfig({ + active: true, + halted: false, + addCap: 1000, + drawCap: 500, + riskPremiumThreshold: 0 + }); + + uint256[] memory assetIds = new uint256[](1); + assetIds[0] = assetId; + IHub.SpokeConfig[] memory configs = new IHub.SpokeConfig[](1); + configs[0] = config; + + spokeManagerCalldata.push( + abi.encodeCall(IHubConfigurator.addSpoke, (address(hub1), newSpoke, assetId, config)) + ); + spokeManagerCalldata.push( + abi.encodeCall( + IHubConfigurator.addSpokeToAssets, + (address(hub1), makeAddr('NEW_SPOKE_2'), assetIds, configs) + ) + ); + spokeManagerCalldata.push( + abi.encodeCall(IHubConfigurator.updateSpokeActive, (address(hub1), assetId, spokeAddr, false)) + ); + spokeManagerCalldata.push( + abi.encodeCall(IHubConfigurator.updateSpokeHalted, (address(hub1), assetId, spokeAddr, true)) + ); + spokeManagerCalldata.push( + abi.encodeCall( + IHubConfigurator.updateSpokeSupplyCap, + (address(hub1), assetId, spokeAddr, 5000) + ) + ); + spokeManagerCalldata.push( + abi.encodeCall(IHubConfigurator.updateSpokeDrawCap, (address(hub1), assetId, spokeAddr, 2500)) + ); + spokeManagerCalldata.push( + abi.encodeCall( + IHubConfigurator.updateSpokeRiskPremiumThreshold, + (address(hub1), assetId, spokeAddr, 500) + ) + ); + spokeManagerCalldata.push( + abi.encodeCall(IHubConfigurator.updateSpokeCaps, (address(hub1), assetId, spokeAddr, 100, 50)) + ); + spokeManagerCalldata.push( + abi.encodeCall(IHubConfigurator.deactivateSpoke, (address(hub1), spokeAddr)) + ); + spokeManagerCalldata.push( + abi.encodeCall(IHubConfigurator.haltSpoke, (address(hub1), spokeAddr)) + ); + spokeManagerCalldata.push( + abi.encodeCall(IHubConfigurator.resetSpokeCaps, (address(hub1), spokeAddr)) + ); + } + + function test_fuzz_unauthorized_cannotCall_assetManagerMethods(address caller) public { + vm.assume(caller != ASSET_MANAGER); + vm.assume(caller != address(0)); + + for (uint256 i = 0; i < assetManagerCalldata.length; ++i) { + vm.prank(caller); + (bool ok, bytes memory ret) = address(hubConfigurator).call(assetManagerCalldata[i]); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, caller) + ); + } + } + + function test_fuzz_unauthorized_cannotCall_spokeManagerMethods(address caller) public { + vm.assume(caller != SPOKE_MANAGER); + vm.assume(caller != address(0)); + + for (uint256 i = 0; i < spokeManagerCalldata.length; ++i) { + vm.prank(caller); + (bool ok, bytes memory ret) = address(hubConfigurator).call(spokeManagerCalldata[i]); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, caller) + ); + } + } + + function test_assetManager_cannotCall_anySpokeManagerMethod() public { + for (uint256 i = 0; i < spokeManagerCalldata.length; ++i) { + vm.prank(ASSET_MANAGER); + (bool ok, bytes memory ret) = address(hubConfigurator).call(spokeManagerCalldata[i]); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, ASSET_MANAGER) + ); + } + } + + function test_spokeManager_cannotCall_anyAssetManagerMethod() public { + for (uint256 i = 0; i < assetManagerCalldata.length; ++i) { + vm.prank(SPOKE_MANAGER); + (bool ok, bytes memory ret) = address(hubConfigurator).call(assetManagerCalldata[i]); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, SPOKE_MANAGER) + ); + } + } + + function test_assetManager_canCall_updateLiquidityFee() public { + vm.prank(ASSET_MANAGER); + hubConfigurator.updateLiquidityFee(address(hub1), assetId, 10_00); + + assertEq(hub1.getAssetConfig(assetId).liquidityFee, 10_00); + } + + function test_assetManager_canCall_resetAssetCaps() public { + vm.prank(ASSET_MANAGER); + hubConfigurator.resetAssetCaps(address(hub1), assetId); + + IHub.SpokeConfig memory config = hub1.getSpokeConfig(assetId, spokeAddr); + assertEq(config.addCap, 0); + assertEq(config.drawCap, 0); + } + + function test_assetManager_canCall_deactivateAsset() public { + vm.prank(ASSET_MANAGER); + hubConfigurator.deactivateAsset(address(hub1), assetId); + + IHub.SpokeConfig memory config = hub1.getSpokeConfig(assetId, spokeAddr); + assertFalse(config.active); + } + + function test_assetManager_canCall_haltAsset() public { + vm.prank(ASSET_MANAGER); + hubConfigurator.haltAsset(address(hub1), assetId); + + IHub.SpokeConfig memory config = hub1.getSpokeConfig(assetId, spokeAddr); + assertTrue(config.halted); + } + + function test_spokeManager_canCall_addSpoke() public { + address newSpoke = makeAddr('NEW_SPOKE_TEST'); + IHub.SpokeConfig memory config = IHub.SpokeConfig({ + active: true, + halted: false, + addCap: 1000, + drawCap: 500, + riskPremiumThreshold: 0 + }); + + vm.prank(SPOKE_MANAGER); + hubConfigurator.addSpoke(address(hub1), newSpoke, assetId, config); + + assertTrue(hub1.isSpokeListed(assetId, newSpoke)); + } + + function test_spokeManager_canCall_updateSpokeActive() public { + vm.prank(SPOKE_MANAGER); + hubConfigurator.updateSpokeActive(address(hub1), assetId, spokeAddr, false); + + assertFalse(hub1.getSpokeConfig(assetId, spokeAddr).active); + } + + function test_spokeManager_canCall_updateSpokeHalted() public { + vm.prank(SPOKE_MANAGER); + hubConfigurator.updateSpokeHalted(address(hub1), assetId, spokeAddr, true); + + assertTrue(hub1.getSpokeConfig(assetId, spokeAddr).halted); + } + + function test_spokeManager_canCall_updateSpokeCaps() public { + vm.prank(SPOKE_MANAGER); + hubConfigurator.updateSpokeCaps(address(hub1), assetId, spokeAddr, 100, 50); + + IHub.SpokeConfig memory config = hub1.getSpokeConfig(assetId, spokeAddr); + assertEq(config.addCap, 100); + assertEq(config.drawCap, 50); + } + + function test_spokeManager_canCall_resetSpokeCaps() public { + vm.prank(SPOKE_MANAGER); + hubConfigurator.resetSpokeCaps(address(hub1), spokeAddr); + + for (uint256 i = 0; i < hub1.getAssetCount(); ++i) { + if (hub1.isSpokeListed(i, spokeAddr)) { + IHub.SpokeConfig memory config = hub1.getSpokeConfig(i, spokeAddr); + assertEq(config.addCap, 0); + assertEq(config.drawCap, 0); + } + } + } +} diff --git a/tests/unit/HubConfigurator.t.sol b/tests/unit/HubConfigurator.t.sol index c75ca4aa5..6f9c87955 100644 --- a/tests/unit/HubConfigurator.t.sol +++ b/tests/unit/HubConfigurator.t.sol @@ -9,7 +9,6 @@ contract HubConfiguratorTest is HubBase { HubConfigurator internal hubConfigurator; - address internal HUB_CONFIGURATOR_ADMIN = makeAddr('HUB_CONFIGURATOR_ADMIN'); uint256 internal _assetId; bytes internal _encodedIrData; @@ -21,11 +20,9 @@ contract HubConfiguratorTest is HubBase { function setUp() public virtual override { super.setUp(); - hubConfigurator = new HubConfigurator(HUB_CONFIGURATOR_ADMIN); - IAccessManager accessManager = IAccessManager(hub1.authority()); - // Grant hubConfigurator hub admin role with 0 delay - vm.prank(ADMIN); - accessManager.grantRole(Roles.HUB_ADMIN_ROLE, address(hubConfigurator), 0); + hubConfigurator = new HubConfigurator(hub1.authority()); + setUpHubConfiguratorRoles(address(hubConfigurator), hub1.authority()); + _assetId = daiAssetId; _encodedIrData = abi.encode( IAssetInterestRateStrategy.InterestRateData({ @@ -39,10 +36,12 @@ contract HubConfiguratorTest is HubBase { spoke = address(spoke1); } - function test_addAsset_fuzz_revertsWith_OwnableUnauthorizedAccount(address caller) public { - vm.assume(caller != HUB_CONFIGURATOR_ADMIN); + function test_addAsset_fuzz_revertsWith_AccessManagedUnauthorized(address caller) public { + vm.assume(caller != HUB_CONFIGURATOR); - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller)); + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, caller) + ); vm.prank(caller); _addAsset({ fetchErc20Decimals: vm.randomBool(), @@ -62,7 +61,7 @@ contract HubConfiguratorTest is HubBase { function test_addAsset_reverts_invalidIrData() public { vm.expectRevert(); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); _addAsset({ fetchErc20Decimals: vm.randomBool(), underlying: vm.randomAddress(), @@ -91,7 +90,7 @@ contract HubConfiguratorTest is HubBase { liquidityFee = bound(liquidityFee, 0, PercentageMath.PERCENTAGE_FACTOR); vm.expectRevert(IHub.InvalidAssetDecimals.selector, address(hub1)); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); _addAsset( fetchErc20Decimals, underlying, @@ -104,13 +103,18 @@ contract HubConfiguratorTest is HubBase { } function test_addAsset_revertsWith_InvalidAddress_underlying() public { - uint8 decimals = uint8(vm.randomUint(0, Constants.MAX_ALLOWED_UNDERLYING_DECIMALS)); + uint8 decimals = uint8( + vm.randomUint( + Constants.MIN_ALLOWED_UNDERLYING_DECIMALS, + Constants.MAX_ALLOWED_UNDERLYING_DECIMALS + ) + ); address feeReceiver = makeAddr('newFeeReceiver'); address interestRateStrategy = makeAddr('newIrStrategy'); uint256 liquidityFee = vm.randomUint(0, PercentageMath.PERCENTAGE_FACTOR); vm.expectRevert(IHub.InvalidAddress.selector, address(hub1)); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); _addAsset( true, address(0), @@ -124,24 +128,34 @@ contract HubConfiguratorTest is HubBase { function test_addAsset_revertsWith_InvalidAddress_irStrategy() public { address underlying = makeAddr('newUnderlying'); - uint8 decimals = uint8(vm.randomUint(0, Constants.MAX_ALLOWED_UNDERLYING_DECIMALS)); + uint8 decimals = uint8( + vm.randomUint( + Constants.MIN_ALLOWED_UNDERLYING_DECIMALS, + Constants.MAX_ALLOWED_UNDERLYING_DECIMALS + ) + ); address feeReceiver = makeAddr('newFeeReceiver'); uint256 liquidityFee = vm.randomUint(0, PercentageMath.PERCENTAGE_FACTOR); vm.expectRevert(IHub.InvalidAddress.selector, address(hub1)); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); _addAsset(true, underlying, decimals, feeReceiver, liquidityFee, address(0), _encodedIrData); } function test_addAsset_revertsWith_InvalidLiquidityFee() public { address underlying = makeAddr('newUnderlying'); - uint8 decimals = uint8(vm.randomUint(0, Constants.MAX_ALLOWED_UNDERLYING_DECIMALS)); + uint8 decimals = uint8( + vm.randomUint( + Constants.MIN_ALLOWED_UNDERLYING_DECIMALS, + Constants.MAX_ALLOWED_UNDERLYING_DECIMALS + ) + ); address feeReceiver = makeAddr('newFeeReceiver'); address interestRateStrategy = address(new AssetInterestRateStrategy(address(hub1))); uint256 liquidityFee = vm.randomUint(PercentageMath.PERCENTAGE_FACTOR + 1, type(uint16).max); vm.expectRevert(IHub.InvalidLiquidityFee.selector, address(hub1)); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); _addAsset( false, underlying, @@ -204,7 +218,7 @@ contract HubConfiguratorTest is HubBase { }); IHub.SpokeConfig memory expectedSpokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: 0, riskPremiumThreshold: 0 @@ -223,7 +237,7 @@ contract HubConfiguratorTest is HubBase { abi.encodeCall(IHub.updateAssetConfig, (hub1.getAssetCount(), expectedConfig, new bytes(0))) ); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); _assetId = _addAsset( fetchErc20Decimals, underlying, @@ -242,8 +256,10 @@ contract HubConfiguratorTest is HubBase { assertEq(hub1.getAsset(_assetId).reinvestmentController, address(0)); // should init to addr(0) } - function test_updateLiquidityFee_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateLiquidityFee_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); hubConfigurator.updateLiquidityFee(address(hub1), vm.randomUint(), vm.randomUint()); } @@ -255,7 +271,7 @@ contract HubConfiguratorTest is HubBase { ); vm.expectRevert(IHub.InvalidLiquidityFee.selector); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateLiquidityFee(address(hub1), _assetId, liquidityFee); } @@ -271,17 +287,19 @@ contract HubConfiguratorTest is HubBase { abi.encodeCall(IHub.updateAssetConfig, (_assetId, expectedConfig, new bytes(0))) ); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateLiquidityFee(address(hub1), _assetId, expectedConfig.liquidityFee); assertEq(hub1.getAssetConfig(_assetId), expectedConfig); } - function test_updateFeeReceiver_fuzz_revertsWith_OwnableUnauthorizedAccount( + function test_updateFeeReceiver_fuzz_revertsWith_AccessManagedUnauthorized( address caller ) public { - vm.assume(caller != HUB_CONFIGURATOR_ADMIN); - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller)); + vm.assume(caller != HUB_CONFIGURATOR); + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, caller) + ); vm.prank(caller); hubConfigurator.updateFeeReceiver(address(hub1), vm.randomUint(), vm.randomAddress()); } @@ -290,7 +308,7 @@ contract HubConfiguratorTest is HubBase { _assetId = vm.randomUint(0, hub1.getAssetCount() - 1); vm.expectRevert(IHub.InvalidAddress.selector, address(hub1)); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateFeeReceiver(address(hub1), _assetId, address(0)); } @@ -312,20 +330,19 @@ contract HubConfiguratorTest is HubBase { vm.expectRevert(IHub.SpokeAlreadyListed.selector, address(hub1)); } } - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateFeeReceiver(address(hub1), _assetId, feeReceiver); assertEq(hub1.getAssetConfig(_assetId), expectedConfig); } function test_updateFeeReceiver_revertsWith_SpokeAlreadyListed() public { - _assetId = vm.randomUint(0, hub1.getAssetCount() - 1); assertTrue(hub1.isSpokeListed(_assetId, address(spoke1))); // set feeReceiver as an existing spoke address feeReceiver = address(spoke1); vm.expectRevert(IHub.SpokeAlreadyListed.selector, address(hub1)); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateFeeReceiver(address(hub1), _assetId, feeReceiver); } @@ -354,7 +371,7 @@ contract HubConfiguratorTest is HubBase { // Change the fee receiver TreasurySpoke newTreasurySpoke = new TreasurySpoke(HUB_ADMIN, address(hub1)); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateFeeReceiver(address(hub1), daiAssetId, address(newTreasurySpoke)); uint256 fees = treasurySpoke.getSuppliedAmount(daiAssetId); @@ -419,7 +436,7 @@ contract HubConfiguratorTest is HubBase { // Change the fee receiver TreasurySpoke newTreasurySpoke = new TreasurySpoke(HUB_ADMIN, address(hub1)); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateFeeReceiver(address(hub1), daiAssetId, address(newTreasurySpoke)); // Ensure fee receiver was updated @@ -481,9 +498,11 @@ contract HubConfiguratorTest is HubBase { test_updateFeeReceiver_fuzz(makeAddr('newFeeReceiver')); } - function test_updateFeeConfig_fuzz_revertsWith_OwnableUnauthorizedAccount(address caller) public { - vm.assume(caller != HUB_CONFIGURATOR_ADMIN); - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller)); + function test_updateFeeConfig_fuzz_revertsWith_AccessManagedUnauthorized(address caller) public { + vm.assume(caller != HUB_CONFIGURATOR); + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, caller) + ); vm.prank(caller); hubConfigurator.updateFeeConfig({ hub: address(hub1), @@ -498,7 +517,7 @@ contract HubConfiguratorTest is HubBase { uint256 liquidityFee = vm.randomUint(1, PercentageMath.PERCENTAGE_FACTOR); vm.expectRevert(IHub.InvalidAddress.selector, address(hub1)); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateFeeConfig(address(hub1), assetId, liquidityFee, address(0)); } @@ -510,7 +529,7 @@ contract HubConfiguratorTest is HubBase { address feeReceiver = hub1.getAssetConfig(assetId).feeReceiver; vm.expectRevert(IHub.InvalidLiquidityFee.selector, address(hub1)); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateFeeConfig(address(hub1), assetId, liquidityFee, feeReceiver); } @@ -540,7 +559,7 @@ contract HubConfiguratorTest is HubBase { vm.expectRevert(IHub.SpokeAlreadyListed.selector, address(hub1)); } } - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateFeeConfig(address(hub1), assetId_, liquidityFee, feeReceiver); assertEq(hub1.getAssetConfig(assetId_), expectedConfig); } @@ -554,11 +573,13 @@ contract HubConfiguratorTest is HubBase { test_updateFeeConfig_fuzz(0, 0, makeAddr('newFeeReceiver2')); } - function test_updateInterestRateStrategy_fuzz_revertsWith_OwnableUnauthorizedAccount( + function test_updateInterestRateStrategy_fuzz_revertsWith_AccessManagedUnauthorized( address caller ) public { - vm.assume(caller != HUB_CONFIGURATOR_ADMIN); - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller)); + vm.assume(caller != HUB_CONFIGURATOR); + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, caller) + ); vm.prank(caller); hubConfigurator.updateInterestRateStrategy( address(hub1), @@ -580,7 +601,7 @@ contract HubConfiguratorTest is HubBase { abi.encodeCall(IHub.updateAssetConfig, (_assetId, expectedConfig, _encodedIrData)) ); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateInterestRateStrategy( address(hub1), _assetId, @@ -595,7 +616,7 @@ contract HubConfiguratorTest is HubBase { _assetId = vm.randomUint(0, hub1.getAssetCount() - 1); vm.expectRevert(IHub.InvalidAddress.selector, address(hub1)); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateInterestRateStrategy(address(hub1), _assetId, address(0), _encodedIrData); } @@ -604,7 +625,7 @@ contract HubConfiguratorTest is HubBase { address interestRateStrategy = makeAddr('newInterestRateStrategy'); vm.expectRevert(); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateInterestRateStrategy( address(hub1), _assetId, @@ -615,7 +636,7 @@ contract HubConfiguratorTest is HubBase { function test_updateInterestRateStrategy_revertsWith_InvalidInterestRateStrategy() public { vm.expectRevert(IHub.InvalidInterestRateStrategy.selector, address(hub1)); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateInterestRateStrategy( address(hub1), _assetId, @@ -624,11 +645,13 @@ contract HubConfiguratorTest is HubBase { ); } - function test_updateReinvestmentController_fuzz_revertsWith_OwnableUnauthorizedAccount( + function test_updateReinvestmentController_fuzz_revertsWith_AccessManagedUnauthorized( address caller ) public { - vm.assume(caller != HUB_CONFIGURATOR_ADMIN); - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller)); + vm.assume(caller != HUB_CONFIGURATOR); + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, caller) + ); vm.prank(caller); hubConfigurator.updateReinvestmentController( address(hub1), @@ -645,19 +668,21 @@ contract HubConfiguratorTest is HubBase { address(hub1), abi.encodeCall(IHub.updateAssetConfig, (_assetId, expectedConfig, new bytes(0))) ); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateReinvestmentController(address(hub1), _assetId, reinvestmentController); assertEq(hub1.getAssetConfig(_assetId), expectedConfig); } - function test_freezeAsset_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_resetAssetCaps_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - hubConfigurator.freezeAsset(address(hub1), _assetId); + hubConfigurator.resetAssetCaps(address(hub1), _assetId); } - function test_freezeAsset() public { + function test_resetAssetCaps() public { for (uint256 i; i < spokeAddresses.length; i++) { IHub.SpokeConfig memory spokeConfig = hub1.getSpokeConfig(_assetId, spokeAddresses[i]); spokeConfig.addCap = 0; @@ -670,8 +695,8 @@ contract HubConfiguratorTest is HubBase { riskPremiumThresholdsPerSpoke[spokeAddresses[i]] = spokeConfig.riskPremiumThreshold; } - vm.prank(HUB_CONFIGURATOR_ADMIN); - hubConfigurator.freezeAsset(address(hub1), _assetId); + vm.prank(HUB_CONFIGURATOR); + hubConfigurator.resetAssetCaps(address(hub1), _assetId); for (uint256 i; i < spokeAddresses.length; i++) { IHub.SpokeConfig memory spokeConfig = hub1.getSpokeConfig(_assetId, spokeAddresses[i]); @@ -681,8 +706,10 @@ contract HubConfiguratorTest is HubBase { } } - function test_deactivateAsset_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_deactivateAsset_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); hubConfigurator.deactivateAsset(address(hub1), _assetId); } @@ -697,7 +724,7 @@ contract HubConfiguratorTest is HubBase { ); } - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.deactivateAsset(address(hub1), _assetId); for (uint256 i; i < spokeAddresses.length; i++) { @@ -706,33 +733,37 @@ contract HubConfiguratorTest is HubBase { } } - function test_pauseAsset_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_haltAsset_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - hubConfigurator.pauseAsset(address(hub1), _assetId); + hubConfigurator.haltAsset(address(hub1), _assetId); } - function test_pauseAsset() public { + function test_haltAsset() public { for (uint256 i; i < spokeAddresses.length; i++) { IHub.SpokeConfig memory spokeConfig = hub1.getSpokeConfig(_assetId, spokeAddresses[i]); - spokeConfig.paused = true; + spokeConfig.halted = true; vm.expectCall( address(hub1), abi.encodeCall(IHub.updateSpokeConfig, (_assetId, spokeAddresses[i], spokeConfig)) ); } - vm.prank(HUB_CONFIGURATOR_ADMIN); - hubConfigurator.pauseAsset(address(hub1), _assetId); + vm.prank(HUB_CONFIGURATOR); + hubConfigurator.haltAsset(address(hub1), _assetId); for (uint256 i; i < spokeAddresses.length; i++) { IHub.SpokeConfig memory spokeConfig = hub1.getSpokeConfig(_assetId, spokeAddresses[i]); - assertEq(spokeConfig.paused, true); + assertEq(spokeConfig.halted, true); } } - function test_addSpoke_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_addSpoke_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); IHub.SpokeConfig memory spokeConfig; hubConfigurator.addSpoke(address(hub1), vm.randomAddress(), 0, spokeConfig); @@ -743,7 +774,7 @@ contract HubConfiguratorTest is HubBase { IHub.SpokeConfig memory daiSpokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: 1, drawCap: 2, riskPremiumThreshold: 22 @@ -751,14 +782,16 @@ contract HubConfiguratorTest is HubBase { vm.expectEmit(address(hub1)); emit IHub.AddSpoke(daiAssetId, newSpoke); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.addSpoke(address(hub1), newSpoke, daiAssetId, daiSpokeConfig); assertEq(hub1.getSpokeConfig(daiAssetId, newSpoke), daiSpokeConfig); } - function test_addSpokeToAssets_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_addSpokeToAssets_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); hubConfigurator.addSpokeToAssets( address(hub1), @@ -778,26 +811,26 @@ contract HubConfiguratorTest is HubBase { addCap: 1, drawCap: 2, active: true, - paused: false, + halted: false, riskPremiumThreshold: 0 }); spokeConfigs[1] = IHub.SpokeConfig({ addCap: 3, drawCap: 4, active: true, - paused: false, + halted: false, riskPremiumThreshold: 0 }); spokeConfigs[2] = IHub.SpokeConfig({ addCap: 5, drawCap: 6, active: true, - paused: false, + halted: false, riskPremiumThreshold: 0 }); vm.expectRevert(IHubConfigurator.MismatchedConfigs.selector); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.addSpokeToAssets(address(hub1), spoke, assetIds, spokeConfigs); } @@ -810,14 +843,14 @@ contract HubConfiguratorTest is HubBase { IHub.SpokeConfig memory daiSpokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: 1, drawCap: 2, riskPremiumThreshold: 0 }); IHub.SpokeConfig memory wethSpokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: 3, drawCap: 4, riskPremiumThreshold: 0 @@ -831,7 +864,7 @@ contract HubConfiguratorTest is HubBase { emit IHub.AddSpoke(daiAssetId, newSpoke); vm.expectEmit(address(hub1)); emit IHub.AddSpoke(wethAssetId, newSpoke); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.addSpokeToAssets(address(hub1), newSpoke, assetIds, spokeConfigs); IHub.SpokeConfig memory daiSpokeData = hub1.getSpokeConfig(daiAssetId, newSpoke); @@ -841,29 +874,33 @@ contract HubConfiguratorTest is HubBase { assertEq(wethSpokeData, wethSpokeConfig); } - function test_updateSpokePaused_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateSpokeHalted_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - hubConfigurator.updateSpokePaused(address(hub1), _assetId, spokeAddresses[0], false); + hubConfigurator.updateSpokeHalted(address(hub1), _assetId, spokeAddresses[0], false); } - function test_updateSpokePaused() public { + function test_updateSpokeHalted() public { IHub.SpokeConfig memory expectedSpokeConfig = hub1.getSpokeConfig(_assetId, spoke); for (uint256 i = 0; i < 2; ++i) { - bool paused = (i == 0) ? false : true; - expectedSpokeConfig.paused = paused; + bool halted = (i == 0) ? false : true; + expectedSpokeConfig.halted = halted; vm.expectCall( address(hub1), abi.encodeCall(IHub.updateSpokeConfig, (_assetId, spoke, expectedSpokeConfig)) ); - vm.prank(HUB_CONFIGURATOR_ADMIN); - hubConfigurator.updateSpokePaused(address(hub1), _assetId, spoke, paused); + vm.prank(HUB_CONFIGURATOR); + hubConfigurator.updateSpokeHalted(address(hub1), _assetId, spoke, halted); assertEq(hub1.getSpokeConfig(_assetId, spoke), expectedSpokeConfig); } } - function test_updateSpokeActive_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateSpokeActive_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); hubConfigurator.updateSpokeActive(address(hub1), _assetId, spokeAddresses[0], true); } @@ -877,14 +914,16 @@ contract HubConfiguratorTest is HubBase { address(hub1), abi.encodeCall(IHub.updateSpokeConfig, (_assetId, spoke, expectedSpokeConfig)) ); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateSpokeActive(address(hub1), _assetId, spoke, active); assertEq(hub1.getSpokeConfig(_assetId, spoke), expectedSpokeConfig); } } - function test_updateSpokeSupplyCap_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateSpokeSupplyCap_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); hubConfigurator.updateSpokeSupplyCap(address(hub1), _assetId, spokeAddresses[0], 100); } @@ -897,13 +936,15 @@ contract HubConfiguratorTest is HubBase { address(hub1), abi.encodeCall(IHub.updateSpokeConfig, (_assetId, spoke, expectedSpokeConfig)) ); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateSpokeSupplyCap(address(hub1), _assetId, spoke, newSupplyCap); assertEq(hub1.getSpokeConfig(_assetId, spoke), expectedSpokeConfig); } - function test_updateSpokeDrawCap_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateSpokeDrawCap_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); hubConfigurator.updateSpokeDrawCap(address(hub1), _assetId, spokeAddresses[0], 100); } @@ -916,13 +957,15 @@ contract HubConfiguratorTest is HubBase { address(hub1), abi.encodeCall(IHub.updateSpokeConfig, (_assetId, spoke, expectedSpokeConfig)) ); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateSpokeDrawCap(address(hub1), _assetId, spoke, newDrawCap); assertEq(hub1.getSpokeConfig(_assetId, spoke), expectedSpokeConfig); } - function test_updateSpokeRiskPremiumThreshold_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateSpokeRiskPremiumThreshold_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); hubConfigurator.updateSpokeRiskPremiumThreshold( address(hub1), @@ -940,7 +983,7 @@ contract HubConfiguratorTest is HubBase { address(hub1), abi.encodeCall(IHub.updateSpokeConfig, (_assetId, spoke, expectedSpokeConfig)) ); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateSpokeRiskPremiumThreshold( address(hub1), _assetId, @@ -950,8 +993,10 @@ contract HubConfiguratorTest is HubBase { assertEq(hub1.getSpokeConfig(_assetId, spoke), expectedSpokeConfig); } - function test_updateSpokeCaps_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateSpokeCaps_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); hubConfigurator.updateSpokeCaps(address(hub1), _assetId, spokeAddresses[0], 100, 100); } @@ -966,13 +1011,15 @@ contract HubConfiguratorTest is HubBase { address(hub1), abi.encodeCall(IHub.updateSpokeConfig, (_assetId, spoke, expectedSpokeConfig)) ); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateSpokeCaps(address(hub1), _assetId, spoke, newSupplyCap, newDrawCap); assertEq(hub1.getSpokeConfig(_assetId, spoke), expectedSpokeConfig); } - function test_deactivateSpoke_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_deactivateSpoke_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); hubConfigurator.deactivateSpoke(address(hub1), address(spoke3)); } @@ -996,7 +1043,7 @@ contract HubConfiguratorTest is HubBase { vm.expectCall(address(hub1), abi.encodeCall(IHub.isSpokeListed, (assetId, address(spoke3)))); } - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.deactivateSpoke(address(hub1), address(spoke3)); for (uint256 assetId = 0; assetId < 4; ++assetId) { @@ -1005,13 +1052,15 @@ contract HubConfiguratorTest is HubBase { } } - function test_pauseSpoke_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_haltSpoke_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - hubConfigurator.pauseSpoke(address(hub1), address(spoke3)); + hubConfigurator.haltSpoke(address(hub1), address(spoke3)); } - function test_pauseSpoke() public { + function test_haltSpoke() public { /// @dev Spoke3 is listed on hub1 on 4 assets: dai, weth, wbtc, usdx assertGt(hub1.getAssetCount(), 4, 'hub1 has less than 4 assets listed'); @@ -1019,7 +1068,7 @@ contract HubConfiguratorTest is HubBase { vm.expectCall(address(hub1), abi.encodeCall(IHub.isSpokeListed, (assetId, address(spoke3)))); IHub.SpokeConfig memory expectedSpokeConfig = hub1.getSpokeConfig(assetId, address(spoke3)); - expectedSpokeConfig.paused = true; + expectedSpokeConfig.halted = true; vm.expectCall( address(hub1), abi.encodeCall(IHub.updateSpokeConfig, (assetId, address(spoke3), expectedSpokeConfig)) @@ -1030,22 +1079,24 @@ contract HubConfiguratorTest is HubBase { vm.expectCall(address(hub1), abi.encodeCall(IHub.isSpokeListed, (assetId, address(spoke3)))); } - vm.prank(HUB_CONFIGURATOR_ADMIN); - hubConfigurator.pauseSpoke(address(hub1), address(spoke3)); + vm.prank(HUB_CONFIGURATOR); + hubConfigurator.haltSpoke(address(hub1), address(spoke3)); for (uint256 assetId = 0; assetId < 4; ++assetId) { IHub.SpokeConfig memory spokeConfig = hub1.getSpokeConfig(assetId, address(spoke3)); - assertEq(spokeConfig.paused, true); + assertEq(spokeConfig.halted, true); } } - function test_freezeSpoke_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_resetSpokeCaps_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - hubConfigurator.freezeSpoke(address(hub1), address(spoke3)); + hubConfigurator.resetSpokeCaps(address(hub1), address(spoke3)); } - function test_freezeSpoke() public { + function test_resetSpokeCaps() public { /// @dev Spoke3 is listed on hub1 on 4 assets: dai, weth, wbtc, usdx assertGt(hub1.getAssetCount(), 4, 'hub1 has less than 4 assets listed'); @@ -1067,8 +1118,8 @@ contract HubConfiguratorTest is HubBase { vm.expectCall(address(hub1), abi.encodeCall(IHub.isSpokeListed, (assetId, address(spoke3)))); } - vm.prank(HUB_CONFIGURATOR_ADMIN); - hubConfigurator.freezeSpoke(address(hub1), address(spoke3)); + vm.prank(HUB_CONFIGURATOR); + hubConfigurator.resetSpokeCaps(address(hub1), address(spoke3)); for (uint256 assetId = 0; assetId < 4; ++assetId) { IHub.SpokeConfig memory spokeConfig = hub1.getSpokeConfig(assetId, address(spoke3)); @@ -1078,8 +1129,10 @@ contract HubConfiguratorTest is HubBase { } } - function test_updateInterestRateData_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateInterestRateData_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); hubConfigurator.updateInterestRateData(address(hub1), _assetId, vm.randomBytes(32)); } @@ -1097,7 +1150,7 @@ contract HubConfiguratorTest is HubBase { address(hub1), abi.encodeCall(IHub.setInterestRateData, (_assetId, abi.encode(newIrData))) ); - vm.prank(HUB_CONFIGURATOR_ADMIN); + vm.prank(HUB_CONFIGURATOR); hubConfigurator.updateInterestRateData(address(hub1), _assetId, abi.encode(newIrData)); assertEq(irStrategy.getInterestRateData(_assetId), newIrData); @@ -1125,7 +1178,7 @@ contract HubConfiguratorTest is HubBase { ); } else { return - hubConfigurator.addAsset( + hubConfigurator.addAssetWithDecimals( address(hub1), underlying, decimals, diff --git a/tests/unit/MathUtils.t.sol b/tests/unit/MathUtils.t.sol index f80662d67..b3fb6c9b4 100644 --- a/tests/unit/MathUtils.t.sol +++ b/tests/unit/MathUtils.t.sol @@ -111,6 +111,10 @@ contract MathUtilsTest is Base { MathUtils.add(UINT256_MAX, 1); } + function test_zeroFloorSub(uint256 a, uint256 b) public pure { + assertEq(MathUtils.zeroFloorSub(a, b), a < b ? 0 : a - b); + } + function test_uncheckedAdd(uint256 a, uint256 b) public pure { uint256 result = MathUtils.uncheckedAdd(a, b); assertEq(result, b <= UINT256_MAX - a ? a + b : a - (UINT256_MAX - b) - 1); @@ -127,6 +131,15 @@ contract MathUtilsTest is Base { assertTrue(result <= INT256_MAX); } + function test_signedSub_revertsWith_SafeCastOverflowedUintToInt(uint256 a) public { + a = bound(a, uint256(INT256_MAX) + 1, UINT256_MAX); + vm.expectRevert(abi.encodeWithSelector(SafeCast.SafeCastOverflowedUintToInt.selector, a)); + MathUtils.signedSub(a, 0); + + vm.expectRevert(abi.encodeWithSelector(SafeCast.SafeCastOverflowedUintToInt.selector, a)); + MathUtils.signedSub(0, a); + } + function test_uncheckedSub(uint256 a, uint256 b) public pure { uint256 result = a >= b ? a - b : UINT256_MAX - b + a + 1; assertEq(MathUtils.uncheckedSub(a, b), result); @@ -152,6 +165,16 @@ contract MathUtilsTest is Base { assertEq(result, expectedRes); } + function test_fuzz_divUp(uint256 a, uint256 b) external { + if (b == 0) { + vm.expectRevert(); + MathUtils.divUp(a, b); + } else { + uint256 result = MathUtils.divUp(a, b); + assertEq(result, a / b + (a % b > 0 ? 1 : 0)); + } + } + function test_mulDivDown_WithRemainder() external pure { assertEq(MathUtils.mulDivDown(2, 13, 3), 8); // 26 / 3 = 8.666 -> floor -> 8 } diff --git a/tests/unit/ReserveFlags.t.sol b/tests/unit/ReserveFlags.t.sol index 3ab28113e..d6c2b477c 100644 --- a/tests/unit/ReserveFlags.t.sol +++ b/tests/unit/ReserveFlags.t.sol @@ -10,8 +10,7 @@ contract ReserveFlagsTests is Test { uint8 internal constant PAUSED_MASK = 0x01; uint8 internal constant FROZEN_MASK = 0x02; uint8 internal constant BORROWABLE_MASK = 0x04; - uint8 internal constant LIQUIDATABLE_MASK = 0x08; - uint8 internal constant RECEIVE_SHARES_ENABLED_MASK = 0x10; + uint8 internal constant RECEIVE_SHARES_ENABLED_MASK = 0x08; ReserveFlagsMapWrapper internal w; @@ -23,7 +22,6 @@ contract ReserveFlagsTests is Test { assertEq(w.PAUSED_MASK(), PAUSED_MASK); assertEq(w.FROZEN_MASK(), FROZEN_MASK); assertEq(w.BORROWABLE_MASK(), BORROWABLE_MASK); - assertEq(w.LIQUIDATABLE_MASK(), LIQUIDATABLE_MASK); assertEq(w.RECEIVE_SHARES_ENABLED_MASK(), RECEIVE_SHARES_ENABLED_MASK); } @@ -31,21 +29,18 @@ contract ReserveFlagsTests is Test { bool paused, bool frozen, bool borrowable, - bool liquidatable, bool receiveSharesEnabled ) public view { ReserveFlags flags = w.create({ initPaused: paused, initFrozen: frozen, initBorrowable: borrowable, - initLiquidatable: liquidatable, initReceiveSharesEnabled: receiveSharesEnabled }); assertEq(w.paused(flags), paused); assertEq(w.frozen(flags), frozen); assertEq(w.borrowable(flags), borrowable); - assertEq(w.liquidatable(flags), liquidatable); assertEq(w.receiveSharesEnabled(flags), receiveSharesEnabled); } @@ -54,77 +49,54 @@ contract ReserveFlagsTests is Test { assertEq(w.paused(flags), false); assertEq(w.frozen(flags), false); assertEq(w.borrowable(flags), false); - assertEq(w.liquidatable(flags), false); assertEq(w.receiveSharesEnabled(flags), false); flags = w.setPaused(flags, true); assertEq(w.paused(flags), true); assertEq(w.frozen(flags), false); assertEq(w.borrowable(flags), false); - assertEq(w.liquidatable(flags), false); assertEq(w.receiveSharesEnabled(flags), false); flags = w.setFrozen(flags, true); assertEq(w.paused(flags), true); assertEq(w.frozen(flags), true); assertEq(w.borrowable(flags), false); - assertEq(w.liquidatable(flags), false); assertEq(w.receiveSharesEnabled(flags), false); flags = w.setBorrowable(flags, true); assertEq(w.paused(flags), true); assertEq(w.frozen(flags), true); assertEq(w.borrowable(flags), true); - assertEq(w.liquidatable(flags), false); - assertEq(w.receiveSharesEnabled(flags), false); - - flags = w.setLiquidatable(flags, true); - assertEq(w.paused(flags), true); - assertEq(w.frozen(flags), true); - assertEq(w.borrowable(flags), true); - assertEq(w.liquidatable(flags), true); assertEq(w.receiveSharesEnabled(flags), false); flags = w.setReceiveSharesEnabled(flags, true); assertEq(w.paused(flags), true); assertEq(w.frozen(flags), true); assertEq(w.borrowable(flags), true); - assertEq(w.liquidatable(flags), true); assertEq(w.receiveSharesEnabled(flags), true); flags = w.setFrozen(flags, false); assertEq(w.paused(flags), true); assertEq(w.frozen(flags), false); assertEq(w.borrowable(flags), true); - assertEq(w.liquidatable(flags), true); assertEq(w.receiveSharesEnabled(flags), true); flags = w.setBorrowable(flags, false); assertEq(w.paused(flags), true); assertEq(w.frozen(flags), false); assertEq(w.borrowable(flags), false); - assertEq(w.liquidatable(flags), true); - assertEq(w.receiveSharesEnabled(flags), true); - - flags = w.setLiquidatable(flags, false); - assertEq(w.paused(flags), true); - assertEq(w.frozen(flags), false); - assertEq(w.borrowable(flags), false); - assertEq(w.liquidatable(flags), false); assertEq(w.receiveSharesEnabled(flags), true); flags = w.setReceiveSharesEnabled(flags, false); assertEq(w.paused(flags), true); assertEq(w.frozen(flags), false); assertEq(w.borrowable(flags), false); - assertEq(w.liquidatable(flags), false); assertEq(w.receiveSharesEnabled(flags), false); flags = w.setPaused(flags, false); assertEq(w.paused(flags), false); assertEq(w.frozen(flags), false); assertEq(w.borrowable(flags), false); - assertEq(w.liquidatable(flags), false); assertEq(w.receiveSharesEnabled(flags), false); } @@ -185,25 +157,6 @@ contract ReserveFlagsTests is Test { assertEq(ReserveFlags.unwrap(flags), expectedRawFlags); } - function test_setLiquidatable_fuzz(uint8 rawFlags) public view { - ReserveFlags flags = _sanitizeFlags(rawFlags); - uint8 expectedRawFlags = ReserveFlags.unwrap(flags); - - expectedRawFlags = expectedRawFlags | LIQUIDATABLE_MASK; - - flags = w.setLiquidatable(flags, true); - - assertEq(w.liquidatable(flags), true); - assertEq(ReserveFlags.unwrap(flags), expectedRawFlags); - - expectedRawFlags = expectedRawFlags & ~LIQUIDATABLE_MASK; - - flags = w.setLiquidatable(flags, false); - - assertEq(w.liquidatable(flags), false); - assertEq(ReserveFlags.unwrap(flags), expectedRawFlags); - } - function test_setReceiveSharesEnabled_fuzz(uint8 rawFlags) public view { ReserveFlags flags = _sanitizeFlags(rawFlags); uint8 expectedRawFlags = ReserveFlags.unwrap(flags); @@ -226,11 +179,7 @@ contract ReserveFlagsTests is Test { /// @dev Sanitizes the raw flags by masking out any irrelevant bits. function _sanitizeFlags(uint8 rawFlags) internal pure returns (ReserveFlags) { uint8 sanitizedFlags = rawFlags & - (PAUSED_MASK | - FROZEN_MASK | - BORROWABLE_MASK | - LIQUIDATABLE_MASK | - RECEIVE_SHARES_ENABLED_MASK); + (PAUSED_MASK | FROZEN_MASK | BORROWABLE_MASK | RECEIVE_SHARES_ENABLED_MASK); return ReserveFlags.wrap(sanitizedFlags); } } diff --git a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Base.t.sol b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Base.t.sol index ff22caaee..0ada2a8ad 100644 --- a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Base.t.sol +++ b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Base.t.sol @@ -12,7 +12,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { using MathUtils for uint256; uint256 internal constant MAX_AMOUNT_IN_BASE_CURRENCY = 1_000_000_000e26; // 1 billion USD - uint256 internal constant MIN_AMOUNT_IN_BASE_CURRENCY = 1e26; // 1 USD + uint256 internal constant MIN_AMOUNT_IN_BASE_CURRENCY = 100e26; // 1 USD struct CheckedLiquidationCallParams { ISpoke spoke; @@ -47,22 +47,24 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { } struct LiquidationMetadata { - uint256 debtToTarget; - uint256 collateralToLiquidate; - uint256 collateralToLiquidator; + uint256 debtRayToTarget; + uint256 collateralAssetsToLiquidate; + uint256 collateralAssetsToLiquidator; uint256 collateralSharesToLiquidate; uint256 collateralSharesToLiquidator; - uint256 debtToLiquidate; + uint256 debtAssetsToLiquidate; + uint256 debtRayToLiquidate; + uint256 drawnSharesToLiquidate; + uint256 premiumDebtRayToLiquidate; + uint256 debtAssetsToRestore; uint256 liquidationBonus; - uint256 expectedUserRiskPremium; - uint256 expectedUserAvgCollateralFactor; - bool isCollateralAffectingUserHf; + bool fullDebtReserveLiquidated; bool hasDeficit; } struct ExpectEventsAndCallsParams { uint256 userDrawnDebt; uint256 userPremiumDebt; - uint256 baseAmountToRestore; + uint256 drawnAmountToRestore; int256 realizedDelta; IHubBase.PremiumDelta premiumDelta; ISpoke.UserPosition userReservePosition; @@ -73,59 +75,14 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { uint256 collateralAssetId; } - /// @notice Bound liquidation config to full range of possible values function _bound( - ISpoke.LiquidationConfig memory liqConfig - ) internal pure virtual returns (ISpoke.LiquidationConfig memory) { - liqConfig.targetHealthFactor = bound( - liqConfig.targetHealthFactor, - HEALTH_FACTOR_LIQUIDATION_THRESHOLD, - MAX_CLOSE_FACTOR - ).toUint120(); - - liqConfig.healthFactorForMaxBonus = bound( - liqConfig.healthFactorForMaxBonus, - 0, - HEALTH_FACTOR_LIQUIDATION_THRESHOLD - 1 - ).toUint64(); - - liqConfig.liquidationBonusFactor = bound( - liqConfig.liquidationBonusFactor, - 0, - PercentageMath.PERCENTAGE_FACTOR - ).toUint16(); - - return liqConfig; - } - - function _bound( - ISpoke.DynamicReserveConfig memory dynConfig - ) internal pure virtual returns (ISpoke.DynamicReserveConfig memory) { - dynConfig.maxLiquidationBonus = bound( - dynConfig.maxLiquidationBonus, - MIN_LIQUIDATION_BONUS, - MAX_LIQUIDATION_BONUS - ).toUint32(); - dynConfig.collateralFactor = bound( - dynConfig.collateralFactor, - 1, - (PercentageMath.PERCENTAGE_FACTOR - 1).percentDivDown(dynConfig.maxLiquidationBonus) - ).toUint16(); - return dynConfig; - } - - function _boundAssume( ISpoke spoke, uint256 collateralReserveId, - uint256 debtReserveId, - address user, - address liquidator - ) internal virtual returns (uint256, uint256, address) { + uint256 debtReserveId + ) internal view virtual returns (uint256, uint256) { collateralReserveId = bound(collateralReserveId, 0, spoke.getReserveCount() - 1); debtReserveId = bound(debtReserveId, 0, spoke.getReserveCount() - 1); - vm.assume(user != liquidator); - assumeUnusedAddress(user); - return (collateralReserveId, debtReserveId, user); + return (collateralReserveId, debtReserveId); } function _boundDebtToCoverNoDustRevert( @@ -153,19 +110,24 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { try liquidationLogicWrapper.calculateLiquidationAmounts(params) returns ( LiquidationLogic.LiquidationAmounts memory ) {} catch { - ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); uint256 liquidationBonus = spoke.getLiquidationBonus( collateralReserveId, user, - userAccountData.healthFactor + spoke.getUserAccountData(user).healthFactor + ); + uint256 debtReserveBalance = params.drawnShares.rayMulUp(params.drawnIndex) + + params.premiumDebtRay.fromRayUp(); + uint256 collateralReserveBalance = params.collateralReserveHub.previewRemoveByShares( + params.collateralReserveAssetId, + params.suppliedShares ); debtToCover = bound( debtToCover, - params.debtReserveBalance.min( + debtReserveBalance.min( _convertAssetAmount( spoke, collateralReserveId, - params.collateralReserveBalance.percentDivUp(liquidationBonus), + collateralReserveBalance.percentDivUp(liquidationBonus), debtReserveId ) ), @@ -178,54 +140,6 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { return debtToCover; } - function _bound( - ISpoke spoke, - uint256[] memory reserveIds, - uint256 reserveIdToExclude, - uint256 maxLength - ) internal view returns (bytes memory) { - uint256[] memory boundedReserveIds = new uint256[](_min(reserveIds.length, maxLength)); - - for (uint256 i = 0; i < boundedReserveIds.length; i++) { - boundedReserveIds[i] = bound(reserveIds[i], 0, spoke.getReserveCount() - 1); - if (boundedReserveIds[i] == reserveIdToExclude) { - boundedReserveIds[i] = bound(boundedReserveIds[i] + 1, 0, spoke.getReserveCount() - 1); - } - } - return abi.encode(boundedReserveIds); - } - - function _getCalculateDebtToLiquidateParams( - ISpoke spoke, - uint256 collateralReserveId, - uint256 debtReserveId, - address user, - uint256 debtToCover - ) internal virtual returns (LiquidationLogic.CalculateDebtToLiquidateParams memory) { - ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); - return - LiquidationLogic.CalculateDebtToLiquidateParams({ - debtReserveBalance: spoke.getUserTotalDebt(debtReserveId, user), - totalDebtValue: userAccountData.totalDebtValue, - debtAssetPrice: IPriceOracle(spoke.ORACLE()).getReservePrice(debtReserveId), - debtAssetUnit: 10 ** spoke.getReserve(debtReserveId).decimals, - debtToCover: debtToCover, - liquidationBonus: spoke.getLiquidationBonus( - collateralReserveId, - user, - userAccountData.healthFactor - ), - collateralFactor: spoke - .getDynamicReserveConfig( - collateralReserveId, - spoke.getUserPosition(collateralReserveId, user).dynamicConfigKey - ) - .collateralFactor, - healthFactor: userAccountData.healthFactor, - targetHealthFactor: spoke.getLiquidationConfig().targetHealthFactor - }); - } - function _getCalculateDebtToTargetHealthFactorParams( ISpoke spoke, uint256 collateralReserveId, @@ -235,7 +149,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); return LiquidationLogic.CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: userAccountData.totalDebtValue, + totalDebtValueRay: userAccountData.totalDebtValueRay, debtAssetUnit: 10 ** spoke.getReserve(debtReserveId).decimals, debtAssetPrice: IPriceOracle(spoke.ORACLE()).getReservePrice(debtReserveId), collateralFactor: spoke @@ -264,12 +178,16 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); return LiquidationLogic.CalculateLiquidationAmountsParams({ - collateralReserveBalance: spoke.getUserSuppliedAssets(collateralReserveId, user), - collateralAssetUnit: 10 ** spoke.getReserve(collateralReserveId).decimals, + collateralReserveHub: _hub(spoke, collateralReserveId), + collateralReserveAssetId: spoke.getReserve(collateralReserveId).assetId, + suppliedShares: spoke.getUserPosition(collateralReserveId, user).suppliedShares, + collateralAssetDecimals: spoke.getReserve(collateralReserveId).decimals, collateralAssetPrice: IPriceOracle(spoke.ORACLE()).getReservePrice(collateralReserveId), - debtReserveBalance: spoke.getUserTotalDebt(debtReserveId, user), - totalDebtValue: userAccountData.totalDebtValue, - debtAssetUnit: 10 ** spoke.getReserve(debtReserveId).decimals, + drawnShares: spoke.getUserPosition(debtReserveId, user).drawnShares, + premiumDebtRay: _calculatePremiumDebtRay(spoke, debtReserveId, user), + drawnIndex: _reserveDrawnIndex(spoke, debtReserveId), + totalDebtValueRay: userAccountData.totalDebtValueRay, + debtAssetDecimals: spoke.getReserve(debtReserveId).decimals, debtAssetPrice: IPriceOracle(spoke.ORACLE()).getReservePrice(debtReserveId), debtToCover: debtToCover, collateralFactor: spoke @@ -313,120 +231,193 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { _borrowToBeAtHf(spoke, user, debtReserveId, newHealthFactor); } - function _calculateExpectedUserRiskPremiumAndAvgCollateralFactor( + // calculate expected user account data after liquidation + function _calculateExpectedUserAccountData( CheckedLiquidationCallParams memory params, - ISpoke.UserAccountData memory userAccountDataBefore, - uint256 collateralToLiquidate, - uint256 debtToLiquidate - ) internal virtual returns (uint256, uint256) { - KeyValueList.List memory list = KeyValueList.init(userAccountDataBefore.activeCollateralCount); - - uint256 totalCollateralValue = 0; - uint256 newAvgCollateralFactor = 0; + LiquidationMetadata memory liquidationMetadata + ) internal virtual returns (ISpoke.UserAccountData memory expectedUserAccountData) { + KeyValueList.List memory list = KeyValueList.init(params.spoke.getReserveCount()); - uint256 index = 0; for (uint256 reserveId = 0; reserveId < params.spoke.getReserveCount(); reserveId++) { if (!_isUsingAsCollateral(params.spoke, reserveId, params.user)) { continue; } - uint256 collateralFactor = _getCollateralFactor(params.spoke, reserveId, params.user); - if (collateralFactor == 0) { + if (_getCollateralFactor(params.spoke, reserveId, params.user) == 0) { continue; } - uint256 userSuppliedAmount = params.spoke.getUserSuppliedAssets(reserveId, params.user); + IHubBase hub = _hub(params.spoke, reserveId); + uint256 assetId = _reserveAssetId(params.spoke, reserveId); + uint256 totalAddedAssets = hub.getAddedAssets(assetId); + uint256 totalAddedShares = hub.getAddedShares(assetId); + uint256 userSuppliedShares = params + .spoke + .getUserPosition(reserveId, params.user) + .suppliedShares; + if (params.collateralReserveId == reserveId) { - userSuppliedAmount -= collateralToLiquidate; + userSuppliedShares -= liquidationMetadata.collateralSharesToLiquidate; + if (!params.receiveShares) { + totalAddedAssets -= liquidationMetadata.collateralAssetsToLiquidator; + totalAddedShares -= liquidationMetadata.collateralSharesToLiquidator; + } + } + + if (userSuppliedShares == 0) { + continue; + } + + if (params.debtReserveId == reserveId) { + IHub.Asset memory asset = IHub(address(hub)).getAsset(assetId); + uint256 drawnIndex = _reserveDrawnIndex(params.spoke, reserveId); + uint256 premiumDebtRay = _calculatePremiumDebtRay( + asset.premiumShares, + asset.premiumOffsetRay, + drawnIndex + ); + totalAddedAssets += liquidationMetadata.debtAssetsToLiquidate; + uint256 aggregatedOwedRayBefore = asset.drawnShares * drawnIndex + + premiumDebtRay + + asset.deficitRay; + totalAddedAssets -= (aggregatedOwedRayBefore.fromRayUp() - + (aggregatedOwedRayBefore - liquidationMetadata.debtRayToLiquidate).fromRayUp()); + } + + uint256 userSuppliedAssets = userSuppliedShares.mulDivDown( + totalAddedAssets + Constants.VIRTUAL_ASSETS, + totalAddedShares + Constants.VIRTUAL_SHARES + ); + uint256 userSuppliedValue = _convertAmountToValue( + params.spoke, + reserveId, + userSuppliedAssets + ); + list.add( + expectedUserAccountData.activeCollateralCount++, + _getCollateralRisk(params.spoke, reserveId), + userSuppliedValue + ); + expectedUserAccountData.totalCollateralValue += userSuppliedValue; + expectedUserAccountData.avgCollateralFactor += + _getCollateralFactor(params.spoke, reserveId, params.user) * userSuppliedValue; + } + + for ( + uint256 reserveId = 0; + reserveId < params.spoke.getReserveCount() && !liquidationMetadata.hasDeficit; + reserveId++ + ) { + if (!_isBorrowing(params.spoke, reserveId, params.user)) { + continue; } - if (userSuppliedAmount == 0) { + + uint256 userDrawnShares = params.spoke.getUserPosition(reserveId, params.user).drawnShares; + uint256 userPremiumDebtRay = _calculatePremiumDebtRay(params.spoke, reserveId, params.user); + if (params.debtReserveId == reserveId) { + userDrawnShares -= liquidationMetadata.drawnSharesToLiquidate.toUint120(); + userPremiumDebtRay -= liquidationMetadata.premiumDebtRayToLiquidate; + } + if (userDrawnShares == 0) { continue; } + expectedUserAccountData.borrowCount++; + expectedUserAccountData.totalDebtValueRay += _convertAmountToValue( + params.spoke, + reserveId, + userDrawnShares * _reserveDrawnIndex(params.spoke, reserveId) + userPremiumDebtRay + ); + } - // from now, userSuppliedAmount is in value terms (to avoid stack too deep) - userSuppliedAmount = _convertAmountToValue(params.spoke, reserveId, userSuppliedAmount); - list.add(index++, _getCollateralRisk(params.spoke, reserveId), userSuppliedAmount); - totalCollateralValue += userSuppliedAmount; - newAvgCollateralFactor += collateralFactor * userSuppliedAmount; + if (expectedUserAccountData.totalDebtValueRay > 0) { + expectedUserAccountData.healthFactor = Math.mulDiv( + expectedUserAccountData.avgCollateralFactor, + (WadRayMath.WAD * WadRayMath.RAY) / PercentageMath.PERCENTAGE_FACTOR, + expectedUserAccountData.totalDebtValueRay, + Math.Rounding.Floor + ); + } else { + expectedUserAccountData.healthFactor = type(uint256).max; } - if (totalCollateralValue != 0) { - newAvgCollateralFactor = newAvgCollateralFactor - .wadDivDown(totalCollateralValue) - .fromBpsDown(); + if (expectedUserAccountData.totalCollateralValue != 0) { + expectedUserAccountData.avgCollateralFactor = expectedUserAccountData + .avgCollateralFactor + .mulDivDown( + WadRayMath.WAD / PercentageMath.PERCENTAGE_FACTOR, + expectedUserAccountData.totalCollateralValue + ); } list.sortByKey(); - uint256 debtToLiquidateValue = _convertAmountToValue( - params.spoke, - params.debtReserveId, - debtToLiquidate - ); - uint256 totalDebtToCover = userAccountDataBefore.totalDebtValue - debtToLiquidateValue; - uint256 remainingDebtToCover = totalDebtToCover; - - uint256 newRiskPremium = 0; + uint256 remainingDebtToCover = expectedUserAccountData.totalDebtValueRay.fromRayUp(); for (uint256 i = 0; i < list.length() && remainingDebtToCover > 0; i++) { (uint256 collateralRisk, uint256 collateralValue) = list.get(i); - newRiskPremium += collateralRisk * _min(collateralValue, remainingDebtToCover); + expectedUserAccountData.riskPremium += + collateralRisk * _min(collateralValue, remainingDebtToCover); remainingDebtToCover -= _min(collateralValue, remainingDebtToCover); } - newRiskPremium /= _max(1, _min(totalDebtToCover, totalCollateralValue)); - - return (newRiskPremium, newAvgCollateralFactor); - } - - function _calculateExactRestoreAmount( - IHub hub, - uint256 drawn, - uint256 premium, - uint256 restoreAmount, - uint256 assetId - ) internal view returns (uint256, uint256) { - if (restoreAmount <= premium) { - return (0, restoreAmount); - } - uint256 drawnRestored = _min(drawn, restoreAmount - premium); - // round drawn debt to nearest whole share - drawnRestored = hub.previewRestoreByShares( - assetId, - hub.previewRestoreByAssets(assetId, drawnRestored) + expectedUserAccountData.riskPremium = _divUp( + expectedUserAccountData.riskPremium, + _max( + 1, + _min( + expectedUserAccountData.totalDebtValueRay.fromRayUp(), + expectedUserAccountData.totalCollateralValue + ) + ) ); - return (drawnRestored, premium); + + return expectedUserAccountData; } function _expectEventsAndCalls( CheckedLiquidationCallParams memory params, - AccountsInfo memory /*accountsInfoBefore*/, - LiquidationMetadata memory liquidationMetadata + AccountsInfo memory accountsInfoBefore, + LiquidationMetadata memory liquidationMetadata, + ISpoke.UserAccountData memory expectedUserAccountData ) internal virtual { ExpectEventsAndCallsParams memory vars; vars.userDebtPosition = params.spoke.getUserPosition(params.debtReserveId, params.user); vars.collateralHub = _hub(params.spoke, params.collateralReserveId); vars.debtHub = _hub(params.spoke, params.debtReserveId); - vars.debtAssetId = _spokeAssetId(params.spoke, params.debtReserveId); - vars.collateralAssetId = _spokeAssetId(params.spoke, params.collateralReserveId); + vars.debtAssetId = _reserveAssetId(params.spoke, params.debtReserveId); + vars.collateralAssetId = _reserveAssetId(params.spoke, params.collateralReserveId); (vars.userDrawnDebt, vars.userPremiumDebt) = params.spoke.getUserDebt( params.debtReserveId, params.user ); - (vars.baseAmountToRestore, ) = _calculateRestoreAmounts( - params.spoke, - params.debtReserveId, - params.user, - liquidationMetadata.debtToLiquidate + vars.drawnAmountToRestore = vars.debtHub.previewRestoreByShares( + vars.debtAssetId, + liquidationMetadata.drawnSharesToLiquidate ); + uint256 amountToRestore = vars.drawnAmountToRestore + + liquidationMetadata.premiumDebtRayToLiquidate.fromRayUp(); vars.premiumDelta = _getExpectedPremiumDeltaForRestore( params.spoke, params.user, params.debtReserveId, - liquidationMetadata.debtToLiquidate + amountToRestore ); + if ( + liquidationMetadata.collateralSharesToLiquidate > + liquidationMetadata.collateralSharesToLiquidator + ) { + vm.expectEmit(address(_hub(params.spoke, params.collateralReserveId))); + emit IHubBase.TransferShares({ + assetId: _reserveAssetId(params.spoke, params.collateralReserveId), + sender: address(params.spoke), + receiver: _getFeeReceiver(params.spoke, params.collateralReserveId), + shares: liquidationMetadata.collateralSharesToLiquidate - + liquidationMetadata.collateralSharesToLiquidator + }); + } + vm.expectEmit(address(params.spoke)); emit ISpokeBase.LiquidationCall({ collateralReserveId: params.collateralReserveId, @@ -434,44 +425,57 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { user: params.user, liquidator: params.liquidator, receiveShares: params.receiveShares, - debtToLiquidate: liquidationMetadata.debtToLiquidate, - drawnSharesToLiquidate: vars - .debtHub - .previewRestoreByAssets(vars.debtAssetId, vars.baseAmountToRestore) - .toUint120(), + debtAmountRestored: amountToRestore, + drawnSharesLiquidated: liquidationMetadata.drawnSharesToLiquidate, premiumDelta: vars.premiumDelta, - collateralToLiquidate: liquidationMetadata.collateralToLiquidate, - collateralSharesToLiquidate: liquidationMetadata.collateralSharesToLiquidate, + collateralAmountRemoved: vars.collateralHub.previewRemoveByShares( + vars.collateralAssetId, + liquidationMetadata.collateralSharesToLiquidate + ), + collateralSharesLiquidated: liquidationMetadata.collateralSharesToLiquidate, collateralSharesToLiquidator: liquidationMetadata.collateralSharesToLiquidator }); - if (!params.receiveShares && liquidationMetadata.collateralToLiquidator > 0) { - vm.expectCall( - address(vars.collateralHub), - abi.encodeCall( - IHubBase.remove, - (vars.collateralAssetId, liquidationMetadata.collateralToLiquidator, params.liquidator) - ), - 1 - ); - } + vm.expectCall( + address(vars.collateralHub), + abi.encodeCall( + IHubBase.remove, + ( + vars.collateralAssetId, + liquidationMetadata.collateralAssetsToLiquidator, + params.liquidator + ) + ), + (!params.receiveShares && liquidationMetadata.collateralSharesToLiquidator > 0) ? 1 : 0 + ); vm.expectCall( address(vars.debtHub), abi.encodeCall( IHubBase.restore, - (vars.debtAssetId, vars.baseAmountToRestore, vars.premiumDelta) + (vars.debtAssetId, vars.drawnAmountToRestore, vars.premiumDelta) ), 1 ); - // PayFee call is partially checked, as conversion from assets to shares might differ due to restore donation - if (liquidationMetadata.collateralToLiquidate > liquidationMetadata.collateralToLiquidator) { - vm.expectCall( - address(_hub(params.spoke, params.collateralReserveId)), - abi.encodeWithSelector(IHubBase.payFeeShares.selector) - ); - } + vm.expectCall( + address(_hub(params.spoke, params.collateralReserveId)), + abi.encodeCall( + IHubBase.payFeeShares, + ( + vars.collateralAssetId, + liquidationMetadata.collateralSharesToLiquidate - + liquidationMetadata.collateralSharesToLiquidator + ) + ), + liquidationMetadata.collateralSharesToLiquidate > + liquidationMetadata.collateralSharesToLiquidator + ? 1 + : 0 + ); + + bool riskPremiumOptimisation = accountsInfoBefore.userLastRiskPremium == 0 && + expectedUserAccountData.riskPremium == 0; { for (uint256 i = params.spoke.getReserveCount(); i != 0; ) { @@ -479,20 +483,18 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { uint256 reserveId = i; if (_isBorrowing(params.spoke, reserveId, params.user)) { vars.userReservePosition = params.spoke.getUserPosition(reserveId, params.user); - uint256 assetId = _spokeAssetId(params.spoke, reserveId); + uint256 assetId = _reserveAssetId(params.spoke, reserveId); if (reserveId == params.debtReserveId) { - vars.userReservePosition.drawnShares -= _hub(params.spoke, reserveId) - .previewRestoreByAssets(assetId, vars.baseAmountToRestore) + vars.userReservePosition.drawnShares -= liquidationMetadata + .drawnSharesToLiquidate .toUint120(); if (vars.userReservePosition.drawnShares == 0) { continue; } - - vars.userReservePosition.premiumShares = (vars - .userReservePosition - .premiumShares - .toInt256() + vars.premiumDelta.sharesDelta).toUint256().toUint120(); + vars.userReservePosition.premiumShares = uint256(vars.userReservePosition.premiumShares) + .add(vars.premiumDelta.sharesDelta) + .toUint120(); vars.userReservePosition.premiumOffsetRay = (vars.userReservePosition.premiumOffsetRay + vars.premiumDelta.offsetRayDelta).toInt200(); } @@ -530,14 +532,56 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { emit ISpoke.ReportDeficit({ reserveId: reserveId, user: params.user, - drawnShares: targetHub - .previewRestoreByAssets(assetId, userReserveDrawnDebt) - .toUint120(), + drawnShares: vars.userReservePosition.drawnShares, premiumDelta: premiumDelta }); + } else { + vm.expectCall( + address(targetHub), + abi.encodeWithSelector(IHubBase.reportDeficit.selector, assetId), + 0 + ); + + if (!riskPremiumOptimisation) { + IHubBase.PremiumDelta memory premiumDelta = _getExpectedPremiumDelta({ + hub: targetHub, + assetId: assetId, + oldPremiumShares: vars.userReservePosition.premiumShares, + oldPremiumOffsetRay: vars.userReservePosition.premiumOffsetRay, + drawnShares: vars.userReservePosition.drawnShares, + riskPremium: expectedUserAccountData.riskPremium, + restoredPremiumRay: 0 + }); + + vm.expectCall( + address(targetHub), + abi.encodeCall(IHubBase.refreshPremium, (assetId, premiumDelta)), + 1 + ); + vm.expectEmit(address(params.spoke)); + emit ISpoke.RefreshPremiumDebt({ + reserveId: reserveId, + user: params.user, + premiumDelta: premiumDelta + }); + } else { + vm.expectCall( + address(targetHub), + abi.encodeWithSelector(IHubBase.refreshPremium.selector, assetId), + 0 + ); + } } } } + + if (!liquidationMetadata.hasDeficit && !riskPremiumOptimisation) { + vm.expectEmit(address(params.spoke)); + emit ISpoke.UpdateUserRiskPremium({ + user: params.user, + riskPremium: expectedUserAccountData.riskPremium + }); + } } } @@ -554,13 +598,13 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { ), suppliedInSpoke: spoke.getUserSuppliedAssets(collateralReserveId, addr), addedInHub: _hub(spoke, collateralReserveId).getSpokeAddedAssets( - _spokeAssetId(spoke, collateralReserveId), + _reserveAssetId(spoke, collateralReserveId), addr ), debtErc20Balance: getAssetUnderlyingByReserveId(spoke, debtReserveId).balanceOf(addr), borrowedFromSpoke: spoke.getUserTotalDebt(debtReserveId, addr), drawnFromHub: _hub(spoke, debtReserveId).getSpokeTotalOwed( - _spokeAssetId(spoke, debtReserveId), + _reserveAssetId(spoke, debtReserveId), addr ) }); @@ -622,7 +666,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { CheckedLiquidationCallParams memory params, ISpoke.UserAccountData memory userAccountDataBefore ) internal virtual returns (LiquidationMetadata memory) { - uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( + uint256 debtRayToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( _getCalculateDebtToTargetHealthFactorParams( params.spoke, params.collateralReserveId, @@ -630,6 +674,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { params.user ) ); + LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts( _getCalculateLiquidationAmountsParams( @@ -641,89 +686,100 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { ) ); - uint256 collateralSharesToLiquidate = _hub(params.spoke, params.collateralReserveId) - .previewRemoveByAssets( - _spokeAssetId(params.spoke, params.collateralReserveId), - liquidationAmounts.collateralToLiquidate - ); - - uint256 collateralSharesToLiquidator; - if (params.receiveShares && liquidationAmounts.collateralToLiquidator > 0) { - collateralSharesToLiquidator = _hub(params.spoke, params.collateralReserveId) - .previewAddByAssets( - _spokeAssetId(params.spoke, params.collateralReserveId), - liquidationAmounts.collateralToLiquidator - ); - } else { - collateralSharesToLiquidator = _hub(params.spoke, params.collateralReserveId) - .previewRemoveByAssets( - _spokeAssetId(params.spoke, params.collateralReserveId), - liquidationAmounts.collateralToLiquidator - ); - } - uint256 liquidationBonus = params.spoke.getLiquidationBonus( params.collateralReserveId, params.user, userAccountDataBefore.healthFactor ); - ( - uint256 expectedUserRiskPremium, - uint256 expectedUserAvgCollateralFactor - ) = _calculateExpectedUserRiskPremiumAndAvgCollateralFactor( - params, - userAccountDataBefore, - liquidationAmounts.collateralToLiquidate, - liquidationAmounts.debtToLiquidate - ); - - uint256 debtToLiquidateValue = _convertAmountToValue( - params.spoke, - params.debtReserveId, - liquidationAmounts.debtToLiquidate - ); - - // health factor is decreasing due to liquidation bonus / collateral factor if: - // (totalCollateralValue - debtToLiquidateValue * LB) * newCF / (totalDebtValue - debtToLiquidateValue) < totalCollateralValue * oldCF / totalDebtValue - // this is equivalent to: LB * totalDebtValue * debtToLiquidateValue * newCF > totalCollateralValue * (totalDebtValue * (newCF - oldCF) + debtToLiquidateValue * oldCF) - bool isCollateralAffectingUserHf = (liquidationBonus * - userAccountDataBefore.totalDebtValue.wadMulUp(debtToLiquidateValue) * - expectedUserAvgCollateralFactor).toInt256() > - PercentageMath.PERCENTAGE_FACTOR.toInt256() * - (userAccountDataBefore - .totalCollateralValue - .wadMulDown(userAccountDataBefore.totalDebtValue) - .toInt256() * - (expectedUserAvgCollateralFactor.toInt256() - - userAccountDataBefore.avgCollateralFactor.toInt256()) + - (userAccountDataBefore.totalCollateralValue.wadMulDown(debtToLiquidateValue) * - userAccountDataBefore.avgCollateralFactor).toInt256()); + bool fullDebtReserveLiquidated = liquidationAmounts.drawnSharesToLiquidate == + _getUserDrawnShares(params.spoke, params.debtReserveId, params.user); bool hasDeficit = (userAccountDataBefore.activeCollateralCount == 1) && - (!params.isSolvent || isCollateralAffectingUserHf) && - (liquidationAmounts.collateralToLiquidate == - params.spoke.getUserSuppliedAssets(params.collateralReserveId, params.user)); + (liquidationAmounts.collateralSharesToLiquidate == + params.spoke.getUserPosition(params.collateralReserveId, params.user).suppliedShares) && + (userAccountDataBefore.borrowCount > 1 || !fullDebtReserveLiquidated); + + uint256 drawnIndex = _hub(params.spoke, params.debtReserveId).getAssetDrawnIndex( + _reserveAssetId(params.spoke, params.debtReserveId) + ); + uint256 debtAssetsToLiquidate = _calculateDebtAssetsToRestore({ + drawnSharesToLiquidate: liquidationAmounts.drawnSharesToLiquidate, + premiumDebtRayToLiquidate: liquidationAmounts.premiumDebtRayToLiquidate, + drawnIndex: drawnIndex + }); + IHubBase collateralHub = _hub(params.spoke, params.collateralReserveId); + uint256 collateralAssetId = _reserveAssetId(params.spoke, params.collateralReserveId); return LiquidationMetadata({ - debtToTarget: debtToTarget, - collateralToLiquidate: liquidationAmounts.collateralToLiquidate, - collateralToLiquidator: liquidationAmounts.collateralToLiquidator, - collateralSharesToLiquidate: collateralSharesToLiquidate, - collateralSharesToLiquidator: collateralSharesToLiquidator, - debtToLiquidate: liquidationAmounts.debtToLiquidate, + debtRayToTarget: debtRayToTarget, + collateralAssetsToLiquidate: collateralHub.previewRemoveByShares( + collateralAssetId, + liquidationAmounts.collateralSharesToLiquidate + ), + collateralAssetsToLiquidator: collateralHub.previewRemoveByShares( + collateralAssetId, + liquidationAmounts.collateralSharesToLiquidator + ), + collateralSharesToLiquidate: liquidationAmounts.collateralSharesToLiquidate, + collateralSharesToLiquidator: liquidationAmounts.collateralSharesToLiquidator, + debtAssetsToLiquidate: debtAssetsToLiquidate, + debtRayToLiquidate: liquidationAmounts.drawnSharesToLiquidate * drawnIndex + + liquidationAmounts.premiumDebtRayToLiquidate, + drawnSharesToLiquidate: liquidationAmounts.drawnSharesToLiquidate, + premiumDebtRayToLiquidate: liquidationAmounts.premiumDebtRayToLiquidate, + debtAssetsToRestore: _calculateDebtAssetsToRestore( + liquidationAmounts.drawnSharesToLiquidate, + liquidationAmounts.premiumDebtRayToLiquidate, + drawnIndex + ), liquidationBonus: liquidationBonus, - expectedUserRiskPremium: expectedUserRiskPremium, - expectedUserAvgCollateralFactor: expectedUserAvgCollateralFactor, - isCollateralAffectingUserHf: isCollateralAffectingUserHf, + fullDebtReserveLiquidated: fullDebtReserveLiquidated, hasDeficit: hasDeficit }); } + function _isCollateralAffectingUserHf( + CheckedLiquidationCallParams memory params, + LiquidationLogic.LiquidationAmounts memory liquidationAmounts, + ISpoke.UserAccountData memory userAccountDataBefore, + ISpoke.UserAccountData memory userAccountDataAfter + ) internal view returns (bool) { + // collateral reserve + uint256 collateralValueRemoved = userAccountDataBefore.totalCollateralValue - + userAccountDataAfter.totalCollateralValue; + + // debt reserve + uint256 drawnIndex = _reserveDrawnIndex(params.spoke, params.debtReserveId); + uint256 debtValueRayRepaid = _convertAmountToValue( + params.spoke, + params.debtReserveId, + liquidationAmounts.drawnSharesToLiquidate * drawnIndex + + liquidationAmounts.premiumDebtRayToLiquidate + ); + + if (debtValueRayRepaid == 0) { + return false; + } + + uint256 effectiveLiquidationBonusWad = Math.mulDiv( + collateralValueRemoved, + WadRayMath.RAY * WadRayMath.WAD, + debtValueRayRepaid, + Math.Rounding.Ceil + ); + + // health factor is decreasing due to liquidation bonus / collateral factor if: + // lb * cf > hf_beforeLiq + return + effectiveLiquidationBonusWad * + _getCollateralFactor(params.spoke, params.collateralReserveId, params.user) > + userAccountDataBefore.healthFactor * PercentageMath.PERCENTAGE_FACTOR; + } + function _checkPositionStatus( CheckedLiquidationCallParams memory params, - AccountsInfo memory accountsInfoBefore, LiquidationMetadata memory liquidationMetadata ) internal virtual { assertEq( @@ -731,10 +787,11 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { true, 'user position status: using as collateral' ); - assertEq( - _isBorrowing(params.spoke, params.debtReserveId, params.user) || - liquidationMetadata.hasDeficit, - liquidationMetadata.debtToLiquidate < accountsInfoBefore.userBalanceInfo.borrowedFromSpoke, + bool isBorrowing = _isBorrowing(params.spoke, params.debtReserveId, params.user); + assertTrue( + !liquidationMetadata.fullDebtReserveLiquidated + ? (isBorrowing || liquidationMetadata.hasDeficit) + : !isBorrowing, 'user position status: borrowing' ); } @@ -745,10 +802,19 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { AccountsInfo memory accountsInfoAfter, LiquidationMetadata memory liquidationMetadata ) internal virtual { - if ( - accountsInfoAfter.userAccountData.totalDebtValue == 0 || - (params.isSolvent && !liquidationMetadata.isCollateralAffectingUserHf) - ) { + // accountsInfoAfter.userAccountData was already checked against expectedUserAccountData + bool isCollateralAffectingUserHf = _isCollateralAffectingUserHf( + params, + LiquidationLogic.LiquidationAmounts({ + collateralSharesToLiquidate: liquidationMetadata.collateralSharesToLiquidate, + collateralSharesToLiquidator: liquidationMetadata.collateralSharesToLiquidator, + drawnSharesToLiquidate: liquidationMetadata.drawnSharesToLiquidate, + premiumDebtRayToLiquidate: liquidationMetadata.premiumDebtRayToLiquidate + }), + accountsInfoBefore.userAccountData, + accountsInfoAfter.userAccountData + ); + if (accountsInfoAfter.userAccountData.totalDebtValueRay == 0 || !isCollateralAffectingUserHf) { assertGe( accountsInfoAfter.userAccountData.healthFactor, accountsInfoBefore.userAccountData.healthFactor, @@ -762,21 +828,17 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { ); } - if (accountsInfoAfter.userAccountData.totalDebtValue == 0) { + if ( + liquidationMetadata.hasDeficit || + (liquidationMetadata.fullDebtReserveLiquidated && + accountsInfoBefore.userAccountData.borrowCount == 1) + ) { assertEq( accountsInfoAfter.userAccountData.healthFactor, UINT256_MAX, 'health factor should be max if all debt is liquidated' ); - } else if (liquidationMetadata.debtToLiquidate == liquidationMetadata.debtToTarget) { - assertApproxEqRel( - accountsInfoAfter.userAccountData.healthFactor, - _getTargetHealthFactor(params.spoke), - _approxRelFromBps(1), - 'health factor should be approx equal to target health factor' - ); - } else if (liquidationMetadata.debtToLiquidate > liquidationMetadata.debtToTarget) { - // dust adjusted + } else if (liquidationMetadata.debtRayToTarget <= liquidationMetadata.debtRayToLiquidate) { assertGe( accountsInfoAfter.userAccountData.healthFactor, _getTargetHealthFactor(params.spoke), @@ -870,11 +932,12 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { // Hubs address collateralHub = address(_hub(params.spoke, params.collateralReserveId)); address debtHub = address(_hub(params.spoke, params.debtReserveId)); + if (collateralHub == debtHub && params.collateralReserveId == params.debtReserveId) { assertEq( accountsInfoAfter.collateralHubBalanceInfo.collateralErc20Balance, accountsInfoBefore.collateralHubBalanceInfo.collateralErc20Balance + - liquidationMetadata.debtToLiquidate, + liquidationMetadata.debtAssetsToLiquidate, 'collateral hub: collateral erc20 balance' ); } else { @@ -893,7 +956,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { assertEq( accountsInfoAfter.debtHubBalanceInfo.debtErc20Balance, accountsInfoBefore.debtHubBalanceInfo.debtErc20Balance + - liquidationMetadata.debtToLiquidate, + liquidationMetadata.debtAssetsToLiquidate, 'debt hub: debt erc20 balance' ); if (collateralHub != debtHub) { @@ -913,7 +976,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { assertEq( accountsInfoAfter.liquidatorBalanceInfo.collateralErc20Balance, accountsInfoBefore.liquidatorBalanceInfo.collateralErc20Balance - - liquidationMetadata.debtToLiquidate, + liquidationMetadata.debtAssetsToLiquidate, 'liquidator: collateral erc20 balance' ); } else { @@ -925,7 +988,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { assertEq( accountsInfoAfter.liquidatorBalanceInfo.debtErc20Balance, accountsInfoBefore.liquidatorBalanceInfo.debtErc20Balance - - liquidationMetadata.debtToLiquidate, + liquidationMetadata.debtAssetsToLiquidate, 'liquidator: debt erc20 balance' ); } @@ -944,15 +1007,15 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { assertEq( accountsInfoAfter.collateralHubBalanceInfo.collateralErc20Balance, accountsInfoBefore.collateralHubBalanceInfo.collateralErc20Balance - - liquidationMetadata.collateralToLiquidator + - liquidationMetadata.debtToLiquidate, + liquidationMetadata.collateralAssetsToLiquidator + + liquidationMetadata.debtAssetsToLiquidate, 'collateral hub: collateral erc20 balance' ); } else { assertEq( accountsInfoAfter.collateralHubBalanceInfo.collateralErc20Balance, accountsInfoBefore.collateralHubBalanceInfo.collateralErc20Balance - - liquidationMetadata.collateralToLiquidator, + liquidationMetadata.collateralAssetsToLiquidator, 'collateral hub: collateral erc20 balance' ); if (collateralHub != debtHub) { @@ -966,7 +1029,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { assertEq( accountsInfoAfter.debtHubBalanceInfo.debtErc20Balance, accountsInfoBefore.debtHubBalanceInfo.debtErc20Balance + - liquidationMetadata.debtToLiquidate, + liquidationMetadata.debtAssetsToLiquidate, 'debt hub: debt erc20 balance' ); if (collateralHub != debtHub) { @@ -986,21 +1049,21 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { assertEq( accountsInfoAfter.liquidatorBalanceInfo.collateralErc20Balance, accountsInfoBefore.liquidatorBalanceInfo.collateralErc20Balance + - liquidationMetadata.collateralToLiquidator - - liquidationMetadata.debtToLiquidate, + liquidationMetadata.collateralAssetsToLiquidator - + liquidationMetadata.debtAssetsToLiquidate, 'liquidator: collateral erc20 balance' ); } else { assertEq( accountsInfoAfter.liquidatorBalanceInfo.collateralErc20Balance, accountsInfoBefore.liquidatorBalanceInfo.collateralErc20Balance + - liquidationMetadata.collateralToLiquidator, + liquidationMetadata.collateralAssetsToLiquidator, 'liquidator: collateral erc20 balance' ); assertEq( accountsInfoAfter.liquidatorBalanceInfo.debtErc20Balance, accountsInfoBefore.liquidatorBalanceInfo.debtErc20Balance - - liquidationMetadata.debtToLiquidate, + liquidationMetadata.debtAssetsToLiquidate, 'liquidator: debt erc20 balance' ); } @@ -1013,20 +1076,20 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { LiquidationMetadata memory liquidationMetadata ) internal pure { // User - assertApproxEqRel( + assertApproxEqAbs( accountsInfoAfter.userBalanceInfo.suppliedInSpoke, accountsInfoBefore.userBalanceInfo.suppliedInSpoke - - liquidationMetadata.collateralToLiquidate, - _approxRelFromBps(1), + liquidationMetadata.collateralAssetsToLiquidate, + 2, 'user: collateral supplied' ); - assertApproxEqRel( + assertApproxEqAbs( accountsInfoAfter.userBalanceInfo.borrowedFromSpoke, (liquidationMetadata.hasDeficit) ? 0 : accountsInfoBefore.userBalanceInfo.borrowedFromSpoke - - liquidationMetadata.debtToLiquidate, - _approxRelFromBps(1), + liquidationMetadata.debtAssetsToLiquidate, + 2, 'user: debt borrowed' ); @@ -1054,17 +1117,17 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { // Liquidator if (!params.receiveShares) { - assertEq( + assertApproxEqAbs( accountsInfoAfter.liquidatorBalanceInfo.suppliedInSpoke, accountsInfoBefore.liquidatorBalanceInfo.suppliedInSpoke, + 2, 'liquidator: collateral supplied' ); } else { - // collateral rounded down on receiveShares, can differ by 2 wei in asset terms assertApproxEqAbs( accountsInfoAfter.liquidatorBalanceInfo.suppliedInSpoke, accountsInfoBefore.liquidatorBalanceInfo.suppliedInSpoke + - liquidationMetadata.collateralToLiquidator, + liquidationMetadata.collateralAssetsToLiquidator, 2, 'liquidator: collateral supplied (receiveShares)' ); @@ -1169,25 +1232,14 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { accountsInfoBefore.collateralFeeReceiverBalanceInfo.drawnFromHub, 'collateral fee receiver: drawn' ); - if (!params.receiveShares) { - assertApproxEqRel( - accountsInfoAfter.collateralFeeReceiverBalanceInfo.addedInHub, - accountsInfoBefore.collateralFeeReceiverBalanceInfo.addedInHub + - liquidationMetadata.collateralToLiquidate - - liquidationMetadata.collateralToLiquidator, - _approxRelFromBps(1), - 'collateral fee receiver: added' - ); - } else { - assertApproxEqAbs( - accountsInfoAfter.collateralFeeReceiverBalanceInfo.addedInHub, - accountsInfoBefore.collateralFeeReceiverBalanceInfo.addedInHub + - liquidationMetadata.collateralToLiquidate - - liquidationMetadata.collateralToLiquidator, - 2, - 'collateral fee receiver: added (receiveShares)' - ); - } + assertApproxEqAbs( + accountsInfoAfter.collateralFeeReceiverBalanceInfo.addedInHub, + accountsInfoBefore.collateralFeeReceiverBalanceInfo.addedInHub + + liquidationMetadata.collateralAssetsToLiquidate - + liquidationMetadata.collateralAssetsToLiquidator, + 2, + 'collateral fee receiver: added' + ); if ( _getFeeReceiver(params.spoke, params.collateralReserveId) != @@ -1206,104 +1258,37 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { } // Spoke - assertApproxEqRel( + assertApproxEqAbs( accountsInfoAfter.spokeBalanceInfo.addedInHub, - accountsInfoBefore.spokeBalanceInfo.addedInHub - liquidationMetadata.collateralToLiquidate, - _approxRelFromBps(10), + accountsInfoBefore.spokeBalanceInfo.addedInHub - + ( + params.receiveShares + ? liquidationMetadata.collateralAssetsToLiquidate - + liquidationMetadata.collateralAssetsToLiquidator + : liquidationMetadata.collateralAssetsToLiquidate + ), + 5, 'spoke: added' ); - assertApproxEqRel( + assertApproxEqAbs( accountsInfoAfter.spokeBalanceInfo.drawnFromHub, (liquidationMetadata.hasDeficit) - ? 0 - : accountsInfoBefore.spokeBalanceInfo.drawnFromHub - liquidationMetadata.debtToLiquidate, - _approxRelFromBps(1), + ? accountsInfoBefore.spokeBalanceInfo.drawnFromHub - + accountsInfoBefore.userBalanceInfo.borrowedFromSpoke + : accountsInfoBefore.spokeBalanceInfo.drawnFromHub - + liquidationMetadata.debtAssetsToLiquidate, + 2, 'spoke: drawn' ); } - function _checkTransferSharesCall( - CheckedLiquidationCallParams memory params, - LiquidationMetadata memory liquidationMetadata, - Vm.Log[] memory logs - ) internal view { - uint256 transferSharesEventCount = 0; - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics[0] == IHubBase.TransferShares.selector) { - transferSharesEventCount += 1; - - assertEq( - uint256(logs[i].topics[1]), - _spokeAssetId(params.spoke, params.collateralReserveId) - ); - address sender = address(uint160(uint256(logs[i].topics[2]))); - address receiver = address(uint160(uint256(logs[i].topics[3]))); - uint256 shares = abi.decode(logs[i].data, (uint256)); - uint256 expectedShares = _hub(params.spoke, params.collateralReserveId) - .previewRemoveByAssets( - _spokeAssetId(params.spoke, params.collateralReserveId), - liquidationMetadata.collateralToLiquidate - liquidationMetadata.collateralToLiquidator - ); - assertApproxEqAbs(shares, expectedShares, 1); - assertEq(sender, address(params.spoke)); - assertEq(receiver, _getFeeReceiver(params.spoke, params.collateralReserveId)); - } - } - - assertEq( - transferSharesEventCount, - (liquidationMetadata.collateralToLiquidate > liquidationMetadata.collateralToLiquidator) - ? 1 - : 0, - 'transfer shares: event emitted' - ); - } - - function _checkRiskPremium( + function _checkUserAccountData( CheckedLiquidationCallParams memory params, - AccountsInfo memory accountsInfoBefore, AccountsInfo memory accountsInfoAfter, LiquidationMetadata memory liquidationMetadata, - Vm.Log[] memory logs + ISpoke.UserAccountData memory expectedUserAccountData ) internal view { - uint256 precision = 0.1e18; - uint256 riskPremiumEventCount; - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics[0] == ISpoke.UpdateUserRiskPremium.selector) { - riskPremiumEventCount += 1; - - assertEq(address(uint160(uint256(logs[i].topics[1]))), address(params.user)); - uint256 actualUserRiskPremium = abi.decode(logs[i].data, (uint256)); - assertApproxEqRel( - actualUserRiskPremium, - liquidationMetadata.expectedUserRiskPremium, - precision, - 'user risk premium: event' - ); - } - } - - uint256 riskPremiumEventExpectedCount = 1; - if (accountsInfoBefore.userLastRiskPremium == 0 && accountsInfoAfter.userLastRiskPremium == 0) { - riskPremiumEventExpectedCount = 0; - } - assertEq(riskPremiumEventCount, riskPremiumEventExpectedCount, 'riskPremiumEventExpectedCount'); - assertEq( - accountsInfoAfter.userLastRiskPremium, - accountsInfoAfter.userAccountData.riskPremium, - 'user latest risk premium' - ); - - assertApproxEqRel( - accountsInfoAfter.userAccountData.riskPremium, - liquidationMetadata.expectedUserRiskPremium, - precision, - 'user risk premium: user account data' - ); - - if (liquidationMetadata.hasDeficit) { - assertEq(accountsInfoAfter.userLastRiskPremium, 0, 'user risk premium: 0 in deficit'); - } + assertEq(accountsInfoAfter.userAccountData, expectedUserAccountData); for (uint256 reserveId = 0; reserveId < params.spoke.getReserveCount(); reserveId++) { if (_isBorrowing(params.spoke, reserveId, params.user)) { @@ -1319,44 +1304,42 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { ); } } - } - function _checkAvgCollateralFactor( - AccountsInfo memory accountsInfoAfter, - LiquidationMetadata memory liquidationMetadata - ) internal pure { - assertApproxEqRel( - accountsInfoAfter.userAccountData.avgCollateralFactor, - liquidationMetadata.expectedUserAvgCollateralFactor, - 0.1e18, - 'user avg collateral factor: user account data' + assertEq( + accountsInfoAfter.userAccountData.riskPremium, + accountsInfoAfter.userLastRiskPremium, + 'user risk premium: user account data' ); + if (liquidationMetadata.hasDeficit) { + assertEq(accountsInfoAfter.userLastRiskPremium, 0, 'user risk premium: 0 in deficit'); + } } - function _execBeforeLiquidation(CheckedLiquidationCallParams memory params) internal virtual {} - function _assertBeforeLiquidation( CheckedLiquidationCallParams memory params, AccountsInfo memory accountsInfoBefore, LiquidationMetadata memory liquidationMetadata - ) internal virtual {} + ) internal view virtual {} function _checkedLiquidationCall(CheckedLiquidationCallParams memory params) internal virtual { - // make sure there is enough liquidity to liquidate - _openSupplyPosition(params.spoke, params.collateralReserveId, MAX_SUPPLY_AMOUNT); - - _execBeforeLiquidation(params); + // ensures there is enough liquidity to liquidate + _openSupplyPosition( + params.spoke, + params.collateralReserveId, + params.spoke.getUserSuppliedAssets(params.collateralReserveId, params.user) + ); AccountsInfo memory accountsInfoBefore = _getAccountsInfo(params); LiquidationMetadata memory liquidationMetadata = _getLiquidationMetadata( params, accountsInfoBefore.userAccountData ); - + ISpoke.UserAccountData memory expectedUserAccountData = _calculateExpectedUserAccountData( + params, + liquidationMetadata + ); _assertBeforeLiquidation(params, accountsInfoBefore, liquidationMetadata); - - _expectEventsAndCalls(params, accountsInfoBefore, liquidationMetadata); - vm.recordLogs(); + _expectEventsAndCalls(params, accountsInfoBefore, liquidationMetadata, expectedUserAccountData); vm.prank(params.liquidator); params.spoke.liquidationCall( params.collateralReserveId, @@ -1365,15 +1348,9 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { params.debtToCover, params.receiveShares ); - Vm.Log[] memory logs = vm.getRecordedLogs(); - AccountsInfo memory accountsInfoAfter = _getAccountsInfo(params); - - _checkTransferSharesCall(params, liquidationMetadata, logs); - _checkRiskPremium(params, accountsInfoBefore, accountsInfoAfter, liquidationMetadata, logs); - _checkAvgCollateralFactor(accountsInfoAfter, liquidationMetadata); - - _checkPositionStatus(params, accountsInfoBefore, liquidationMetadata); + _checkUserAccountData(params, accountsInfoAfter, liquidationMetadata, expectedUserAccountData); + _checkPositionStatus(params, liquidationMetadata); _checkHealthFactor(params, accountsInfoBefore, accountsInfoAfter, liquidationMetadata); _checkErc20Balances(params, accountsInfoBefore, accountsInfoAfter, liquidationMetadata); _checkSpokeBalances(params, accountsInfoBefore, accountsInfoAfter, liquidationMetadata); diff --git a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Dust.t.sol b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Dust.t.sol index 9c6c225b0..5e3726dd4 100644 --- a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Dust.t.sol +++ b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Dust.t.sol @@ -80,7 +80,7 @@ contract SpokeLiquidationCallDustTest is SpokeLiquidationCallBaseTest { }); _borrowToBeAtHf(_spoke, alice, _usdxReserveId(_spoke), 0.9999e18); - uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( + uint256 debtRayToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( _getCalculateDebtToTargetHealthFactorParams( _spoke, _daiReserveId(_spoke), @@ -89,10 +89,10 @@ contract SpokeLiquidationCallDustTest is SpokeLiquidationCallBaseTest { ) ); - // debtToTarget (~$11) as limiting factor would result in dust collateral + // debtRayToTarget (~$11) as limiting factor would result in dust collateral assertLt( _getCollateralValue(_spoke, _daiReserveId(_spoke), alice) - - _convertAmountToValue(_spoke, _usdxReserveId(_spoke), debtToTarget), + _convertAmountToValue(_spoke, _usdxReserveId(_spoke), debtRayToTarget.fromRayUp()), LiquidationLogic.DUST_LIQUIDATION_THRESHOLD ); @@ -103,7 +103,7 @@ contract SpokeLiquidationCallDustTest is SpokeLiquidationCallBaseTest { _daiReserveId(_spoke), _usdxReserveId(_spoke), alice, - debtToTarget, + debtRayToTarget.fromRayUp(), false ); diff --git a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Premium.t.sol b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Premium.t.sol deleted file mode 100644 index 827558fa0..000000000 --- a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Premium.t.sol +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -// Copyright (c) 2025 Aave Labs -pragma solidity ^0.8.0; - -import 'tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.t.sol'; - -contract SpokeLiquidationCallPremiumTest is SpokeLiquidationCallHelperTest { - using SafeCast for uint256; - - uint256 internal baseAmountValue; - - function setUp() public virtual override { - super.setUp(); - baseAmountValue = vm.randomUint(MIN_AMOUNT_IN_BASE_CURRENCY, MAX_AMOUNT_IN_BASE_CURRENCY); - } - - function _baseAmountValue() internal virtual override returns (uint256) { - return baseAmountValue; - } - - function _processAdditionalConfigs( - uint256 collateralReserveId, - uint256 /*debtReserveId*/, - address /*user*/ - ) internal virtual override { - uint256 targetHealthFactor = vm.randomUint(MIN_CLOSE_FACTOR, MAX_CLOSE_FACTOR); - _updateTargetHealthFactor(spoke, targetHealthFactor.toUint120()); - - uint256 liquidationFee = vm.randomUint(MIN_LIQUIDATION_FEE, MAX_LIQUIDATION_FEE); - _updateLiquidationFee(spoke, collateralReserveId, liquidationFee.toUint16()); - - uint256 liquidationBonus = _randomMaxLiquidationBonus(spoke, collateralReserveId); - _updateMaxLiquidationBonus(spoke, collateralReserveId, liquidationBonus.toUint32()); - - _updateCollateralRisk( - spoke, - collateralReserveId, - vm.randomUint(MIN_COLLATERAL_RISK_BPS, MAX_COLLATERAL_RISK_BPS).toUint24() - ); - } - - function _execBeforeLiquidation(CheckedLiquidationCallParams memory) internal virtual override { - skip(vm.randomUint(1, MAX_SKIP_TIME)); - } - - function _assertBeforeLiquidation( - CheckedLiquidationCallParams memory params, - AccountsInfo memory /*accountsInfoBefore*/, - LiquidationMetadata memory /*liquidationMetadata*/ - ) internal virtual override { - (, uint256 premiumDebt) = params.spoke.getUserDebt(params.debtReserveId, params.user); - assertGt(premiumDebt, 0, 'premiumDebt: before liquidation, healthy'); - } -} diff --git a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Scenarios.t.sol b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Scenarios.t.sol index c3ea80aa3..c73962207 100644 --- a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Scenarios.t.sol +++ b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Scenarios.t.sol @@ -5,6 +5,8 @@ pragma solidity ^0.8.0; import 'tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Base.t.sol'; contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { + using SafeCast for *; + address user = makeAddr('user'); address liquidator = makeAddr('liquidator'); @@ -52,10 +54,96 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { } } + function test_liquidationCall_revertsWith_ReentrancyGuardReentrantCall_hubRemove() public { + uint256 collateralReserveId = _daiReserveId(spoke); + uint256 debtReserveId = _wethReserveId(spoke); + _increaseCollateralSupply(spoke, collateralReserveId, 100000e18, user); + _makeUserLiquidatable(spoke, user, debtReserveId, 0.999e18); + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke), + ISpokeBase.liquidationCall.selector + ); + + vm.mockFunction( + address(_hub(spoke, collateralReserveId)), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.remove.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(liquidator); + spoke.liquidationCall(collateralReserveId, debtReserveId, user, type(uint256).max, false); + } + + function test_liquidationCall_revertsWith_ReentrancyGuardReentrantCall_hubRestore() public { + uint256 collateralReserveId = _daiReserveId(spoke); + uint256 debtReserveId = _wethReserveId(spoke); + _increaseCollateralSupply(spoke, collateralReserveId, 100000e18, user); + _makeUserLiquidatable(spoke, user, debtReserveId, 0.999e18); + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke), + ISpokeBase.liquidationCall.selector + ); + + vm.mockFunction( + address(_hub(spoke, debtReserveId)), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.restore.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(liquidator); + spoke.liquidationCall(collateralReserveId, debtReserveId, user, type(uint256).max, false); + } + + function test_liquidationCall_revertsWith_ReentrancyGuardReentrantCall_hubRefreshPremium() + public + { + uint256 collateralReserveId = _daiReserveId(spoke); + uint256 debtReserveId = _wethReserveId(spoke); + _increaseCollateralSupply(spoke, collateralReserveId, 100000e18, user); + _makeUserLiquidatable(spoke, user, debtReserveId, 0.999e18); + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke), + ISpokeBase.liquidationCall.selector + ); + + vm.mockFunction( + address(_hub(spoke, debtReserveId)), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.refreshPremium.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(liquidator); + spoke.liquidationCall(collateralReserveId, debtReserveId, user, type(uint256).max, false); + } + + function test_liquidationCall_revertsWith_ReentrancyGuardReentrantCall_hubReportDeficit() public { + uint256 collateralReserveId = _daiReserveId(spoke); + uint256 debtReserveId = _wethReserveId(spoke); + _increaseCollateralSupply(spoke, collateralReserveId, 100000e18, user); + _makeUserLiquidatable(spoke, user, debtReserveId, 0.5e18); + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke), + ISpokeBase.liquidationCall.selector + ); + + vm.mockFunction( + address(_hub(spoke, debtReserveId)), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.reportDeficit.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(liquidator); + spoke.liquidationCall(collateralReserveId, debtReserveId, user, type(uint256).max, false); + } + // User is solvent, but health factor decreases after liquidation due to high liquidation bonus. // A new collateral factor is set for WETH, but it does not affect the user since dynamic config // key is not refreshed during liquidations. - function test_scenario1() public { + function test_liquidationCall_scenario1() public { // A high liquidation bonus will be applied _updateMaxLiquidationBonus(spoke, _wethReserveId(spoke), 124_00); @@ -157,7 +245,7 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { } // User is solvent, but health factor decreases after liquidation due to high collateral factor. - function test_scenario2() public { + function test_liquidationCall_scenario2() public { _updateMaxLiquidationBonus(spoke, _wethReserveId(spoke), 103_00); _updateCollateralFactor(spoke, _wethReserveId(spoke), 97_00); @@ -193,8 +281,8 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { 0.0001e18, 'pre liquidation: health factor' ); - // Risk Premium: ($3300 * 5% + $100 * 10% + $200 * 15%) / $3600 = ~5.694% - assertEq(userAccountData.riskPremium, 5_69, 'pre liquidation: risk premium'); + // Risk Premium: ceil(($3300 * 5% + $100 * 10% + $200 * 15%) / $3600) = ceil(~5.694%) = ~5.70% + assertEq(userAccountData.riskPremium, 5_70, 'pre liquidation: risk premium'); skip(365 days / 2); userAccountData = spoke.getUserAccountData(user); @@ -254,12 +342,12 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { assertApproxEqAbs(userAccountData.riskPremium, 13_89, 1, 'post liquidation: risk premium'); } - // Liquidated collateral is between 0 and 1 wei. It is rounded up to prevent reverting. - function test_scenario3() public { + // Liquidated collateral is between 0 and 1 wei. It is rounded down and hub.remove is skipped to avoid reverting. + function test_liquidationCall_scenario3() public { // Liquidation bonus: 0 _updateMaxLiquidationBonus(spoke, _wethReserveId(spoke), 100_00); - // The collateral has a price 10 times higher than the debt + // The collateral has a price 100 times higher than the debt _mockReservePrice(spoke, _wethReserveId(spoke), 100e8); _mockReservePrice(spoke, _daiReserveId(spoke), 1e8); @@ -298,7 +386,7 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { assertEq(spoke.getUserTotalDebt(_daiReserveId(spoke), user), 0, 'Debt should be 0'); assertEq( _hub(spoke, _daiReserveId(spoke)).getAssetDeficitRay( - _spokeAssetId(spoke, _daiReserveId(spoke)) + _reserveAssetId(spoke, _daiReserveId(spoke)) ), 0, 'Deficit should be 0' @@ -306,7 +394,7 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { } /// @dev when receiving shares, liquidator can already have setUsingAsCollateral - function test_scenario_liquidator_usingAsCollateral() public { + function test_liquidationCall_scenario4() public { uint256 collateralReserveId = _wethReserveId(spoke); uint256 debtReserveId = _daiReserveId(spoke); // liquidator can receive shares even if they have already set as collateral @@ -331,4 +419,348 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { }) ); } + + // When liquidation bonus is 0, effective collateral liquidated must be less than effective debt liquidated. + // Full debt is liquidated, and amount of collateral liquidated must be computed based on the effective debt liquidated. + function test_liquidationCall_scenario5() public { + // Liquidation bonus: 0 + _updateMaxLiquidationBonus(spoke, _wethReserveId(spoke), 100_00); + + // Supply share price: 1.25 + _mockSupplySharePrice(hub1, wethAssetId, 12_500.25e6, 10_000e6); + + // The collateral and debt have the same price + _mockReservePrice(spoke, _wethReserveId(spoke), 1e8); + _mockReservePrice(spoke, _daiReserveId(spoke), 1e8); + + // Update WETH collateral factor to 80% + _updateCollateralFactor(spoke, _wethReserveId(spoke), 80_00); + + // Collateral: 3 wei of USDX -> 2 share = 2.5 USDX + _increaseCollateralSupply(spoke, _wethReserveId(spoke), 3, user); + + // Mock interest rate to 10% + _mockInterestRateBps(10_00); + + // Borrow: 1 wei of DAI + _increaseReserveDebt(spoke, _daiReserveId(spoke), 1, user); + + // Skip 1 year to increase drawn index + skip(365 days); + assertEq(hub1.getAssetDrawnIndex(daiAssetId), 1.1e27); + + // Increase DAI price by 101% + _mockReservePriceByPercent(spoke, _daiReserveId(spoke), 201_00); + + // User is fully liquidatable + ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); + assertLe(userAccountData.healthFactor, 1e18, 'User should be unhealthy'); + + // User position before liquidation + ISpoke.UserPosition memory userCollateralPositionBefore = spoke.getUserPosition( + _wethReserveId(spoke), + user + ); + assertEq(userCollateralPositionBefore.suppliedShares, 2, 'User should have 2 shares of WETH'); + ISpoke.UserPosition memory userDebtPositionBefore = spoke.getUserPosition( + _daiReserveId(spoke), + user + ); + assertEq(userDebtPositionBefore.drawnShares, 1, 'User should have 1 drawn share of DAI'); + assertEq( + userDebtPositionBefore.premiumShares * 1.1e27 - + userDebtPositionBefore.premiumOffsetRay.toUint256(), + 0.1e27, + 'User should have 0.1 premium' + ); + + // Perform liquidation + // 1 drawn share of DAI is liquidated = 1.1 wei of DAI = 2.211 wei of USD = 2.211 wei of WETH = 1.7688 wei of WETH shares + _checkedLiquidationCall( + CheckedLiquidationCallParams({ + spoke: spoke, + collateralReserveId: _wethReserveId(spoke), + debtReserveId: _daiReserveId(spoke), + user: user, + debtToCover: type(uint256).max, + liquidator: liquidator, + isSolvent: true, + receiveShares: false + }) + ); + + // User position after liquidation + ISpoke.UserPosition memory userCollateralPositionAfter = spoke.getUserPosition( + _wethReserveId(spoke), + user + ); + assertEq( + userCollateralPositionAfter.suppliedShares, + 1, + 'User should have 1 share of WETH after liquidation' + ); + ISpoke.UserPosition memory userDebtPositionAfter = spoke.getUserPosition( + _daiReserveId(spoke), + user + ); + assertEq( + userDebtPositionAfter.drawnShares, + 0, + 'User should have 0 drawn share of DAI after liquidation' + ); + assertEq( + userDebtPositionAfter.premiumShares, + 0, + 'User should have 0 premium share of DAI after liquidation' + ); + assertEq( + userDebtPositionAfter.premiumOffsetRay, + 0, + 'User should have 0 premium offset after liquidation' + ); + } + + // When (at least) debtRayToTarget is liquidated, user should not be below target health factor even if debtRayToTarget + // cannot be represented within the precision of the debt token but can be represented within the precision of the collateral token. + function test_liquidationCall_scenario6() public { + // set target health factor to 1 + _updateTargetHealthFactor(spoke, 1e18); + + // mock prices such that dust is not created + _mockReservePrice(spoke, _usdxReserveId(spoke), 1000e14); + _mockReservePrice(spoke, _wbtcReserveId(spoke), 500e17); + _mockReservePrice(spoke, _usdyReserveId(spoke), 1000e27); + + // collateral configs + _updateMaxLiquidationBonus(spoke, _usdxReserveId(spoke), 100_00); + _updateMaxLiquidationBonus(spoke, _wbtcReserveId(spoke), 100_00); + _updateCollateralFactor(spoke, _usdxReserveId(spoke), 70_00); + _updateCollateralFactor(spoke, _wbtcReserveId(spoke), 99_00); + _updateCollateralRisk(spoke, _usdxReserveId(spoke), 0); + _updateCollateralRisk(spoke, _wbtcReserveId(spoke), 0); + + // mock interest rate + _mockInterestRateBps(50_00); + + // User collaterals: 20 wei of USDX, 3 wei of WBTC + // User debt: 1 wei of USDY + _increaseCollateralSupply(spoke, _usdxReserveId(spoke), 20, user); + _increaseCollateralSupply(spoke, _wbtcReserveId(spoke), 3, user); + _increaseReserveDebt(spoke, _usdyReserveId(spoke), 2, user); + + ISpoke.UserPosition memory usdxUserPosition = spoke.getUserPosition( + _usdxReserveId(spoke), + user + ); + assertEq( + usdxUserPosition.suppliedShares, + 20, + 'User should have 20 supplied shares of USDX before liquidation' + ); + ISpoke.UserPosition memory usdyUserPosition = spoke.getUserPosition( + _usdyReserveId(spoke), + user + ); + assertEq( + usdyUserPosition.drawnShares, + 2, + 'User should have 2 drawn shares of USDY before liquidation' + ); + + // Skip 1 year to increase drawn index + skip(365 days); + assertEq(hub1.getAssetDrawnIndex(usdyAssetId), 1.5e27); + + // User is liquidatable + ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); + assertLe(userAccountData.healthFactor, 1e18, 'User should be unhealthy'); + + // Perform liquidation + vm.prank(liquidator); + spoke.liquidationCall({ + collateralReserveId: _usdxReserveId(spoke), + debtReserveId: _usdyReserveId(spoke), + user: user, + debtToCover: type(uint256).max, + receiveShares: false + }); + + usdxUserPosition = spoke.getUserPosition(_usdxReserveId(spoke), user); + assertEq( + usdxUserPosition.suppliedShares, + 5, + 'User should have 5 supplied shares of USDX after liquidation' + ); + usdyUserPosition = spoke.getUserPosition(_usdyReserveId(spoke), user); + // check liquidation was partial. since debtToCover was max, it means that target should be reached. + assertEq( + usdyUserPosition.drawnShares, + 1, + 'User should have 1 drawn shares of USDY after liquidation' + ); + + // user should not be liquidatable anymore, which means that he cannot be under the target health factor + vm.expectRevert(ISpoke.HealthFactorNotBelowThreshold.selector); + vm.prank(liquidator); + spoke.liquidationCall({ + collateralReserveId: _wbtcReserveId(spoke), + debtReserveId: _usdyReserveId(spoke), + user: user, + debtToCover: type(uint256).max, + receiveShares: false + }); + } + + // When (at least) debtRayToTarget is liquidated, user should not be below target health factor even if debtRayToTarget + // cannot be represented within the precision of the debt token but can be represented within the precision of the collateral token. + function test_liquidationCall_scenario7() public { + // set target health factor to 1 + _updateTargetHealthFactor(spoke, 1e18); + + // mock prices such that dust is not created + _mockReservePrice(spoke, _usdxReserveId(spoke), 1000e14); + _mockReservePrice(spoke, _wbtcReserveId(spoke), 500e17); + _mockReservePrice(spoke, _usdyReserveId(spoke), 1000e27); + + // collateral configs + _updateMaxLiquidationBonus(spoke, _usdxReserveId(spoke), 100_00); + _updateMaxLiquidationBonus(spoke, _wbtcReserveId(spoke), 100_00); + _updateCollateralFactor(spoke, _usdxReserveId(spoke), 70_00); + _updateCollateralFactor(spoke, _wbtcReserveId(spoke), 99_00); + _updateCollateralRisk(spoke, _usdxReserveId(spoke), 50_00); + _updateCollateralRisk(spoke, _wbtcReserveId(spoke), 50_00); + + // set interest rate + _mockInterestRateBps(60_00); + address randomUser = makeAddr('randomUser'); + + // Skip 1 year to increase drawn index to 1.6 + _increaseCollateralSupply(spoke, _usdyReserveId(spoke), 2, randomUser); + _increaseReserveDebt(spoke, _usdyReserveId(spoke), 1, randomUser); + skip(365 days); + assertEq(hub1.getAssetDrawnIndex(usdyAssetId), 1.6e27); + + // set interest rate + _mockInterestRateBps(56_25); + + // User collaterals: 40 wei of USDX, 5 wei of WBTC + // User debt: 2 wei of USDY + _increaseCollateralSupply(spoke, _usdxReserveId(spoke), 40, user); + _increaseCollateralSupply(spoke, _wbtcReserveId(spoke), 5, user); + _increaseReserveDebt(spoke, _usdyReserveId(spoke), 2, user); + + ISpoke.UserPosition memory usdxUserPosition = spoke.getUserPosition( + _usdxReserveId(spoke), + user + ); + assertEq( + usdxUserPosition.suppliedShares, + 40, + 'User should have 40 supplied shares of USDX before liquidation' + ); + ISpoke.UserPosition memory usdyUserPosition = spoke.getUserPosition( + _usdyReserveId(spoke), + user + ); + assertEq( + usdyUserPosition.drawnShares, + 2, + 'User should have 2 drawn shares of USDY before liquidation' + ); + + // Skip 1 year to increase drawn index to 2.5 + skip(365 days); + assertEq(hub1.getAssetDrawnIndex(usdyAssetId), 2.5e27); + + // User is liquidatable + ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); + assertLe(userAccountData.healthFactor, 1e18, 'User should be unhealthy'); + + // Perform liquidation + vm.prank(liquidator); + spoke.liquidationCall({ + collateralReserveId: _usdxReserveId(spoke), + debtReserveId: _usdyReserveId(spoke), + user: user, + debtToCover: type(uint256).max, + receiveShares: false + }); + + usdxUserPosition = spoke.getUserPosition(_usdxReserveId(spoke), user); + assertEq( + usdxUserPosition.suppliedShares, + 6, + 'User should have 6 supplied shares of USDX after liquidation' + ); + usdyUserPosition = spoke.getUserPosition(_usdyReserveId(spoke), user); + assertEq( + usdyUserPosition.drawnShares, + 1, + 'User should have 1 drawn shares of USDY after liquidation' + ); + + // user should not be liquidatable anymore, which means that he cannot be under the target health factor + vm.expectRevert(ISpoke.HealthFactorNotBelowThreshold.selector); + vm.prank(liquidator); + spoke.liquidationCall({ + collateralReserveId: _wbtcReserveId(spoke), + debtReserveId: _usdyReserveId(spoke), + user: user, + debtToCover: type(uint256).max, + receiveShares: false + }); + } + + /// @dev a halted peripheral asset won't block a liquidation + function test_scenario_halted_asset() public { + uint256 collateralReserveId = _wethReserveId(spoke); + uint256 debtReserveId = _daiReserveId(spoke); + + _increaseCollateralSupply(spoke, collateralReserveId, 10e18, user); + // borrow usdx as peripheral debt asset not directly involved in liquidation + _openSupplyPosition(spoke, _usdxReserveId(spoke), 100e6); + Utils.borrow(spoke, _usdxReserveId(spoke), user, 100e6, user); + _makeUserLiquidatable(spoke, user, debtReserveId, 0.95e18); + + // set spoke halted + IHub hub = _hub(spoke, _usdxReserveId(spoke)); + _updateSpokeHalted(hub, usdxAssetId, address(spoke), true); + + _openSupplyPosition(spoke, collateralReserveId, MAX_SUPPLY_AMOUNT); + + vm.expectCall( + address(hub), + abi.encodeWithSelector(IHubBase.refreshPremium.selector, usdxAssetId) + ); + + vm.prank(liquidator); + spoke.liquidationCall(collateralReserveId, debtReserveId, user, type(uint256).max, false); + } + + /// @dev a halted peripheral asset won't block a liquidation with deficit + function test_scenario_halted_asset_with_deficit() public { + uint256 collateralReserveId = _wethReserveId(spoke); + uint256 debtReserveId = _daiReserveId(spoke); + + _increaseCollateralSupply(spoke, collateralReserveId, 10e18, user); + // borrow usdx as peripheral debt asset not directly involved in liquidation + _openSupplyPosition(spoke, _usdxReserveId(spoke), 100e6); + Utils.borrow(spoke, _usdxReserveId(spoke), user, 100e6, user); + // make user unhealthy to result in deficit + _makeUserLiquidatable(spoke, user, debtReserveId, 0.5e18); + + // set spoke halted + IHub hub = _hub(spoke, _usdxReserveId(spoke)); + _updateSpokeHalted(hub, usdxAssetId, address(spoke), true); + + _openSupplyPosition(spoke, collateralReserveId, MAX_SUPPLY_AMOUNT); + + vm.expectCall( + address(hub), + abi.encodeWithSelector(IHubBase.reportDeficit.selector, usdxAssetId) + ); + + vm.prank(liquidator); + spoke.liquidationCall(collateralReserveId, debtReserveId, user, type(uint256).max, false); + } } diff --git a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.t.sol b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.t.sol index 1b2b6b6c3..460a94a0e 100644 --- a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.t.sol +++ b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.t.sol @@ -6,69 +6,92 @@ import 'tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Base.t.sol'; abstract contract SpokeLiquidationCallHelperTest is SpokeLiquidationCallBaseTest { using WadRayMath for uint256; + using SafeCast for uint256; + using PercentageMath for uint256; ISpoke spoke; + address user = makeAddr('user'); address liquidator = makeAddr('liquidator'); - function setUp() public virtual override { + uint256 skipTime; + uint256 baseAmountValue; + + function setUp() public override { super.setUp(); spoke = spoke1; + } - vm.prank(SPOKE_ADMIN); - spoke.updateLiquidationConfig( + function _processAdditionalSetup( + uint256 /* collateralReserveId */, + uint256 /* debtReserveId */ + ) internal virtual { + skipTime = vm.randomUint(0, 10 * 365 days); + baseAmountValue = vm.randomUint(MIN_AMOUNT_IN_BASE_CURRENCY, MAX_AMOUNT_IN_BASE_CURRENCY); + + _updateTargetHealthFactor(spoke, vm.randomUint(MIN_CLOSE_FACTOR, MAX_CLOSE_FACTOR).toUint128()); + _updateLiquidationConfig( + spoke, ISpoke.LiquidationConfig({ - targetHealthFactor: 1.05e18, - healthFactorForMaxBonus: 0.7e18, - liquidationBonusFactor: 20_00 + targetHealthFactor: vm.randomUint(MIN_CLOSE_FACTOR, MAX_CLOSE_FACTOR).toUint128(), + healthFactorForMaxBonus: vm + .randomUint(0, HEALTH_FACTOR_LIQUIDATION_THRESHOLD - 1) + .toUint64(), + liquidationBonusFactor: vm.randomUint(0, PercentageMath.PERCENTAGE_FACTOR).toUint16() }) ); - } - function _baseAmountValue() internal virtual returns (uint256); - - function _processAdditionalConfigs( - uint256 collateralReserveId, - uint256 debtReserveId, - address user - ) internal virtual {} - - function _processAdditionalCollateralReserves(address user, uint256 amountValue) internal { - uint256 count = vm.randomUint(1, 10); - for (uint256 i = 0; i < count; i++) { - uint256 reserveId = vm.randomUint(0, spoke.getReserveCount() - 1); - uint256 amount = _convertValueToAmount(spoke, reserveId, amountValue); - _increaseCollateralSupply(spoke, reserveId, amount, user); + for (uint256 i = 0; i < spoke.getReserveCount(); i++) { + _updateMaxLiquidationBonus(spoke, i, _randomMaxLiquidationBonus(spoke, i)); + _updateCollateralFactor(spoke, i, 1); // temporary value to have full range of possibility for liquidation fee + _updateLiquidationFee( + spoke, + i, + vm.randomUint(MIN_LIQUIDATION_FEE, MAX_LIQUIDATION_FEE).toUint16() + ); + _updateCollateralFactor(spoke, i, _randomCollateralFactor(spoke, i)); + _updateCollateralRisk( + spoke, + i, + vm.randomUint(MIN_COLLATERAL_RISK_BPS, MAX_COLLATERAL_RISK_BPS).toUint24() + ); + _setConstantInterestRateBps( + _hub(spoke, i), + _reserveAssetId(spoke, i), + vm.randomUint(MIN_BORROW_RATE, MAX_BORROW_RATE).toUint32() + ); } - } - function _processAdditionalDebtReserves(address user, uint256 amountValue) internal { - uint256 count = vm.randomUint(1, 10); - for (uint256 i = 0; i < count; i++) { - uint256 reserveId = vm.randomUint(0, spoke.getReserveCount() - 1); - uint256 amount = _convertValueToAmount(spoke, reserveId, amountValue); - _increaseReserveDebt(spoke, reserveId, amount, user); + // user enables more collaterals, but still has deficit given that only one collateral is supplied + for (uint256 reserveId = 0; reserveId < spoke.getReserveCount(); reserveId++) { + if (vm.randomBool()) { + Utils.setUsingAsCollateral(spoke, reserveId, user, true, user); + } } } function _testLiquidationCall( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool isSolvent, bool receiveShares ) internal virtual { + skip(skipTime); + ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); uint256 newHealthFactor; // new health factor of user, just before liquidation if (isSolvent) { // health factor of user should be at least its average collateral factor newHealthFactor = vm.randomUint( - userAccountData.avgCollateralFactor + 0.01e18, - PercentageMath.PERCENTAGE_FACTOR.bpsToWad() + userAccountData.avgCollateralFactor + 0.0000001e18, + PercentageMath.PERCENTAGE_FACTOR.bpsToWad() - 0.0000001e18 ); } else { - newHealthFactor = vm.randomUint(0.01e18, userAccountData.avgCollateralFactor); + newHealthFactor = vm.randomUint( + _min(userAccountData.avgCollateralFactor - 0.0000001e18, 0.1e18), + userAccountData.avgCollateralFactor - 0.0000001e18 + ); } _makeUserLiquidatable(spoke, user, debtReserveId, newHealthFactor); @@ -98,520 +121,521 @@ abstract contract SpokeLiquidationCallHelperTest is SpokeLiquidationCallBaseTest function test_liquidationCall_fuzz_OneCollateral_OneDebt_UserSolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, - _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), + _convertValueToAmount(spoke, collateralReserveId, baseAmountValue), user ); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - true, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, true, receiveShares); } function test_liquidationCall_fuzz_OneCollateral_OneDebt_UserInsolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, - _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), + _convertValueToAmount(spoke, collateralReserveId, baseAmountValue), user ); - // user enables more collaterals, but still has deficit given that only one collateral is supplied - for (uint256 reserveId = 0; reserveId < spoke.getReserveCount(); reserveId++) { - if (vm.randomBool()) { - Utils.setUsingAsCollateral(spoke, reserveId, user, true, user); - } - } - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - false, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, false, receiveShares); } function test_liquidationCall_fuzz_ManyCollaterals_OneDebt_UserSolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, - _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), + _convertValueToAmount(spoke, collateralReserveId, baseAmountValue), user ); - _processAdditionalCollateralReserves(user, 1e26); + _processAdditionalCollateralReserves(debtReserveId); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - true, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, true, receiveShares); } function test_liquidationCall_fuzz_ManyCollaterals_OneDebt_UserInsolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, - _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), + _convertValueToAmount(spoke, collateralReserveId, baseAmountValue), user ); - _processAdditionalCollateralReserves(user, 1e26); + _processAdditionalCollateralReserves(debtReserveId); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - false, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, false, receiveShares); } function test_liquidationCall_fuzz_OneCollateral_ManyDebts_UserSolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, - _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), + _convertValueToAmount(spoke, collateralReserveId, baseAmountValue), user ); - _processAdditionalDebtReserves(user, 1e26); + _processAdditionalDebtReserves(); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - true, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, true, receiveShares); } function test_liquidationCall_fuzz_OneCollateral_ManyDebts_UserInsolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, - _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), + _convertValueToAmount(spoke, collateralReserveId, baseAmountValue), user ); - _processAdditionalDebtReserves(user, 1e26); + _processAdditionalDebtReserves(); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - false, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, false, receiveShares); } function test_liquidationCall_fuzz_ManyCollaterals_ManyDebts_UserSolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, - _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), + _convertValueToAmount(spoke, collateralReserveId, baseAmountValue), user ); - _processAdditionalCollateralReserves(user, 1e26); - _processAdditionalDebtReserves(user, 1e26); + _processAdditionalCollateralReserves(debtReserveId); + _processAdditionalDebtReserves(); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - true, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, true, receiveShares); } function test_liquidationCall_fuzz_ManyCollaterals_ManyDebts_UserInsolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, - _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), + _convertValueToAmount(spoke, collateralReserveId, baseAmountValue), user ); - _processAdditionalCollateralReserves(user, 1e26); - _processAdditionalDebtReserves(user, 1e26); + _processAdditionalCollateralReserves(debtReserveId); + _processAdditionalDebtReserves(); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - false, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, false, receiveShares); } - function test_validateLiquidationCall_revertsWith_ReserveNotListed_CollateralReserve( - uint256 collateralId, - uint256 debtId - ) public { - collateralId = vm.randomUint(spoke.getReserveCount(), UINT256_MAX); - debtId = vm.randomUint(spoke.getReserveCount(), UINT256_MAX); - vm.expectRevert(ISpoke.ReserveNotListed.selector); - spoke.liquidationCall( - collateralId, - debtId, - vm.randomAddress(), - vm.randomUint(), - vm.randomBool() + // calculates the max borrow amount that ensures user will be healthy after skipping time as well + function _calculateMaxHealthyBorrowValue(address addr) internal returns (uint256) { + uint256 maxBorrowValue = _getRequiredDebtValueForHf( + spoke, + addr, + Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD ); - } - function test_validateLiquidationCall_revertsWith_ReserveNotListed_DebtReserve( - uint256 collateralId, - uint256 debtId - ) public { - collateralId = vm.randomUint(0, spoke.getReserveCount() - 1); - debtId = vm.randomUint(spoke.getReserveCount(), UINT256_MAX); - vm.expectRevert(ISpoke.ReserveNotListed.selector); - spoke.liquidationCall( - collateralId, - debtId, - vm.randomAddress(), - vm.randomUint(), - vm.randomBool() + // buffer + maxBorrowValue /= 2; + // account for borrow rate and time + maxBorrowValue = maxBorrowValue.percentDivDown( + PercentageMath.PERCENTAGE_FACTOR + (_spokeMaxBorrowRate(spoke) * skipTime) / 365 days ); + // account for premium debt + maxBorrowValue = maxBorrowValue.percentDivDown( + PercentageMath.PERCENTAGE_FACTOR + + (_spokeMaxCollateralRisk(spoke) + PercentageMath.PERCENTAGE_FACTOR) + ); + + return maxBorrowValue; + } + + function _processAdditionalCollateralReserves(uint256 debtReserveId) internal { + // ensures debt required to make user liquidatable does not exceed max supply amount + uint256 suppliableValue = ( + _convertAmountToValue(spoke, debtReserveId, _calculateMaxSupplyAmount(spoke, debtReserveId)) + ).percentDivDown( + 10 * PercentageMath.PERCENTAGE_FACTOR + (_spokeMaxBorrowRate(spoke) * skipTime) / 365 days + ) - baseAmountValue; + + uint256 count = vm.randomUint(1, spoke.getReserveCount() * 2); + for (uint256 i = 0; i < count; i++) { + uint256 reserveId = vm.randomUint(0, spoke.getReserveCount() - 1); + uint256 minAmount = _hub(spoke, reserveId).previewAddByShares( + _reserveAssetId(spoke, reserveId), + 1 + ); + uint256 maxAmount = _convertValueToAmount(spoke, reserveId, suppliableValue); + if (minAmount >= maxAmount) { + require(i > 0, 'No supply operations'); + break; + } + uint256 amount = vm.randomUint(minAmount, maxAmount); + suppliableValue -= _convertAmountToValue(spoke, reserveId, amount); + _increaseCollateralSupply(spoke, reserveId, amount, user); + } } - function test_validateLiquidationCall_revertsWith_CannotReceiveShares( + function _processAdditionalDebtReserves() internal { + uint256 count = vm.randomUint(1, spoke.getReserveCount() * 2); + // accounts for borrow share price increase due to time skip (and borrow interest rate) + // ensures user is healthy enough to borrow + uint256 borrowableValue = _calculateMaxHealthyBorrowValue(user); + for (uint256 i = 0; i < count; i++) { + uint256 reserveId = vm.randomUint(0, spoke.getReserveCount() - 1); + uint256 maxBorrowAmount = _min( + _convertValueToAmount(spoke, reserveId, borrowableValue), + _calculateMaxSupplyAmount(spoke, reserveId) + ); + if (maxBorrowAmount == 0) { + require(i > 0, 'No borrow operations'); + break; + } + uint256 amount = vm.randomUint(1, maxBorrowAmount); + borrowableValue -= _convertAmountToValue(spoke, reserveId, amount); + _increaseReserveDebt(spoke, reserveId, amount, user); + } + } +} + +contract SpokeLiquidationCallTest_SmallPosition is SpokeLiquidationCallHelperTest { + function _processAdditionalSetup( uint256 collateralReserveId, - uint256 debtReserveId, - address user, - uint256 debtToCover - ) public { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - _updateReserveReceiveSharesEnabledFlag(spoke, collateralReserveId, false); + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + baseAmountValue = vm.randomUint(MIN_AMOUNT_IN_BASE_CURRENCY, 10_000e26); + } +} - _increaseCollateralSupply( +contract SpokeLiquidationCallTest_LargePosition is SpokeLiquidationCallHelperTest { + function _processAdditionalSetup( + uint256 collateralReserveId, + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + baseAmountValue = vm.randomUint(100_000e26, MAX_AMOUNT_IN_BASE_CURRENCY); + } +} + +contract SpokeLiquidationCallTest_NoLiquidationBonus is SpokeLiquidationCallHelperTest { + function _processAdditionalSetup( + uint256 collateralReserveId, + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + _updateMaxLiquidationBonus(spoke, collateralReserveId, 100_00); + } + + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory /* params */, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory liquidationMetadata + ) internal view virtual override { + assertEq(liquidationMetadata.liquidationBonus, 100_00, 'Liquidation bonus'); + } +} + +contract SpokeLiquidationCallTest_SmallLiquidationBonus is SpokeLiquidationCallHelperTest { + using PercentageMath for *; + using SafeCast for uint256; + + function _processAdditionalSetup( + uint256 collateralReserveId, + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + _updateCollateralFactor(spoke, collateralReserveId, 1); // temporary value to have full range of possibility for liquidation bonus + _updateMaxLiquidationBonus( spoke, collateralReserveId, - _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), - user + vm.randomUint(MIN_LIQUIDATION_BONUS, MIN_LIQUIDATION_BONUS.percentMulUp(102_00)).toUint32() ); - - ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); - uint256 newHealthFactor = vm.randomUint( - userAccountData.avgCollateralFactor + 0.01e18, - PercentageMath.PERCENTAGE_FACTOR.bpsToWad() - ); - _makeUserLiquidatable(spoke, user, debtReserveId, newHealthFactor); - debtToCover = _boundDebtToCoverNoDustRevert( + _updateLiquidationBonusFactor(spoke, 100_00); + _updateCollateralFactor( spoke, collateralReserveId, - debtReserveId, - user, - debtToCover, - liquidator + _randomCollateralFactor(spoke, collateralReserveId) ); + } - vm.expectRevert(ISpoke.CannotReceiveShares.selector); - spoke.liquidationCall(collateralReserveId, debtReserveId, user, debtToCover, true); + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory /* params */, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory liquidationMetadata + ) internal view virtual override { + assertLe( + liquidationMetadata.liquidationBonus, + MAX_LIQUIDATION_BONUS.percentMulUp(102_00), + 'Liquidation bonus' + ); } } -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_NoLiquidationBonus_SmallPosition is - SpokeLiquidationCallHelperTest -{ - function _baseAmountValue() internal virtual override returns (uint256) { - return 100e26; +contract SpokeLiquidationCallTest_LargeLiquidationBonus is SpokeLiquidationCallHelperTest { + using PercentageMath for *; + using SafeCast for *; + + function _processAdditionalSetup( + uint256 collateralReserveId, + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + _updateCollateralFactor(spoke, collateralReserveId, 1); // temporary value to have full range of possibility for liquidation bonus + _updateMaxLiquidationBonus( + spoke, + collateralReserveId, + vm.randomUint(MAX_LIQUIDATION_BONUS.percentMulDown(97_00), MAX_LIQUIDATION_BONUS).toUint32() + ); + _updateLiquidationBonusFactor(spoke, 100_00); + _updateCollateralFactor( + spoke, + collateralReserveId, + _randomCollateralFactor(spoke, collateralReserveId) + ); } -} -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_NoLiquidationBonus_LargePosition is - SpokeLiquidationCallHelperTest -{ - function _baseAmountValue() internal virtual override returns (uint256) { - return 10000e26; + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory /* params */, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory liquidationMetadata + ) internal view virtual override { + assertGe( + liquidationMetadata.liquidationBonus, + MAX_LIQUIDATION_BONUS.percentMulDown(97_00), + 'Liquidation bonus' + ); } } -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_SmallLiquidationBonus_SmallPosition is - SpokeLiquidationCallHelperTest -{ - function setUp() public virtual override { - super.setUp(); - for (uint256 i = 0; i < spoke.getReserveCount(); i++) { - ISpoke.DynamicReserveConfig memory dynConfig = spoke.getDynamicReserveConfig( - i, - spoke.getUserPosition(i, liquidator).dynamicConfigKey - ); - dynConfig.maxLiquidationBonus = 105_00; - vm.prank(SPOKE_ADMIN); - spoke.addDynamicReserveConfig(i, dynConfig); - } +contract SpokeLiquidationCallTest_LiquidationFeeZero is SpokeLiquidationCallHelperTest { + function _processAdditionalSetup( + uint256 collateralReserveId, + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + _updateLiquidationFee(spoke, collateralReserveId, 0); } - function _baseAmountValue() internal virtual override returns (uint256) { - return 100e26; + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory params, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory /* liquidationMetadata */ + ) internal view virtual override { + assertEq( + _getLiquidationFee(params.spoke, params.collateralReserveId, params.user), + 0, + 'Liquidation fee' + ); } } -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_SmallLiquidationBonus_LargePosition is - SpokeLiquidationCallHelperTest -{ - function setUp() public virtual override { - super.setUp(); +contract SpokeLiquidationCallTest_NoPremium is SpokeLiquidationCallHelperTest { + function _processAdditionalSetup( + uint256 collateralReserveId, + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); for (uint256 i = 0; i < spoke.getReserveCount(); i++) { - ISpoke.DynamicReserveConfig memory dynConfig = spoke.getDynamicReserveConfig( - i, - spoke.getUserPosition(i, liquidator).dynamicConfigKey - ); - dynConfig.maxLiquidationBonus = 105_00; - vm.prank(SPOKE_ADMIN); - spoke.addDynamicReserveConfig(i, dynConfig); + _updateCollateralRisk(spoke, i, 0); } } - function _baseAmountValue() internal virtual override returns (uint256) { - return 10000e26; + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory params, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory /* liquidationMetadata */ + ) internal view virtual override { + (, uint256 premiumDebt) = params.spoke.getUserDebt(params.debtReserveId, params.user); + assertEq(premiumDebt, 0, 'No premium'); } } -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_LargeLiquidationBonus_SmallPosition is - SpokeLiquidationCallHelperTest -{ - using PercentageMath for uint256; +contract SpokeLiquidationCallTest_Premium is SpokeLiquidationCallHelperTest { using SafeCast for uint256; + using PercentageMath for uint256; - function setUp() public virtual override { - super.setUp(); - for (uint256 i = 0; i < spoke.getReserveCount(); i++) { - ISpoke.DynamicReserveConfig memory dynConfig = spoke.getDynamicReserveConfig( - i, - spoke.getUserPosition(i, liquidator).dynamicConfigKey - ); - dynConfig.maxLiquidationBonus = _randomMaxLiquidationBonus(spoke, i); - vm.prank(SPOKE_ADMIN); - spoke.addDynamicReserveConfig(i, dynConfig); - } + function _processAdditionalSetup( + uint256 collateralReserveId, + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + skipTime = vm.randomUint(1, 10 * 365 days); + _updateCollateralRisk( + spoke, + collateralReserveId, + vm.randomUint(1, MAX_COLLATERAL_RISK_BPS).toUint24() + ); + _setConstantInterestRateBps( + _hub(spoke, debtReserveId), + _reserveAssetId(spoke, debtReserveId), + vm.randomUint(1, MAX_BORROW_RATE).toUint32() + ); + _increaseCollateralSupply( + spoke, + collateralReserveId, + _convertValueToAmount(spoke, collateralReserveId, baseAmountValue), + user + ); + _increaseReserveDebt( + spoke, + debtReserveId, + _convertValueToAmount(spoke, debtReserveId, _calculateMaxHealthyBorrowValue(user)), + user + ); + skip(1 seconds); } - function _baseAmountValue() internal virtual override returns (uint256) { - return 100e26; + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory params, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory /* liquidationMetadata */ + ) internal view virtual override { + (, uint256 premiumDebt) = params.spoke.getUserDebt(params.debtReserveId, params.user); + assertGt(premiumDebt, 0, 'User should have premium debt'); } } -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_LargeLiquidationBonus_LargePosition is - SpokeLiquidationCallHelperTest -{ - using PercentageMath for uint256; - using SafeCast for uint256; - - function setUp() public virtual override { - super.setUp(); - for (uint256 i = 0; i < spoke.getReserveCount(); i++) { - ISpoke.DynamicReserveConfig memory dynConfig = spoke.getDynamicReserveConfig( - i, - spoke.getUserPosition(i, liquidator).dynamicConfigKey - ); - dynConfig.maxLiquidationBonus = _randomMaxLiquidationBonus(spoke, i); - vm.prank(SPOKE_ADMIN); - spoke.addDynamicReserveConfig(i, dynConfig); - } +contract SpokeLiquidationCallTest_NoTimeSkip is SpokeLiquidationCallHelperTest { + function _processAdditionalSetup( + uint256 collateralReserveId, + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + skipTime = 0; } - function _baseAmountValue() internal virtual override returns (uint256) { - return 10000e26; + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory params, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory /* liquidationMetadata */ + ) internal view virtual override { + uint256 reserveCount = params.spoke.getReserveCount(); + for (uint256 i = 0; i < reserveCount; i++) { + assertEq(_reserveDrawnIndex(params.spoke, i), 1e27, 'drawn index'); + IHub hub = _hub(params.spoke, i); + uint256 assetId = _reserveAssetId(params.spoke, i); + assertEq(hub.getAddedAssets(assetId), hub.getAddedShares(assetId), 'supply share price'); + } } } -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_TargetHealthFactor_LiquidationFee is - SpokeLiquidationCallHelperTest -{ - using PercentageMath for uint256; - using SafeCast for uint256; - - uint256 internal baseAmountValue; - - function setUp() public virtual override { - super.setUp(); - baseAmountValue = vm.randomUint(MIN_AMOUNT_IN_BASE_CURRENCY, MAX_AMOUNT_IN_BASE_CURRENCY); - for (uint256 i = 0; i < spoke.getReserveCount(); i++) { - ISpoke.DynamicReserveConfig memory dynConfig = spoke.getDynamicReserveConfig( - i, - spoke.getUserPosition(i, liquidator).dynamicConfigKey - ); - dynConfig.maxLiquidationBonus = _randomMaxLiquidationBonus(spoke, i); - vm.prank(SPOKE_ADMIN); - spoke.addDynamicReserveConfig(i, dynConfig); - } +contract SpokeLiquidationCallTest_TargetHealthFactorOne is SpokeLiquidationCallHelperTest { + function _processAdditionalSetup( + uint256 collateralReserveId, + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + _updateTargetHealthFactor(spoke, 1e18); } - function _baseAmountValue() internal virtual override returns (uint256) { - return baseAmountValue; + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory params, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory /* liquidationMetadata */ + ) internal view virtual override { + assertEq(params.spoke.getLiquidationConfig().targetHealthFactor, 1e18, 'Target health factor'); } +} - function _processAdditionalConfigs( +contract SpokeLiquidationCallTest_LiquidatorHistory is SpokeLiquidationCallHelperTest { + function _processAdditionalSetup( uint256 collateralReserveId, - uint256, - address + uint256 debtReserveId ) internal virtual override { - uint256 targetHealthFactor = vm.randomUint(MIN_CLOSE_FACTOR, MAX_CLOSE_FACTOR); - _updateTargetHealthFactor(spoke, targetHealthFactor.toUint128()); - - uint32 maxLiquidationBonus = _randomMaxLiquidationBonus(spoke, collateralReserveId); - _updateMaxLiquidationBonus(spoke, collateralReserveId, maxLiquidationBonus); + super._processAdditionalSetup(collateralReserveId, debtReserveId); + ISpoke.UserAccountData memory liquidatorAccountData; + uint256 count = vm.randomUint(1, spoke.getReserveCount() * 2); + for (uint256 i = 0; i < count; ++i) { + uint256 reserveId = vm.randomUint(0, spoke.getReserveCount() - 1); + _increaseCollateralSupply( + spoke, + reserveId, + _convertValueToAmount(spoke, reserveId, 100e26), + liquidator + ); + liquidatorAccountData = spoke.getUserAccountData(liquidator); + uint256 maxBorrowAmount = _convertValueToAmount( + spoke, + reserveId, + liquidatorAccountData.healthFactor <= 1.5e18 + ? 0 + : _getRequiredDebtValueForHf(spoke, liquidator, 1.5e18) + ); + if (maxBorrowAmount == 0) { + break; + } + uint256 amount = vm.randomUint(1, maxBorrowAmount); + _increaseReserveDebt(spoke, reserveId, amount, liquidator); + skip(1 days); + } - uint256 liquidationFee = vm.randomUint(MIN_LIQUIDATION_FEE, MAX_LIQUIDATION_FEE); - _updateLiquidationFee(spoke, collateralReserveId, liquidationFee.toUint16()); + // make liquidator unhealthy now, but might get healthy when liquidation happens + liquidatorAccountData = spoke.getUserAccountData(liquidator); + if (liquidatorAccountData.healthFactor > Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD) { + _makeUserLiquidatable( + spoke, + liquidator, + vm.randomUint(0, spoke.getReserveCount() - 1), + vm.randomUint(0.1e18, Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD - 0.0000001e18) + ); + } } } diff --git a/tests/unit/Spoke/Spoke.AccrueInterest.Scenario.t.sol b/tests/unit/Spoke/Spoke.AccrueInterest.Scenario.t.sol index 8934d64e2..9d5398d44 100644 --- a/tests/unit/Spoke/Spoke.AccrueInterest.Scenario.t.sol +++ b/tests/unit/Spoke/Spoke.AccrueInterest.Scenario.t.sol @@ -5,12 +5,10 @@ pragma solidity ^0.8.0; import 'tests/unit/Spoke/SpokeBase.t.sol'; contract SpokeAccrueInterestScenarioTest is SpokeBase { - using SharesMath for uint256; using WadRayMath for *; - using PercentageMath for uint256; - using SafeCast for uint256; + using SafeCast for *; - struct TestAmounts { + struct TestInputs { uint256 daiSupplyAmount; uint256 wethSupplyAmount; uint256 usdxSupplyAmount; @@ -21,25 +19,23 @@ contract SpokeAccrueInterestScenarioTest is SpokeBase { uint256 wbtcBorrowAmount; } - struct Rates { - uint96 daiBaseBorrowRate; - uint96 wethBaseBorrowRate; - uint96 usdxBaseBorrowRate; - uint96 wbtcBaseBorrowRate; + struct TestAmount { + uint256 supplyAmount; + uint256 borrowAmount; + uint256 originalSupplyAmount; + uint256 originalBorrowAmount; + uint256 index; + uint256 originalIndex; + uint256 reserveId; + uint256 assetId; + string name; } - struct Indices { - uint256 daiIndex; - uint256 wethIndex; - uint256 usdxIndex; - uint256 wbtcIndex; - } - - struct BaseShares { - uint256 dai; - uint256 weth; - uint256 usdx; - uint256 wbtc; + struct TestValues { + uint96 baseBorrowRate; + uint256 index; + uint256 baseShares; + uint40 timestamp; } function setUp() public override { @@ -51,89 +47,46 @@ contract SpokeAccrueInterestScenarioTest is SpokeBase { updateLiquidityFee(hub1, usdzAssetId, 0); } - /// Second accrual after an action - which should update the user rp + /// @dev Check protocol supply and debt values after two separate interest accruals with multiple assets supplied and borrowed + /// @dev Ensures interest accrues correctly after each accrual, in accordance with the user's expected risk premium function test_accrueInterest_fuzz_RPBorrowAndSkipTime_twoActions( - TestAmounts memory amounts, + TestInputs memory amounts, uint40 skipTime ) public { - vm.skip(true, 'pending rft'); amounts = _bound(amounts); skipTime = bound(skipTime, 0, MAX_SKIP_TIME / 2).toUint40(); + uint40 startTime = vm.getBlockTimestamp().toUint40(); // Ensure bob does not draw more than half his normalized supply value amounts = _ensureSufficientCollateral(spoke2, amounts); - TestAmounts memory originalAmounts = _copyAmounts(amounts); // deep copy original amounts - - uint40 startTime = vm.getBlockTimestamp().toUint40(); - - // Bob supply dai on spoke 2 - if (amounts.daiSupplyAmount > 0) { - Utils.supplyCollateral(spoke2, _daiReserveId(spoke2), bob, amounts.daiSupplyAmount, bob); - } - - // Bob supply weth on spoke 2 - if (amounts.wethSupplyAmount > 0) { - Utils.supplyCollateral(spoke2, _wethReserveId(spoke2), bob, amounts.wethSupplyAmount, bob); - } + TestAmount[] memory testAmounts = _parseTestInputs(amounts); - // Bob supply usdx on spoke 2 - if (amounts.usdxSupplyAmount > 0) { - Utils.supplyCollateral(spoke2, _usdxReserveId(spoke2), bob, amounts.usdxSupplyAmount, bob); - } - - // Bob supply wbtc on spoke 2 - if (amounts.wbtcSupplyAmount > 0) { - Utils.supplyCollateral(spoke2, _wbtcReserveId(spoke2), bob, amounts.wbtcSupplyAmount, bob); - } - - // Deploy remainder of liquidity - if (amounts.daiSupplyAmount < MAX_SUPPLY_AMOUNT) { - _openSupplyPosition( - spoke2, - _daiReserveId(spoke2), - MAX_SUPPLY_AMOUNT - amounts.daiSupplyAmount - ); - } - if (amounts.wethSupplyAmount < MAX_SUPPLY_AMOUNT) { - _openSupplyPosition( - spoke2, - _wethReserveId(spoke2), - MAX_SUPPLY_AMOUNT - amounts.wethSupplyAmount - ); - } - if (amounts.usdxSupplyAmount < MAX_SUPPLY_AMOUNT) { - _openSupplyPosition( - spoke2, - _usdxReserveId(spoke2), - MAX_SUPPLY_AMOUNT - amounts.usdxSupplyAmount - ); - } - if (amounts.wbtcSupplyAmount < MAX_SUPPLY_AMOUNT) { - _openSupplyPosition( - spoke2, - _wbtcReserveId(spoke2), - MAX_SUPPLY_AMOUNT - amounts.wbtcSupplyAmount - ); - } - - // Bob borrows dai from spoke 2 - if (amounts.daiBorrowAmount > 0) { - Utils.borrow(spoke2, _daiReserveId(spoke2), bob, amounts.daiBorrowAmount, bob); - } - - // Bob borrows weth from spoke 2 - if (amounts.wethBorrowAmount > 0) { - Utils.borrow(spoke2, _wethReserveId(spoke2), bob, amounts.wethBorrowAmount, bob); - } - - // Bob borrows usdx from spoke 2 - if (amounts.usdxBorrowAmount > 0) { - Utils.borrow(spoke2, _usdxReserveId(spoke2), bob, amounts.usdxBorrowAmount, bob); + // Bob supplies amounts on spoke 2, then we deploy remainder of liquidity up to respective supply caps + for (uint256 i = 0; i < 4; ++i) { + if (testAmounts[i].supplyAmount > 0) { + Utils.supplyCollateral( + spoke2, + testAmounts[i].reserveId, + bob, + testAmounts[i].supplyAmount, + bob + ); + } + // Deploy remainder of liquidity for each asset + if (testAmounts[i].supplyAmount < MAX_SUPPLY_AMOUNT) { + _openSupplyPosition( + spoke2, + testAmounts[i].reserveId, + MAX_SUPPLY_AMOUNT - testAmounts[i].supplyAmount + ); + } } - // Bob borrows wbtc from spoke 2 - if (amounts.wbtcBorrowAmount > 0) { - Utils.borrow(spoke2, _wbtcReserveId(spoke2), bob, amounts.wbtcBorrowAmount, bob); + // Bob borrows amounts from spoke 2 + for (uint256 i = 0; i < 4; ++i) { + if (testAmounts[i].borrowAmount > 0) { + Utils.borrow(spoke2, testAmounts[i].reserveId, bob, testAmounts[i].borrowAmount, bob); + } } // Check Bob's risk premium @@ -141,295 +94,60 @@ contract SpokeAccrueInterestScenarioTest is SpokeBase { assertEq(bobRp, _calculateExpectedUserRP(spoke2, bob), 'user risk premium Before'); // Store base borrow rates - Rates memory rates; - rates.daiBaseBorrowRate = hub1.getAssetDrawnRate(daiAssetId).toUint96(); - rates.wethBaseBorrowRate = hub1.getAssetDrawnRate(wethAssetId).toUint96(); - rates.usdxBaseBorrowRate = hub1.getAssetDrawnRate(usdxAssetId).toUint96(); - rates.wbtcBaseBorrowRate = hub1.getAssetDrawnRate(wbtcAssetId).toUint96(); + TestValues[] memory values = new TestValues[](4); + for (uint256 i = 0; i < 4; ++i) { + values[i].baseBorrowRate = hub1.getAsset(testAmounts[i].assetId).drawnRate.toUint96(); + } // Check bob's drawn debt, premium debt, and supplied amounts for all assets at user, reserve, spoke, and asset level - uint256 drawnDebt = _calculateExpectedDrawnDebt( - amounts.daiBorrowAmount, - rates.daiBaseBorrowRate, - startTime - ); - _assertSingleUserProtocolDebt( - spoke2, - _daiReserveId(spoke2), - bob, - drawnDebt, - 0, - 'dai before accrual' - ); - _assertUserSupply( - spoke2, - _daiReserveId(spoke2), - bob, - amounts.daiSupplyAmount, - 'dai before accrual' - ); - _assertReserveSupply(spoke2, _daiReserveId(spoke2), MAX_SUPPLY_AMOUNT, 'dai before accrual'); - _assertSpokeSupply(spoke2, _daiReserveId(spoke2), MAX_SUPPLY_AMOUNT, 'dai before accrual'); - _assertAssetSupply(spoke2, _daiReserveId(spoke2), MAX_SUPPLY_AMOUNT, 'dai before accrual'); - - drawnDebt = _calculateExpectedDrawnDebt( - amounts.wethBorrowAmount, - rates.wethBaseBorrowRate, - startTime - ); - _assertSingleUserProtocolDebt( - spoke2, - _wethReserveId(spoke2), - bob, - drawnDebt, - 0, - 'weth before accrual' - ); - _assertUserSupply( - spoke2, - _wethReserveId(spoke2), - bob, - amounts.wethSupplyAmount, - 'weth before accrual' - ); - _assertReserveSupply(spoke2, _wethReserveId(spoke2), MAX_SUPPLY_AMOUNT, 'weth before accrual'); - _assertSpokeSupply(spoke2, _wethReserveId(spoke2), MAX_SUPPLY_AMOUNT, 'weth before accrual'); - _assertAssetSupply(spoke2, _wethReserveId(spoke2), MAX_SUPPLY_AMOUNT, 'weth before accrual'); - - drawnDebt = _calculateExpectedDrawnDebt( - amounts.usdxBorrowAmount, - rates.usdxBaseBorrowRate, - startTime - ); - _assertSingleUserProtocolDebt( - spoke2, - _usdxReserveId(spoke2), - bob, - drawnDebt, - 0, - 'usdx before accrual' - ); - _assertUserSupply( - spoke2, - _usdxReserveId(spoke2), - bob, - amounts.usdxSupplyAmount, - 'usdx before accrual' - ); - _assertReserveSupply(spoke2, _usdxReserveId(spoke2), MAX_SUPPLY_AMOUNT, 'usdx before accrual'); - _assertSpokeSupply(spoke2, _usdxReserveId(spoke2), MAX_SUPPLY_AMOUNT, 'usdx before accrual'); - _assertAssetSupply(spoke2, _usdxReserveId(spoke2), MAX_SUPPLY_AMOUNT, 'usdx before accrual'); - - drawnDebt = _calculateExpectedDrawnDebt( - amounts.wbtcBorrowAmount, - rates.wbtcBaseBorrowRate, - startTime - ); - _assertSingleUserProtocolDebt( - spoke2, - _wbtcReserveId(spoke2), - bob, - drawnDebt, - 0, - 'wbtc before accrual' - ); - _assertUserSupply( - spoke2, - _wbtcReserveId(spoke2), - bob, - amounts.wbtcSupplyAmount, - 'wbtc before accrual' - ); - _assertReserveSupply(spoke2, _wbtcReserveId(spoke2), MAX_SUPPLY_AMOUNT, 'wbtc before accrual'); - _assertSpokeSupply(spoke2, _wbtcReserveId(spoke2), MAX_SUPPLY_AMOUNT, 'wbtc before accrual'); - _assertAssetSupply(spoke2, _wbtcReserveId(spoke2), MAX_SUPPLY_AMOUNT, 'wbtc before accrual'); + for (uint256 i = 0; i < 4; ++i) { + uint256 drawnDebt = _calculateExpectedDrawnDebt( + testAmounts[i].borrowAmount, + values[i].baseBorrowRate, + startTime + ); + _assertProtocolSupplyAndDebt({ + reserveId: testAmounts[i].reserveId, + reserveName: testAmounts[i].name, + expectedUserSupply: testAmounts[i].supplyAmount, + expectedReserveSupply: MAX_SUPPLY_AMOUNT, + expectedDrawnDebt: drawnDebt, + expectedPremiumDebt: 0, + label: ' before first accrual' + }); + } // Skip time to accrue interest skip(skipTime); // Check bob's drawn debt, premium debt, and supplied amounts for all assets at user, reserve, spoke, and asset level - ISpoke.UserPosition memory bobPosition = spoke2.getUserPosition(_daiReserveId(spoke2), bob); - drawnDebt = _calculateExpectedDrawnDebt( - amounts.daiBorrowAmount, - rates.daiBaseBorrowRate, - startTime - ); - uint256 expectedPremiumDebt = _calculateExpectedPremiumDebt( - amounts.daiBorrowAmount, - drawnDebt, - bobRp - ); - uint256 interest = (drawnDebt + expectedPremiumDebt) - - amounts.daiBorrowAmount - - _calculateBurntInterest(hub1, daiAssetId); - _assertSingleUserProtocolDebt( - spoke2, - _daiReserveId(spoke2), - bob, - drawnDebt, - expectedPremiumDebt, - 'dai after accrual' - ); - _assertUserSupply( - spoke2, - _daiReserveId(spoke2), - bob, - amounts.daiSupplyAmount + (interest * amounts.daiSupplyAmount) / MAX_SUPPLY_AMOUNT, - 'dai after accrual' - ); - _assertReserveSupply( - spoke2, - _daiReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'dai after accrual' - ); - _assertSpokeSupply( - spoke2, - _daiReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'dai after accrual' - ); - _assertAssetSupply( - spoke2, - _daiReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'dai after accrual' - ); - - bobPosition = spoke2.getUserPosition(_wethReserveId(spoke2), bob); - drawnDebt = _calculateExpectedDrawnDebt( - amounts.wethBorrowAmount, - rates.wethBaseBorrowRate, - startTime - ); - expectedPremiumDebt = _calculateExpectedPremiumDebt(amounts.wethBorrowAmount, drawnDebt, bobRp); - interest = - (drawnDebt + expectedPremiumDebt) - - amounts.wethBorrowAmount - - _calculateBurntInterest(hub1, wethAssetId); - _assertSingleUserProtocolDebt( - spoke2, - _wethReserveId(spoke2), - bob, - drawnDebt, - expectedPremiumDebt, - 'weth after accrual' - ); - _assertUserSupply( - spoke2, - _wethReserveId(spoke2), - bob, - amounts.wethSupplyAmount + (interest * amounts.wethSupplyAmount) / MAX_SUPPLY_AMOUNT, - 'weth after accrual' - ); - _assertReserveSupply( - spoke2, - _wethReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'weth after accrual' - ); - _assertSpokeSupply( - spoke2, - _wethReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'weth after accrual' - ); - _assertAssetSupply( - spoke2, - _wethReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'weth after accrual' - ); - - bobPosition = spoke2.getUserPosition(_usdxReserveId(spoke2), bob); - drawnDebt = _calculateExpectedDrawnDebt( - amounts.usdxBorrowAmount, - rates.usdxBaseBorrowRate, - startTime - ); - expectedPremiumDebt = _calculateExpectedPremiumDebt(amounts.usdxBorrowAmount, drawnDebt, bobRp); - interest = - (drawnDebt + expectedPremiumDebt) - - amounts.usdxBorrowAmount - - _calculateBurntInterest(hub1, usdxAssetId); - _assertSingleUserProtocolDebt( - spoke2, - _usdxReserveId(spoke2), - bob, - drawnDebt, - expectedPremiumDebt, - 'usdx after accrual' - ); - _assertUserSupply( - spoke2, - _usdxReserveId(spoke2), - bob, - amounts.usdxSupplyAmount + (interest * amounts.usdxSupplyAmount) / MAX_SUPPLY_AMOUNT, - 'usdx after accrual' - ); - _assertReserveSupply( - spoke2, - _usdxReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'usdx after accrual' - ); - _assertSpokeSupply( - spoke2, - _usdxReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'usdx after accrual' - ); - _assertAssetSupply( - spoke2, - _usdxReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'usdx after accrual' - ); - - bobPosition = spoke2.getUserPosition(_wbtcReserveId(spoke2), bob); - drawnDebt = _calculateExpectedDrawnDebt( - amounts.wbtcBorrowAmount, - rates.wbtcBaseBorrowRate, - startTime - ); - expectedPremiumDebt = _calculateExpectedPremiumDebt(amounts.wbtcBorrowAmount, drawnDebt, bobRp); - interest = - (drawnDebt + expectedPremiumDebt) - - amounts.wbtcBorrowAmount - - _calculateBurntInterest(hub1, wbtcAssetId); - _assertSingleUserProtocolDebt( - spoke2, - _wbtcReserveId(spoke2), - bob, - drawnDebt, - expectedPremiumDebt, - 'wbtc after accrual' - ); - _assertUserSupply( - spoke2, - _wbtcReserveId(spoke2), - bob, - amounts.wbtcSupplyAmount + (interest * amounts.wbtcSupplyAmount) / MAX_SUPPLY_AMOUNT, - 'wbtc after accrual' - ); - _assertReserveSupply( - spoke2, - _wbtcReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'wbtc after accrual' - ); - _assertSpokeSupply( - spoke2, - _wbtcReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'wbtc after accrual' - ); - _assertAssetSupply( - spoke2, - _wbtcReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'wbtc after accrual' - ); + for (uint256 i = 0; i < 4; ++i) { + uint256 drawnDebt = _calculateExpectedDrawnDebt( + testAmounts[i].borrowAmount, + values[i].baseBorrowRate, + startTime + ); + uint256 expectedPremiumDebt = _calculateExpectedPremiumDebt( + testAmounts[i].borrowAmount, + drawnDebt, + bobRp + ); + uint256 interest = (drawnDebt + expectedPremiumDebt) - + testAmounts[i].borrowAmount - + _calculateBurntInterest(hub1, testAmounts[i].assetId); + uint256 expectedUserSupply = testAmounts[i].supplyAmount + + (interest * testAmounts[i].supplyAmount) / MAX_SUPPLY_AMOUNT; + + _assertProtocolSupplyAndDebt({ + reserveId: testAmounts[i].reserveId, + reserveName: testAmounts[i].name, + expectedUserSupply: expectedUserSupply, + expectedReserveSupply: MAX_SUPPLY_AMOUNT + interest, + expectedDrawnDebt: drawnDebt, + expectedPremiumDebt: expectedPremiumDebt, + label: ' after first accrual' + }); + } // Only proceed with test if position is healthy if (_getUserHealthFactor(spoke2, bob) >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD) { @@ -437,99 +155,53 @@ contract SpokeAccrueInterestScenarioTest is SpokeBase { deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT); Utils.supplyCollateral(spoke2, _usdzReserveId(spoke2), bob, MAX_SUPPLY_AMOUNT, bob); - // Handle case that bob isn't already borrowing dai by borrowing 1 share - bobPosition = spoke2.getUserPosition(_daiReserveId(spoke2), bob); - if (bobPosition.drawnShares == 0) { - Utils.borrow( - spoke2, - _daiReserveId(spoke2), - bob, - hub1.previewRestoreByShares(daiAssetId, 1), - bob - ); - } - // Workaround for precision loss with RP calc: https://github.com/aave/aave-v4/issues/421 - // Construct mock call so we can see the same user rp calc as within the borrow function - vm.mockCall( - address(spoke2), - abi.encodeCall(Spoke.getUserTotalDebt, (_daiReserveId(spoke2), bob)), - abi.encode(spoke2.getUserTotalDebt(_daiReserveId(spoke2), bob) + 1e18) // Debt amount seen in the borrow function when calculating user rp - ); - bobRp = _calculateExpectedUserRP(spoke2, bob); - vm.clearMockedCalls(); + uint256 daiBorrowAmount = 1e18; // Bob borrows more dai to trigger accrual - Utils.borrow(spoke2, _daiReserveId(spoke2), bob, 1e18, bob); - - // Refresh debt values - (amounts.daiBorrowAmount, ) = spoke2.getUserDebt(_daiReserveId(spoke2), bob); - (amounts.wethBorrowAmount, ) = spoke2.getUserDebt(_wethReserveId(spoke2), bob); - (amounts.usdxBorrowAmount, ) = spoke2.getUserDebt(_usdxReserveId(spoke2), bob); - (amounts.wbtcBorrowAmount, ) = spoke2.getUserDebt(_wbtcReserveId(spoke2), bob); - - // Refresh base borrow rates - rates.daiBaseBorrowRate = hub1.getAssetDrawnRate(daiAssetId).toUint96(); - rates.wethBaseBorrowRate = hub1.getAssetDrawnRate(wethAssetId).toUint96(); - rates.usdxBaseBorrowRate = hub1.getAssetDrawnRate(usdxAssetId).toUint96(); - rates.wbtcBaseBorrowRate = hub1.getAssetDrawnRate(wbtcAssetId).toUint96(); - - BaseShares memory baseShares; - - // Check debt values before accrual - bobPosition = spoke2.getUserPosition(_daiReserveId(spoke2), bob); - expectedPremiumDebt = _calculatePremiumDebtRay(spoke2, _daiReserveId(spoke2), bob); - _assertSingleUserProtocolDebt( - spoke2, - _daiReserveId(spoke2), - bob, - amounts.daiBorrowAmount, - expectedPremiumDebt, - 'dai before second accrual' - ); - baseShares.dai = bobPosition.drawnShares; - - bobPosition = spoke2.getUserPosition(_wethReserveId(spoke2), bob); - expectedPremiumDebt = _calculatePremiumDebtRay(spoke2, _wethReserveId(spoke2), bob); - _assertSingleUserProtocolDebt( - spoke2, - _wethReserveId(spoke2), - bob, - amounts.wethBorrowAmount, - expectedPremiumDebt, - 'weth before second accrual' - ); - baseShares.weth = bobPosition.drawnShares; - - bobPosition = spoke2.getUserPosition(_usdxReserveId(spoke2), bob); - expectedPremiumDebt = _calculatePremiumDebtRay(spoke2, _usdxReserveId(spoke2), bob); - _assertSingleUserProtocolDebt( - spoke2, - _usdxReserveId(spoke2), - bob, - amounts.usdxBorrowAmount, - expectedPremiumDebt, - 'usdx before second accrual' - ); - baseShares.usdx = bobPosition.drawnShares; - - bobPosition = spoke2.getUserPosition(_wbtcReserveId(spoke2), bob); - expectedPremiumDebt = _calculatePremiumDebtRay(spoke2, _wbtcReserveId(spoke2), bob); - _assertSingleUserProtocolDebt( - spoke2, - _wbtcReserveId(spoke2), - bob, - amounts.wbtcBorrowAmount, - expectedPremiumDebt, - 'wbtc before second accrual' - ); - baseShares.wbtc = bobPosition.drawnShares; + Utils.borrow(spoke2, _daiReserveId(spoke2), bob, daiBorrowAmount, bob); + // Account for the dai we just borrowed + testAmounts[0].originalBorrowAmount += daiBorrowAmount; + + bobRp = _calculateExpectedUserRP(spoke2, bob); + + // Update amounts for second accrual checks + for (uint256 i = 0; i < 4; ++i) { + (testAmounts[i].borrowAmount, ) = spoke2.getUserDebt(testAmounts[i].reserveId, bob); + values[i].baseBorrowRate = hub1.getAsset(testAmounts[i].assetId).drawnRate.toUint96(); + values[i].index = hub1.getAssetDrawnIndex(testAmounts[i].assetId).toUint120(); + values[i].timestamp = hub1.getAsset(testAmounts[i].assetId).lastUpdateTimestamp; + values[i].baseShares = spoke2.getUserPosition(testAmounts[i].reserveId, bob).drawnShares; + } - // Store index before accrual, and use this for calculating expected drawn debt - Indices memory indices; - indices.daiIndex = hub1.getAssetDrawnIndex(daiAssetId); - indices.wethIndex = hub1.getAssetDrawnIndex(wethAssetId); - indices.usdxIndex = hub1.getAssetDrawnIndex(usdxAssetId); - indices.wbtcIndex = hub1.getAssetDrawnIndex(wbtcAssetId); + // Check bob's drawn debt, premium debt, and supplied amounts for all assets at user, reserve, spoke, and asset level + for (uint256 i = 0; i < 4; ++i) { + ISpoke.UserPosition memory bobPosition = spoke2.getUserPosition( + testAmounts[i].reserveId, + bob + ); + uint256 drawnDebt = testAmounts[i].borrowAmount; + uint256 expectedPremiumDebt = _calculatePremiumDebt( + hub1, + testAmounts[i].assetId, + bobPosition.premiumShares, + bobPosition.premiumOffsetRay + ); + uint256 interest = (drawnDebt + expectedPremiumDebt) - + testAmounts[i].originalBorrowAmount - + _calculateBurntInterest(hub1, testAmounts[i].assetId); + uint256 expectedUserSupply = testAmounts[i].originalSupplyAmount + + (interest * testAmounts[i].originalSupplyAmount) / MAX_SUPPLY_AMOUNT; + + _assertProtocolSupplyAndDebt({ + reserveId: testAmounts[i].reserveId, + reserveName: testAmounts[i].name, + expectedUserSupply: expectedUserSupply, + expectedReserveSupply: MAX_SUPPLY_AMOUNT + interest, + expectedDrawnDebt: drawnDebt, + expectedPremiumDebt: expectedPremiumDebt, + label: ' before second accrual' + }); + } // Store timestamp before next skip time startTime = vm.getBlockTimestamp().toUint40(); @@ -537,322 +209,210 @@ contract SpokeAccrueInterestScenarioTest is SpokeBase { skip(skipTime); // Check bob's drawn debt, premium debt, and supplied amounts for all assets at user, reserve, spoke, and asset level - indices.daiIndex = _calculateExpectedDrawnIndex( - indices.daiIndex, - rates.daiBaseBorrowRate, - startTime - ); - bobPosition = spoke2.getUserPosition(_daiReserveId(spoke2), bob); - drawnDebt = baseShares.dai.rayMulUp(indices.daiIndex); - expectedPremiumDebt = _calculateExpectedPremiumDebt( - amounts.daiBorrowAmount, - drawnDebt, - bobRp - ); - interest = - (drawnDebt + expectedPremiumDebt) - - (originalAmounts.daiBorrowAmount + 1e18) - - _calculateBurntInterest(hub1, daiAssetId); // subtract out the extra amount we borrowed - _assertSingleUserProtocolDebt( - spoke2, - _daiReserveId(spoke2), - bob, - drawnDebt, - expectedPremiumDebt, - 'dai after second accrual' - ); - _assertUserSupply( - spoke2, - _daiReserveId(spoke2), - bob, - originalAmounts.daiSupplyAmount + - (interest * originalAmounts.daiSupplyAmount) / - MAX_SUPPLY_AMOUNT, - 'dai after second accrual' - ); - _assertReserveSupply( - spoke2, - _daiReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'dai after second accrual' - ); - _assertSpokeSupply( - spoke2, - _daiReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'dai after second accrual' - ); - _assertAssetSupply( - spoke2, - _daiReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'dai after second accrual' - ); - - indices.wethIndex = _calculateExpectedDrawnIndex( - indices.wethIndex, - rates.wethBaseBorrowRate, - startTime - ); - bobPosition = spoke2.getUserPosition(_wethReserveId(spoke2), bob); - assertEq( - bobPosition.drawnShares, - baseShares.weth, - 'weth base drawn shares after second accrual' - ); - drawnDebt = baseShares.weth.rayMulUp(indices.wethIndex); - expectedPremiumDebt = _calculateExpectedPremiumDebt( - amounts.wethBorrowAmount, - drawnDebt, - bobRp - ); - interest = - (drawnDebt + expectedPremiumDebt) - - originalAmounts.wethBorrowAmount - - _calculateBurntInterest(hub1, wethAssetId); - _assertSingleUserProtocolDebt( - spoke2, - _wethReserveId(spoke2), - bob, - drawnDebt, - expectedPremiumDebt, - 'weth after second accrual' - ); - _assertUserSupply( - spoke2, - _wethReserveId(spoke2), - bob, - originalAmounts.wethSupplyAmount + - (interest * originalAmounts.wethSupplyAmount) / - MAX_SUPPLY_AMOUNT, - 'weth after second accrual' - ); - _assertReserveSupply( - spoke2, - _wethReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'weth after second accrual' - ); - _assertSpokeSupply( - spoke2, - _wethReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'weth after second accrual' - ); - _assertAssetSupply( - spoke2, - _wethReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'weth after second accrual' - ); - - indices.usdxIndex = _calculateExpectedDrawnIndex( - indices.usdxIndex, - rates.usdxBaseBorrowRate, - startTime - ); - bobPosition = spoke2.getUserPosition(_usdxReserveId(spoke2), bob); - drawnDebt = baseShares.usdx.rayMulUp(indices.usdxIndex); - expectedPremiumDebt = _calculateExpectedPremiumDebt( - amounts.usdxBorrowAmount, - drawnDebt, - bobRp - ); - interest = - (drawnDebt + expectedPremiumDebt) - - originalAmounts.usdxBorrowAmount - - _calculateBurntInterest(hub1, usdxAssetId); - _assertSingleUserProtocolDebt( - spoke2, - _usdxReserveId(spoke2), - bob, - drawnDebt, - expectedPremiumDebt, - 'usdx after second accrual' - ); - _assertUserSupply( - spoke2, - _usdxReserveId(spoke2), - bob, - originalAmounts.usdxSupplyAmount + - (interest * originalAmounts.usdxSupplyAmount) / - MAX_SUPPLY_AMOUNT, - 'usdx after second accrual' - ); - _assertReserveSupply( - spoke2, - _usdxReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'usdx after second accrual' - ); - _assertSpokeSupply( - spoke2, - _usdxReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'usdx after second accrual' - ); - _assertAssetSupply( - spoke2, - _usdxReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'usdx after second accrual' - ); - - indices.wbtcIndex = _calculateExpectedDrawnIndex( - indices.wbtcIndex, - rates.wbtcBaseBorrowRate, - startTime - ); - bobPosition = spoke2.getUserPosition(_wbtcReserveId(spoke2), bob); - drawnDebt = baseShares.wbtc.rayMulUp(indices.wbtcIndex); - expectedPremiumDebt = _calculateExpectedPremiumDebt( - amounts.wbtcBorrowAmount, - drawnDebt, - bobRp - ); - interest = - (drawnDebt + expectedPremiumDebt) - - originalAmounts.wbtcBorrowAmount - - _calculateBurntInterest(hub1, wbtcAssetId); - _assertSingleUserProtocolDebt( - spoke2, - _wbtcReserveId(spoke2), - bob, - drawnDebt, - expectedPremiumDebt, - 'wbtc after second accrual' - ); - _assertUserSupply( - spoke2, - _wbtcReserveId(spoke2), - bob, - originalAmounts.wbtcSupplyAmount + - (interest * originalAmounts.wbtcSupplyAmount) / - MAX_SUPPLY_AMOUNT, - 'wbtc after second accrual' - ); - _assertReserveSupply( - spoke2, - _wbtcReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'wbtc after second accrual' - ); - _assertSpokeSupply( - spoke2, - _wbtcReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'wbtc after second accrual' - ); - _assertAssetSupply( - spoke2, - _wbtcReserveId(spoke2), - MAX_SUPPLY_AMOUNT + interest, - 'wbtc after second accrual' - ); + for (uint256 i = 0; i < 4; ++i) { + if (testAmounts[i].originalBorrowAmount == 0) { + _assertProtocolSupplyAndDebt({ + reserveId: testAmounts[i].reserveId, + reserveName: testAmounts[i].name, + expectedUserSupply: testAmounts[i].originalSupplyAmount, + expectedReserveSupply: MAX_SUPPLY_AMOUNT, + expectedDrawnDebt: 0, + expectedPremiumDebt: 0, + label: ' after second accrual' + }); + continue; + } + values[i].index = _calculateExpectedDrawnIndex( + values[i].timestamp == 1 ? testAmounts[i].originalIndex : values[i].index, // If reserve never updated, use original index + values[i].baseBorrowRate, + values[i].timestamp + ); + ISpoke.UserPosition memory bobPosition = spoke2.getUserPosition( + testAmounts[i].reserveId, + bob + ); + uint256 drawnDebt = values[i].baseShares.rayMulUp(values[i].index); + uint256 expectedPremiumDebt = _calculatePremiumDebt( + hub1, + testAmounts[i].assetId, + bobPosition.premiumShares, + bobPosition.premiumOffsetRay + ); + uint256 interest = (drawnDebt + expectedPremiumDebt) - + testAmounts[i].originalBorrowAmount - + _calculateBurntInterest(hub1, testAmounts[i].assetId); + uint256 expectedUserSupply = testAmounts[i].originalSupplyAmount + + (interest * testAmounts[i].originalSupplyAmount) / MAX_SUPPLY_AMOUNT; + + _assertProtocolSupplyAndDebt({ + reserveId: testAmounts[i].reserveId, + reserveName: testAmounts[i].name, + expectedUserSupply: expectedUserSupply, + expectedReserveSupply: MAX_SUPPLY_AMOUNT + interest, + expectedDrawnDebt: drawnDebt, + expectedPremiumDebt: expectedPremiumDebt, + label: ' after second accrual' + }); + } } } - function _bound(TestAmounts memory amounts) internal pure returns (TestAmounts memory) { - amounts.daiSupplyAmount = bound(amounts.daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - amounts.wethSupplyAmount = bound(amounts.wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - amounts.usdxSupplyAmount = bound(amounts.usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - amounts.wbtcSupplyAmount = bound(amounts.wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - amounts.daiBorrowAmount = bound(amounts.daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - amounts.wethBorrowAmount = bound(amounts.wethBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - amounts.usdxBorrowAmount = bound(amounts.usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - amounts.wbtcBorrowAmount = bound(amounts.wbtcBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); + function _bound(TestInputs memory amounts) internal view returns (TestInputs memory) { + amounts.daiSupplyAmount = bound(amounts.daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI); + amounts.wethSupplyAmount = bound(amounts.wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH); + amounts.usdxSupplyAmount = bound(amounts.usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX); + amounts.wbtcSupplyAmount = bound(amounts.wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WBTC); + amounts.daiBorrowAmount = bound(amounts.daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 2); + amounts.wethBorrowAmount = bound(amounts.wethBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 2); + amounts.usdxBorrowAmount = bound(amounts.usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 2); + amounts.wbtcBorrowAmount = bound(amounts.wbtcBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WBTC / 2); return amounts; } - function _bound(Rates memory rates) internal view returns (Rates memory) { - rates.daiBaseBorrowRate = _bpsToRay( - bound(rates.daiBaseBorrowRate, 1, irStrategy.MAX_BORROW_RATE()) - ).toUint96(); - rates.wethBaseBorrowRate = _bpsToRay( - bound(rates.wethBaseBorrowRate, 1, irStrategy.MAX_BORROW_RATE()) - ).toUint96(); - rates.usdxBaseBorrowRate = _bpsToRay( - bound(rates.usdxBaseBorrowRate, 1, irStrategy.MAX_BORROW_RATE()) - ).toUint96(); - rates.wbtcBaseBorrowRate = _bpsToRay( - bound(rates.wbtcBaseBorrowRate, 1, irStrategy.MAX_BORROW_RATE()) - ).toUint96(); - - return rates; + function _parseTestInputs(TestInputs memory amounts) internal view returns (TestAmount[] memory) { + TestAmount[] memory testAmounts = new TestAmount[](4); + + testAmounts[0] = TestAmount({ + supplyAmount: amounts.daiSupplyAmount, + borrowAmount: amounts.daiBorrowAmount, + originalSupplyAmount: amounts.daiSupplyAmount, + originalBorrowAmount: amounts.daiBorrowAmount, + index: hub1.getAssetDrawnIndex(daiAssetId), + originalIndex: hub1.getAssetDrawnIndex(daiAssetId), + reserveId: _daiReserveId(spoke2), + assetId: daiAssetId, + name: 'DAI' + }); + + testAmounts[1] = TestAmount({ + supplyAmount: amounts.wethSupplyAmount, + borrowAmount: amounts.wethBorrowAmount, + originalSupplyAmount: amounts.wethSupplyAmount, + originalBorrowAmount: amounts.wethBorrowAmount, + index: hub1.getAssetDrawnIndex(wethAssetId), + originalIndex: hub1.getAssetDrawnIndex(wethAssetId), + reserveId: _wethReserveId(spoke2), + assetId: wethAssetId, + name: 'WETH' + }); + + testAmounts[2] = TestAmount({ + supplyAmount: amounts.usdxSupplyAmount, + borrowAmount: amounts.usdxBorrowAmount, + originalSupplyAmount: amounts.usdxSupplyAmount, + originalBorrowAmount: amounts.usdxBorrowAmount, + index: hub1.getAssetDrawnIndex(usdxAssetId), + originalIndex: hub1.getAssetDrawnIndex(usdxAssetId), + reserveId: _usdxReserveId(spoke2), + assetId: usdxAssetId, + name: 'USDX' + }); + + testAmounts[3] = TestAmount({ + supplyAmount: amounts.wbtcSupplyAmount, + borrowAmount: amounts.wbtcBorrowAmount, + originalSupplyAmount: amounts.wbtcSupplyAmount, + originalBorrowAmount: amounts.wbtcBorrowAmount, + index: hub1.getAssetDrawnIndex(wbtcAssetId), + originalIndex: hub1.getAssetDrawnIndex(wbtcAssetId), + reserveId: _wbtcReserveId(spoke2), + assetId: wbtcAssetId, + name: 'WBTC' + }); + + return testAmounts; } function _ensureSufficientCollateral( ISpoke spoke, - TestAmounts memory amounts - ) internal view returns (TestAmounts memory) { - uint256 remainingCollateralValue = _getValue( + TestInputs memory amounts + ) internal view returns (TestInputs memory) { + uint256 remainingCollateralValue = _convertAmountToValue( spoke, _daiReserveId(spoke), amounts.daiSupplyAmount ) + - _getValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + - _getValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount); + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + + _convertAmountToValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount); // Bound each debt amount to be no more than half the remaining collateral value amounts.daiBorrowAmount = bound( amounts.daiBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _daiReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _daiReserveId(spoke), 1) ); // Subtract out the set debt value from the remaining collateral value - remainingCollateralValue -= _getValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) * 2; + remainingCollateralValue -= + _convertAmountToValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) * 2; amounts.wethBorrowAmount = bound( amounts.wethBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _wethReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _wethReserveId(spoke), 1) ); remainingCollateralValue -= - _getValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) * - 2; + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) * 2; amounts.usdxBorrowAmount = bound( amounts.usdxBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _usdxReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _usdxReserveId(spoke), 1) ); remainingCollateralValue -= - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) * - 2; + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) * 2; amounts.wbtcBorrowAmount = bound( amounts.wbtcBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _wbtcReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _wbtcReserveId(spoke), 1) ); assertGt( - _getValue(spoke, _daiReserveId(spoke), amounts.daiSupplyAmount) + - _getValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + - _getValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount), + _convertAmountToValue(spoke, _daiReserveId(spoke), amounts.daiSupplyAmount) + + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + + _convertAmountToValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount), 2 * - (_getValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) + - _getValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) + - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) + - _getValue(spoke, _wbtcReserveId(spoke), amounts.wbtcBorrowAmount)), + (_convertAmountToValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) + + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) + + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) + + _convertAmountToValue(spoke, _wbtcReserveId(spoke), amounts.wbtcBorrowAmount)), 'collateral sufficiently covers debt' ); return amounts; } - /// @dev Helper to deep copy TestAmounts struct - function _copyAmounts(TestAmounts memory amounts) internal pure returns (TestAmounts memory) { - return - TestAmounts({ - daiSupplyAmount: amounts.daiSupplyAmount, - wethSupplyAmount: amounts.wethSupplyAmount, - usdxSupplyAmount: amounts.usdxSupplyAmount, - wbtcSupplyAmount: amounts.wbtcSupplyAmount, - daiBorrowAmount: amounts.daiBorrowAmount, - wethBorrowAmount: amounts.wethBorrowAmount, - usdxBorrowAmount: amounts.usdxBorrowAmount, - wbtcBorrowAmount: amounts.wbtcBorrowAmount - }); + function _assertProtocolSupplyAndDebt( + uint256 reserveId, + string memory reserveName, + uint256 expectedUserSupply, + uint256 expectedReserveSupply, + uint256 expectedDrawnDebt, + uint256 expectedPremiumDebt, + string memory label + ) internal view { + _assertUserSupply( + spoke2, + reserveId, + bob, + expectedUserSupply, + string.concat(reserveName, label) + ); + _assertReserveSupply( + spoke2, + reserveId, + expectedReserveSupply, + string.concat(reserveName, label) + ); + _assertSpokeSupply(spoke2, reserveId, expectedReserveSupply, string.concat(reserveName, label)); + _assertAssetSupply(spoke2, reserveId, expectedReserveSupply, string.concat(reserveName, label)); + _assertSingleUserProtocolDebt( + spoke2, + reserveId, + bob, + expectedDrawnDebt, + expectedPremiumDebt, + string.concat(reserveName, label) + ); } } diff --git a/tests/unit/Spoke/Spoke.AccrueInterest.t.sol b/tests/unit/Spoke/Spoke.AccrueInterest.t.sol index 1b909c669..8ef2095a8 100644 --- a/tests/unit/Spoke/Spoke.AccrueInterest.t.sol +++ b/tests/unit/Spoke/Spoke.AccrueInterest.t.sol @@ -256,6 +256,7 @@ contract SpokeAccrueInterestTest is SpokeBase { TestAmounts memory amounts, uint40 skipTime ) public { + vm.skip(true, 'pending rft'); amounts = _bound(amounts); skipTime = bound(skipTime, 0, MAX_SKIP_TIME).toUint40(); @@ -1052,15 +1053,15 @@ contract SpokeAccrueInterestTest is SpokeBase { ); } - function _bound(TestAmounts memory amounts) internal pure returns (TestAmounts memory) { - amounts.daiSupplyAmount = bound(amounts.daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - amounts.wethSupplyAmount = bound(amounts.wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - amounts.usdxSupplyAmount = bound(amounts.usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - amounts.wbtcSupplyAmount = bound(amounts.wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - amounts.daiBorrowAmount = bound(amounts.daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - amounts.wethBorrowAmount = bound(amounts.wethBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - amounts.usdxBorrowAmount = bound(amounts.usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - amounts.wbtcBorrowAmount = bound(amounts.wbtcBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); + function _bound(TestAmounts memory amounts) internal view returns (TestAmounts memory) { + amounts.daiSupplyAmount = bound(amounts.daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI); + amounts.wethSupplyAmount = bound(amounts.wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH); + amounts.usdxSupplyAmount = bound(amounts.usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX); + amounts.wbtcSupplyAmount = bound(amounts.wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WBTC); + amounts.daiBorrowAmount = bound(amounts.daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT_DAI); + amounts.wethBorrowAmount = bound(amounts.wethBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WETH); + amounts.usdxBorrowAmount = bound(amounts.usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT_USDX); + amounts.wbtcBorrowAmount = bound(amounts.wbtcBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WBTC); return amounts; } @@ -1086,55 +1087,54 @@ contract SpokeAccrueInterestTest is SpokeBase { ISpoke spoke, TestAmounts memory amounts ) internal view returns (TestAmounts memory) { - uint256 remainingCollateralValue = _getValue( + uint256 remainingCollateralValue = _convertAmountToValue( spoke, _daiReserveId(spoke), amounts.daiSupplyAmount ) + - _getValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + - _getValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount); + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + + _convertAmountToValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount); // Bound each debt amount to be no more than half the remaining collateral value amounts.daiBorrowAmount = bound( amounts.daiBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _daiReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _daiReserveId(spoke), 1) ); // Subtract out the set debt value from the remaining collateral value - remainingCollateralValue -= _getValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) * 2; + remainingCollateralValue -= + _convertAmountToValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) * 2; amounts.wethBorrowAmount = bound( amounts.wethBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _wethReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _wethReserveId(spoke), 1) ); remainingCollateralValue -= - _getValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) * - 2; + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) * 2; amounts.usdxBorrowAmount = bound( amounts.usdxBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _usdxReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _usdxReserveId(spoke), 1) ); remainingCollateralValue -= - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) * - 2; + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) * 2; amounts.wbtcBorrowAmount = bound( amounts.wbtcBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _wbtcReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _wbtcReserveId(spoke), 1) ); assertGt( - _getValue(spoke, _daiReserveId(spoke), amounts.daiSupplyAmount) + - _getValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + - _getValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount), + _convertAmountToValue(spoke, _daiReserveId(spoke), amounts.daiSupplyAmount) + + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + + _convertAmountToValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount), 2 * - (_getValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) + - _getValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) + - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) + - _getValue(spoke, _wbtcReserveId(spoke), amounts.wbtcBorrowAmount)), + (_convertAmountToValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) + + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) + + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) + + _convertAmountToValue(spoke, _wbtcReserveId(spoke), amounts.wbtcBorrowAmount)), 'collateral sufficiently covers debt' ); diff --git a/tests/unit/Spoke/Spoke.AccrueLiquidityFee.EdgeCases.t.sol b/tests/unit/Spoke/Spoke.AccrueLiquidityFee.EdgeCases.t.sol index cfded4d60..4c142b33c 100644 --- a/tests/unit/Spoke/Spoke.AccrueLiquidityFee.EdgeCases.t.sol +++ b/tests/unit/Spoke/Spoke.AccrueLiquidityFee.EdgeCases.t.sol @@ -24,13 +24,14 @@ contract SpokeAccrueLiquidityFeeEdgeCasesTest is SpokeBase { uint256 skipTime, uint256 rate ) public { - borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); // within collateralization rate = bound(rate, 1, MAX_BORROW_RATE); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); uint256 assetId = spoke1.getReserve(reserveId).assetId; + borrowAmount = bound(borrowAmount, 1, _calculateMaxSupplyAmount(spoke1, reserveId) / 2); // within collateralization + updateLiquidityFee(hub1, assetId, MAX_LIQUIDITY_FEE); uint256 supplyAmount = _calcMinimumCollAmount(spoke1, reserveId, reserveId, borrowAmount); @@ -73,12 +74,12 @@ contract SpokeAccrueLiquidityFeeEdgeCasesTest is SpokeBase { uint256 skipTime, uint256 rate ) public { - borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT / 4); // within collateralization - borrowAmount2 = bound(borrowAmount2, 1, MAX_SUPPLY_AMOUNT / 4); // within collateralization rate = bound(rate, 1, MAX_BORROW_RATE); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); uint256 assetId = spoke1.getReserve(reserveId).assetId; + borrowAmount = bound(borrowAmount, 1, _calculateMaxSupplyAmount(spoke1, reserveId) / 4); // within collateralization + borrowAmount2 = bound(borrowAmount2, 1, _calculateMaxSupplyAmount(spoke1, reserveId) / 4); // within collateralization updateLiquidityFee(hub1, spoke1.getReserve(reserveId).assetId, MAX_LIQUIDITY_FEE); @@ -133,7 +134,7 @@ contract SpokeAccrueLiquidityFeeEdgeCasesTest is SpokeBase { uint256 count = vm.randomUint(10, 1000); for (uint256 i; i < count; ++i) { address user = makeUser(i); - uint256 borrowAmount = vm.randomUint(1, MAX_SUPPLY_AMOUNT / count); + uint256 borrowAmount = vm.randomUint(1, _calculateMaxSupplyAmount(spoke1, reserveId) / count); _backedBorrow(spoke1, user, reserveId, reserveId, borrowAmount); } uint256 totalOwedBefore = hub1.getAssetTotalOwed(assetId); diff --git a/tests/unit/Spoke/Spoke.Borrow.HealthFactor.t.sol b/tests/unit/Spoke/Spoke.Borrow.HealthFactor.t.sol index 2ea26aca9..64ed8aad2 100644 --- a/tests/unit/Spoke/Spoke.Borrow.HealthFactor.t.sol +++ b/tests/unit/Spoke/Spoke.Borrow.HealthFactor.t.sol @@ -172,17 +172,15 @@ contract SpokeBorrowHealthFactorTest is SpokeBase { uint256 wethCollAmountDai, uint256 wethCollAmountUsdx ) public { - // todo: resolve precision bounds for wethCollAmountDai, wethCollAmountUsdx - // at high ratios between them, borrowing additional amounts won't bring HF < 1 - wethCollAmountDai = bound(wethCollAmountDai, 1e10, MAX_SUPPLY_AMOUNT / 2); - wethCollAmountUsdx = bound(wethCollAmountUsdx, 1e10, MAX_SUPPLY_AMOUNT / 2); - // weth collateral uint256 wethReserveId = _wethReserveId(spoke1); // dai/usdx debt uint256 daiReserveId = _daiReserveId(spoke1); uint256 usdxReserveId = _usdxReserveId(spoke1); + wethCollAmountDai = bound(wethCollAmountDai, 1e15, MAX_SUPPLY_AMOUNT / 1e4); + wethCollAmountUsdx = bound(wethCollAmountUsdx, 1e15, MAX_SUPPLY_AMOUNT / 1e4); + uint256 daiDebtAmount = _calcMaxDebtAmount({ spoke: spoke1, collReserveId: wethReserveId, @@ -196,9 +194,6 @@ contract SpokeBorrowHealthFactorTest is SpokeBase { collAmount: wethCollAmountUsdx }); - vm.assume(usdxDebtAmount < MAX_SUPPLY_AMOUNT / 2 && usdxDebtAmount > 0); - vm.assume(daiDebtAmount < MAX_SUPPLY_AMOUNT / 2 && daiDebtAmount > 1e12); // dai is 1e18, keep within similar bounds to usdx (at 1e6) - // Bob supply weth Utils.supplyCollateral(spoke1, wethReserveId, bob, wethCollAmountDai + wethCollAmountUsdx, bob); diff --git a/tests/unit/Spoke/Spoke.Borrow.Scenario.t.sol b/tests/unit/Spoke/Spoke.Borrow.Scenario.t.sol index 2a173b96f..de6124386 100644 --- a/tests/unit/Spoke/Spoke.Borrow.Scenario.t.sol +++ b/tests/unit/Spoke/Spoke.Borrow.Scenario.t.sol @@ -15,10 +15,10 @@ contract SpokeBorrowScenarioTest is SpokeBase { uint256 daiBorrowAmount2, uint256 usdxBorrowAmount2 ) public { - daiBorrowAmount = bound(daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 4); - usdxBorrowAmount = bound(usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 4); - daiBorrowAmount2 = bound(daiBorrowAmount2, 0, MAX_SUPPLY_AMOUNT / 4); - usdxBorrowAmount2 = bound(usdxBorrowAmount2, 0, MAX_SUPPLY_AMOUNT / 4); + daiBorrowAmount = bound(daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 4); + usdxBorrowAmount = bound(usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 4); + daiBorrowAmount2 = bound(daiBorrowAmount2, 0, MAX_SUPPLY_AMOUNT_DAI / 4); + usdxBorrowAmount2 = bound(usdxBorrowAmount2, 0, MAX_SUPPLY_AMOUNT_USDX / 4); BorrowTestData memory state; @@ -41,9 +41,10 @@ contract SpokeBorrowScenarioTest is SpokeBase { state.daiBob.premiumDebtRayBefore = _calculatePremiumDebtRay(spoke1, state.daiReserveId, bob); state.usdxBob.premiumDebtRayBefore = _calculatePremiumDebtRay(spoke1, state.usdxReserveId, bob); - state.wethAlice.supplyAmount = state.wbtcAlice.supplyAmount = state.wethBob.supplyAmount = state - .wbtcBob - .supplyAmount = MAX_SUPPLY_AMOUNT / 2; + state.wethAlice.supplyAmount = MAX_SUPPLY_AMOUNT_WETH / 2; + state.wbtcAlice.supplyAmount = MAX_SUPPLY_AMOUNT_WBTC / 2; + state.wethBob.supplyAmount = MAX_SUPPLY_AMOUNT_WETH / 2; + state.wbtcBob.supplyAmount = MAX_SUPPLY_AMOUNT_WBTC / 2; // Alice supply collateral through spoke1 Utils.supplyCollateral(spoke1, state.wethReserveId, alice, state.wethAlice.supplyAmount, alice); @@ -171,10 +172,10 @@ contract SpokeBorrowScenarioTest is SpokeBase { state.usdxReserveId = _usdxReserveId(spoke2); state.wbtcReserveId = _wbtcReserveId(spoke2); - daiBorrowAmount = bound(daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - wethBorrowAmount = bound(wethBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - usdxBorrowAmount = bound(usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - wbtcBorrowAmount = bound(wbtcBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); + daiBorrowAmount = bound(daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 4); + wethBorrowAmount = bound(wethBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 4); + usdxBorrowAmount = bound(usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 4); + wbtcBorrowAmount = bound(wbtcBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WBTC / 4); // should be 0 because no realized premium yet state.daiBob.premiumDebtRayBefore = _calculatePremiumDebtRay(spoke1, state.daiReserveId, bob); @@ -182,9 +183,10 @@ contract SpokeBorrowScenarioTest is SpokeBase { state.usdxBob.premiumDebtRayBefore = _calculatePremiumDebtRay(spoke1, state.usdxReserveId, bob); state.wbtcBob.premiumDebtRayBefore = _calculatePremiumDebtRay(spoke1, state.wbtcReserveId, bob); - state.daiBob.supplyAmount = state.wethBob.supplyAmount = state.usdxBob.supplyAmount = state - .wbtcBob - .supplyAmount = MAX_SUPPLY_AMOUNT; + state.daiBob.supplyAmount = MAX_SUPPLY_AMOUNT_DAI; + state.wethBob.supplyAmount = MAX_SUPPLY_AMOUNT_WETH; + state.usdxBob.supplyAmount = MAX_SUPPLY_AMOUNT_USDX; + state.wbtcBob.supplyAmount = MAX_SUPPLY_AMOUNT_WBTC; // Bob supply all reserves as collateral Utils.supplyCollateral(spoke2, state.daiReserveId, bob, state.daiBob.supplyAmount, bob); @@ -567,7 +569,7 @@ contract SpokeBorrowScenarioTest is SpokeBase { assertEq(_getCollateralFactor(spoke1, coll1ReserveId), 0); // initially assertNotEq(_getCollateralFactor(spoke1, coll2ReserveId), 0); - uint256 coll2Value = _getValue(spoke1, coll2ReserveId, coll2Amount); + uint256 coll2Value = _convertAmountToValue(spoke1, coll2ReserveId, coll2Amount); Utils.supplyCollateral(spoke1, coll1ReserveId, alice, coll1Amount, alice); Utils.supplyCollateral(spoke1, coll2ReserveId, alice, coll2Amount, alice); diff --git a/tests/unit/Spoke/Spoke.Borrow.Validation.t.sol b/tests/unit/Spoke/Spoke.Borrow.Validation.t.sol index cd37c7940..046039141 100644 --- a/tests/unit/Spoke/Spoke.Borrow.Validation.t.sol +++ b/tests/unit/Spoke/Spoke.Borrow.Validation.t.sol @@ -22,7 +22,7 @@ contract SpokeBorrowValidationTest is SpokeBase { amount = bound(amount, 1, MAX_SUPPLY_AMOUNT); // set reserve not borrowable - updateReserveBorrowableFlag(spoke1, reserveId, false); + _updateReserveBorrowableFlag(spoke1, reserveId, false); assertFalse(spoke1.getReserve(reserveId).flags.borrowable()); // Bob tries to draw @@ -76,7 +76,7 @@ contract SpokeBorrowValidationTest is SpokeBase { reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); amount = bound(amount, 1, MAX_SUPPLY_AMOUNT); - updateReserveFrozenFlag(spoke1, reserveId, true); + _updateReserveFrozenFlag(spoke1, reserveId, true); assertTrue(spoke1.getReserve(reserveId).flags.frozen()); // Bob try to draw @@ -177,4 +177,71 @@ contract SpokeBorrowValidationTest is SpokeBase { vm.expectRevert(abi.encodeWithSelector(IHub.DrawCapExceeded.selector, drawCap)); Utils.borrow(spoke1, daiReserveId, bob, 1, bob); } + + function test_borrow_revertsWith_MaximumUserReservesExceeded() public { + uint16 maxUserReservesLimit = (spoke1.getReserveCount() - 1).toUint16(); + _updateMaxUserReservesLimit(spoke1, maxUserReservesLimit); + assertEq(spoke1.MAX_USER_RESERVES_LIMIT(), maxUserReservesLimit, 'Reserve limit adjusted'); + assertGt(spoke1.getReserveCount(), maxUserReservesLimit, 'More reserves than limit'); + + for (uint256 i = 0; i < maxUserReservesLimit; ++i) { + Utils.supplyCollateral(spoke1, i, bob, MAX_SUPPLY_AMOUNT, bob); + Utils.borrow(spoke1, i, bob, 1e18, bob); + } + ISpoke.UserAccountData memory accountData = spoke1.getUserAccountData(bob); + assertEq(accountData.borrowCount, maxUserReservesLimit, 'Bob has reached the borrow limit'); + + // Ensure the next reserve has supply + Utils.supply(spoke1, maxUserReservesLimit, bob, MAX_SUPPLY_AMOUNT, bob); + + // Bob tries to borrow from the last reserve - should revert due to limit + vm.expectRevert(ISpoke.MaximumUserReservesExceeded.selector); + vm.prank(bob); + spoke1.borrow(maxUserReservesLimit, 1e18, bob); + } + + /// @dev Test that borrows up to the user reserves limit, repays one reserve, and then borrows again. + function test_borrow_to_limit_repay_borrow_again() public { + uint16 maxUserReservesLimit = (spoke1.getReserveCount() - 1).toUint16(); + _updateMaxUserReservesLimit(spoke1, maxUserReservesLimit); + assertEq(spoke1.MAX_USER_RESERVES_LIMIT(), maxUserReservesLimit, 'Reserve limit adjusted'); + assertGt(spoke1.getReserveCount(), maxUserReservesLimit, 'More reserves than limit'); + + uint256 borrowAmount = 1e18; + for (uint256 i = 0; i < maxUserReservesLimit; ++i) { + Utils.supplyCollateral(spoke1, i, bob, MAX_SUPPLY_AMOUNT, bob); + Utils.borrow(spoke1, i, bob, borrowAmount, bob); + } + + ISpoke.UserAccountData memory accountData = spoke1.getUserAccountData(bob); + assertEq(accountData.borrowCount, maxUserReservesLimit, 'Bob has reached the borrow limit'); + + Utils.repay(spoke1, 0, bob, type(uint256).max, bob); + + accountData = spoke1.getUserAccountData(bob); + assertEq(accountData.borrowCount, maxUserReservesLimit - 1, 'Bob has repaid one reserve'); + + // Ensure the next reserve has supply + Utils.supply(spoke1, maxUserReservesLimit, bob, MAX_SUPPLY_AMOUNT, bob); + + Utils.borrow(spoke1, maxUserReservesLimit, bob, borrowAmount, bob); + + accountData = spoke1.getUserAccountData(bob); + assertEq(accountData.borrowCount, maxUserReservesLimit, 'Bob has reached the borrow limit'); + } + + /// @dev Test showing that when the borrow limit is max, all reserves can be borrowed. + function test_borrow_unlimited_whenLimitIsMax() public { + assertEq(spoke1.MAX_USER_RESERVES_LIMIT(), Constants.MAX_ALLOWED_USER_RESERVES_LIMIT); + + uint256 reservesToBorrow = spoke1.getReserveCount(); + + for (uint256 i = 0; i < reservesToBorrow; ++i) { + Utils.supplyCollateral(spoke1, i, bob, MAX_SUPPLY_AMOUNT, bob); + Utils.borrow(spoke1, i, bob, 1e18, bob); + } + + ISpoke.UserAccountData memory accountData = spoke1.getUserAccountData(bob); + assertEq(accountData.borrowCount, reservesToBorrow); + } } diff --git a/tests/unit/Spoke/Spoke.Borrow.t.sol b/tests/unit/Spoke/Spoke.Borrow.t.sol index 21c0f7b13..bda270018 100644 --- a/tests/unit/Spoke/Spoke.Borrow.t.sol +++ b/tests/unit/Spoke/Spoke.Borrow.t.sol @@ -5,6 +5,56 @@ pragma solidity ^0.8.0; import 'tests/unit/Spoke/SpokeBase.t.sol'; contract SpokeBorrowTest is SpokeBase { + function test_borrow_revertsWith_ReentrancyGuardReentrantCall_hubDraw() public { + uint256 amount = 100e18; + Utils.supplyCollateral({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: amount * 10, + onBehalfOf: bob + }); + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke1), + ISpokeBase.borrow.selector + ); + + vm.mockFunction( + address(_hub(spoke1, _daiReserveId(spoke1))), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.draw.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + spoke1.borrow(_daiReserveId(spoke1), amount, bob); + } + + function test_borrow_revertsWith_ReentrancyGuardReentrantCall_hubRefreshPremium() public { + uint256 amount = 100e18; + Utils.supplyCollateral({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: amount * 10, + onBehalfOf: bob + }); + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke1), + ISpokeBase.borrow.selector + ); + + vm.mockFunction( + address(_hub(spoke1, _daiReserveId(spoke1))), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.refreshPremium.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + spoke1.borrow(_daiReserveId(spoke1), amount, bob); + } + function test_borrow() public { BorrowTestData memory state; diff --git a/tests/unit/Spoke/Spoke.Config.t.sol b/tests/unit/Spoke/Spoke.Config.t.sol index 3531ed70e..855ffc245 100644 --- a/tests/unit/Spoke/Spoke.Config.t.sol +++ b/tests/unit/Spoke/Spoke.Config.t.sol @@ -9,36 +9,52 @@ contract SpokeConfigTest is SpokeBase { using PercentageMath for uint256; function test_spoke_deploy() public { - address predictedSpokeAddress = vm.computeCreateAddress( - address(this), - vm.getNonce(address(this)) - ); address oracle = makeAddr('AaveOracle'); vm.expectCall(oracle, abi.encodeCall(IPriceOracle.DECIMALS, ()), 1); vm.mockCall(oracle, abi.encodeCall(IPriceOracle.DECIMALS, ()), abi.encode(8)); - SpokeInstance instance = new SpokeInstance(oracle); - assertEq(address(instance), predictedSpokeAddress, 'predictedSpokeAddress'); + ISpoke instance = ISpoke( + address( + DeployUtils.deploySpokeImplementation(oracle, Constants.MAX_ALLOWED_USER_RESERVES_LIMIT) + ) + ); assertEq(instance.ORACLE(), oracle); + assertEq(instance.MAX_USER_RESERVES_LIMIT(), Constants.MAX_ALLOWED_USER_RESERVES_LIMIT); assertNotEq(instance.getLiquidationLogic(), address(0)); } function test_spoke_deploy_reverts_on_InvalidConstructorInput() public { + DeployWrapper deployer = new DeployWrapper(); + vm.expectRevert(); - new SpokeInstance(address(0)); + deployer.deploySpokeImplementation(address(0), Constants.MAX_ALLOWED_USER_RESERVES_LIMIT); } - function test_spoke_deploy_revertsWith_InvalidOracleDecimals() public { + function test_spoke_deploy_reverts_on_InvalidOracleDecimals() public { + DeployWrapper deployer = new DeployWrapper(); address oracle = makeAddr('AaveOracle'); + vm.mockCall(oracle, abi.encodeCall(IPriceOracle.DECIMALS, ()), abi.encode(7)); - vm.expectRevert(ISpoke.InvalidOracleDecimals.selector); - new SpokeInstance(oracle); + vm.expectRevert(); + deployer.deploySpokeImplementation(oracle, Constants.MAX_ALLOWED_USER_RESERVES_LIMIT); + } + + function test_spoke_deploy_reverts_on_InvalidMaxUserReservesLimit() public { + DeployWrapper deployer = new DeployWrapper(); + address oracle = makeAddr('AaveOracle'); + + vm.mockCall(oracle, abi.encodeCall(IPriceOracle.DECIMALS, ()), abi.encode(8)); + vm.expectRevert(); + deployer.deploySpokeImplementation(oracle, 0); } function test_updateReservePriceSource_revertsWith_AccessManagedUnauthorized( address caller ) public { vm.assume( - caller != SPOKE_ADMIN && caller != ADMIN && caller != _getProxyAdminAddress(address(spoke1)) + caller != SPOKE_ADMIN && + caller != ADMIN && + caller != SPOKE_CONFIGURATOR && + caller != _getProxyAdminAddress(address(spoke1)) ); vm.expectRevert( abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, caller) @@ -75,7 +91,6 @@ contract SpokeConfigTest is SpokeBase { paused: !config.paused, frozen: !config.frozen, borrowable: !config.borrowable, - liquidatable: !config.liquidatable, receiveSharesEnabled: !config.receiveSharesEnabled, collateralRisk: config.collateralRisk + 1 }); @@ -158,6 +173,7 @@ contract SpokeConfigTest is SpokeBase { assertEq(spoke1.getReserveConfig(reserveId), newReserveConfig); assertEq(_getLatestDynamicReserveConfig(spoke1, reserveId), newDynReserveConfig); + assertEq(spoke1.getReserveId(address(hub1), usdzAssetId), reserveId); } function test_addReserve_fuzz_revertsWith_AssetNotListed() public { @@ -176,6 +192,28 @@ contract SpokeConfigTest is SpokeBase { spoke1.addReserve(address(hub1), assetId, reserveSource, newReserveConfig, newDynReserveConfig); } + function test_addReserve_revertsWith_InvalidUnderlyingDecimals() public { + uint256 assetId = usdzAssetId; + ISpoke.ReserveConfig memory newReserveConfig = _getDefaultReserveConfig(10_00); + ISpoke.DynamicReserveConfig memory newDynReserveConfig = ISpoke.DynamicReserveConfig({ + collateralFactor: 10_00, + maxLiquidationBonus: 110_00, + liquidationFee: 10_00 + }); + + address reserveSource = _deployMockPriceFeed(spoke1, 1e8); + + vm.mockCall( + address(hub1), + abi.encodeCall(IHubBase.getAssetUnderlyingAndDecimals, (assetId)), + abi.encode(address(tokenList.dai), 19) + ); + + vm.expectRevert(ISpoke.InvalidAssetDecimals.selector, address(spoke1)); + vm.prank(SPOKE_ADMIN); + spoke1.addReserve(address(hub1), assetId, reserveSource, newReserveConfig, newDynReserveConfig); + } + function test_addReserve_revertsWith_InvalidAddress_hub() public { (ISpoke newSpoke, ) = _deploySpokeWithOracle(ADMIN, address(accessManager), 'New Spoke (USD)'); @@ -263,6 +301,110 @@ contract SpokeConfigTest is SpokeBase { ); } + function test_getReserveId_fuzz(uint256 reserveId) public view { + reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); + uint256 assetId = spoke1.getReserve(reserveId).assetId; + + uint256 returnedId = spoke1.getReserveId(address(hub1), assetId); + assertEq(returnedId, getReserveIdByAssetId(spoke1, hub1, assetId)); + } + + function test_getReserveId_fuzz_multipleHubs(uint256 reserveId) public { + (IHub hub2, ) = hub2Fixture(); + (IHub hub3, ) = hub3Fixture(); + + vm.startPrank(ADMIN); + spoke1.addReserve( + address(hub2), + 0, + _deployMockPriceFeed(spoke1, 2000e8), + spokeInfo[spoke1].weth.reserveConfig, + spokeInfo[spoke1].weth.dynReserveConfig + ); + spoke1.addReserve( + address(hub2), + 1, + _deployMockPriceFeed(spoke1, 2000e8), + spokeInfo[spoke1].usdx.reserveConfig, + spokeInfo[spoke1].usdx.dynReserveConfig + ); + spoke1.addReserve( + address(hub2), + 2, + _deployMockPriceFeed(spoke1, 2000e8), + spokeInfo[spoke1].dai.reserveConfig, + spokeInfo[spoke1].dai.dynReserveConfig + ); + spoke1.addReserve( + address(hub2), + 3, + _deployMockPriceFeed(spoke1, 2000e8), + spokeInfo[spoke1].wbtc.reserveConfig, + spokeInfo[spoke1].wbtc.dynReserveConfig + ); + + spoke1.addReserve( + address(hub3), + 0, + _deployMockPriceFeed(spoke1, 2000e8), + spokeInfo[spoke1].dai.reserveConfig, + spokeInfo[spoke1].dai.dynReserveConfig + ); + spoke1.addReserve( + address(hub3), + 1, + _deployMockPriceFeed(spoke1, 2000e8), + spokeInfo[spoke1].usdx.reserveConfig, + spokeInfo[spoke1].usdx.dynReserveConfig + ); + spoke1.addReserve( + address(hub3), + 2, + _deployMockPriceFeed(spoke1, 2000e8), + spokeInfo[spoke1].wbtc.reserveConfig, + spokeInfo[spoke1].wbtc.dynReserveConfig + ); + spoke1.addReserve( + address(hub3), + 3, + _deployMockPriceFeed(spoke1, 2000e8), + spokeInfo[spoke1].weth.reserveConfig, + spokeInfo[spoke1].weth.dynReserveConfig + ); + + IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ + active: true, + halted: false, + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK + }); + + hub2.addSpoke(0, address(spoke1), spokeConfig); + hub2.addSpoke(1, address(spoke1), spokeConfig); + hub2.addSpoke(2, address(spoke1), spokeConfig); + hub2.addSpoke(3, address(spoke1), spokeConfig); + + hub3.addSpoke(0, address(spoke1), spokeConfig); + hub3.addSpoke(1, address(spoke1), spokeConfig); + hub3.addSpoke(2, address(spoke1), spokeConfig); + hub3.addSpoke(3, address(spoke1), spokeConfig); + vm.stopPrank(); + + reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); + uint256 assetId = spoke1.getReserve(reserveId).assetId; + address hub = address(spoke1.getReserve(reserveId).hub); + + uint256 returnedId = spoke1.getReserveId(hub, assetId); + assertEq(returnedId, getReserveIdByAssetId(spoke1, IHub(hub), assetId)); + } + + function test_getReserveId_fuzz_revertsWith_ReserveNotListed(uint256 assetId) public { + assetId = bound(assetId, hub1.getAssetCount(), UINT256_MAX); + vm.expectRevert(ISpoke.ReserveNotListed.selector, address(spoke1)); + spoke1.getReserveId(address(hub1), assetId); + } + function test_updateLiquidationConfig_targetHealthFactor() public { uint128 newTargetHealthFactor = HEALTH_FACTOR_LIQUIDATION_THRESHOLD + 1; diff --git a/tests/unit/Spoke/Spoke.DynamicConfig.Triggers.t.sol b/tests/unit/Spoke/Spoke.DynamicConfig.Triggers.t.sol index 7060c33a4..ec26f9d38 100644 --- a/tests/unit/Spoke/Spoke.DynamicConfig.Triggers.t.sol +++ b/tests/unit/Spoke/Spoke.DynamicConfig.Triggers.t.sol @@ -5,22 +5,46 @@ pragma solidity ^0.8.0; import 'tests/unit/Spoke/SpokeBase.t.sol'; contract SpokeDynamicConfigTriggersTest is SpokeBase { + using PercentageMath for uint256; + using SafeCast for uint256; + function test_supply_does_not_trigger_dynamicConfigUpdate() public { DynamicConfig[] memory configs = _getUserDynConfigKeys(spoke1, alice); - Utils.supplyCollateral(spoke1, _usdxReserveId(spoke1), alice, 1000e6, alice); - _updateCollateralFactor(spoke1, _usdxReserveId(spoke1), _randomBps()); + uint256 maxLiquidationBonus = _getUserDynConfig(spoke1, alice, _daiReserveId(spoke1)) + .maxLiquidationBonus; + uint256 supplyAmount = 1000e6; + uint256 collateralReserveId = _usdxReserveId(spoke1); + uint256 debtReserveId = _daiReserveId(spoke1); + uint256 collateralFactor = vm + .randomUint(0, _collateralFactorUpperBound(maxLiquidationBonus)) + .toUint16(); + + Utils.supplyCollateral(spoke1, collateralReserveId, alice, supplyAmount, alice); + _updateCollateralFactor(spoke1, collateralReserveId, collateralFactor); assertEq(_getUserDynConfigKeys(spoke1, alice), configs); - _openSupplyPosition(spoke1, _daiReserveId(spoke1), 500e18); - Utils.borrow(spoke1, _daiReserveId(spoke1), alice, 500e18, alice); + // compute max borrowable amount + uint256 borrowAmount = collateralFactor.percentMulDown( + _convertValueToAmount( + spoke1, + debtReserveId, + _convertAmountToValue(spoke1, collateralReserveId, supplyAmount) + ) + ); + _openSupplyPosition(spoke1, debtReserveId, borrowAmount); + Utils.borrow(spoke1, debtReserveId, alice, borrowAmount, alice); configs = _getUserDynConfigKeys(spoke1, alice); - _updateCollateralFactor(spoke1, _usdxReserveId(spoke1), _randomBps()); + _updateCollateralFactor( + spoke1, + collateralReserveId, + vm.randomUint(0, _collateralFactorUpperBound(maxLiquidationBonus)).toUint16() + ); assertEq(_getUserDynConfigKeys(spoke1, alice), configs); - Utils.supply(spoke1, _usdxReserveId(spoke1), alice, 1000e6, alice); + Utils.supply(spoke1, collateralReserveId, alice, supplyAmount, alice); _assertDynamicConfigRefreshEventsNotEmitted(); // user config should not change @@ -101,7 +125,14 @@ contract SpokeDynamicConfigTriggersTest is SpokeBase { vm.prank(alice); spoke1.borrow(_daiReserveId(spoke1), 100e18, alice); - _updateCollateralFactor(spoke1, _usdxReserveId(spoke1), _randomBps()); + uint256 maxLiquidationBonus = _getUserDynConfig(spoke1, alice, _daiReserveId(spoke1)) + .maxLiquidationBonus; + + _updateCollateralFactor( + spoke1, + _usdxReserveId(spoke1), + vm.randomUint(0, _collateralFactorUpperBound(maxLiquidationBonus)).toUint16() + ); configs = _getUserDynConfigKeys(spoke1, alice); Utils.supplyCollateral(spoke1, _wethReserveId(spoke1), alice, 1e18, alice); @@ -157,7 +188,8 @@ contract SpokeDynamicConfigTriggersTest is SpokeBase { vm.prank(alice); spoke1.setUsingAsCollateral(_usdxReserveId(spoke1), false, alice); - _updateCollateralFactor(spoke1, _usdxReserveId(spoke1), _randomBps()); + uint256 reserveId = _usdxReserveId(spoke1); + _updateCollateralFactor(spoke1, reserveId, _randomCollateralFactor(spoke1, reserveId)); configs = _getUserDynConfigKeys(spoke1, alice); Utils.supply(spoke1, _wethReserveId(spoke1), alice, 1e18, alice); @@ -262,8 +294,8 @@ contract SpokeDynamicConfigTriggersTest is SpokeBase { // Alice's dai debt is exactly covered by her weth collateral assertEq( - _getValue(spoke1, _daiReserveId(spoke1), 2000e18), - _getValue(spoke1, _wethReserveId(spoke1), 1e18), + _convertAmountToValue(spoke1, _daiReserveId(spoke1), 2000e18), + _convertAmountToValue(spoke1, _wethReserveId(spoke1), 1e18), 'weth supply covers debt' ); diff --git a/tests/unit/Spoke/Spoke.DynamicConfig.t.sol b/tests/unit/Spoke/Spoke.DynamicConfig.t.sol index d15c6f280..21276ae30 100644 --- a/tests/unit/Spoke/Spoke.DynamicConfig.t.sol +++ b/tests/unit/Spoke/Spoke.DynamicConfig.t.sol @@ -12,7 +12,9 @@ contract SpokeDynamicConfigTest is SpokeBase { function setUp() public override { super.setUp(); spoke = MockSpoke(address(spoke1)); - address mockSpokeImpl = address(new MockSpoke(address(spoke.ORACLE()))); + address mockSpokeImpl = address( + new MockSpoke(address(spoke.ORACLE()), Constants.MAX_ALLOWED_USER_RESERVES_LIMIT) + ); vm.etch(address(spoke1), mockSpokeImpl.code); } @@ -99,7 +101,7 @@ contract SpokeDynamicConfigTest is SpokeBase { MockSpoke(address(spoke1)).setReserveDynamicConfigKey( reserveId, - uint24(Constants.MAX_ALLOWED_DYNAMIC_CONFIG_KEY) + uint32(Constants.MAX_ALLOWED_DYNAMIC_CONFIG_KEY) ); vm.expectRevert(ISpoke.MaximumDynamicConfigKeyReached.selector, address(spoke1)); @@ -111,10 +113,13 @@ contract SpokeDynamicConfigTest is SpokeBase { address caller ) public { vm.assume( - caller != SPOKE_ADMIN && caller != ADMIN && caller != _getProxyAdminAddress(address(spoke1)) + caller != SPOKE_ADMIN && + caller != ADMIN && + caller != SPOKE_CONFIGURATOR && + caller != _getProxyAdminAddress(address(spoke1)) ); uint256 reserveId = _randomReserveId(spoke1); - uint24 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); + uint32 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); ISpoke.DynamicReserveConfig memory dynConf = ISpoke.DynamicReserveConfig({ collateralFactor: 80_00, maxLiquidationBonus: 100_00, @@ -132,7 +137,7 @@ contract SpokeDynamicConfigTest is SpokeBase { public { uint256 reserveId = _randomReserveId(spoke1); - uint24 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); + uint32 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); ISpoke.DynamicReserveConfig memory config = _getLatestDynamicReserveConfig(spoke1, reserveId); config.maxLiquidationBonus = vm.randomUint(0, PercentageMath.PERCENTAGE_FACTOR - 1).toUint32(); @@ -144,7 +149,7 @@ contract SpokeDynamicConfigTest is SpokeBase { /// cannot set collateral factor for a historical config key to 0 function test_updateDynamicReserveConfig_revertsWith_InvalidCollateralFactor() public { uint256 reserveId = _randomReserveId(spoke1); - uint24 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); + uint32 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); ISpoke.DynamicReserveConfig memory config = _getLatestDynamicReserveConfig(spoke1, reserveId); config.collateralFactor = 0; @@ -166,7 +171,7 @@ contract SpokeDynamicConfigTest is SpokeBase { ).toUint32(); uint256 reserveId = _randomReserveId(spoke1); - uint24 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); + uint32 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); ISpoke.DynamicReserveConfig memory config = _getLatestDynamicReserveConfig(spoke1, reserveId); config.collateralFactor = collateralFactor; config.maxLiquidationBonus = liquidationBonus; @@ -178,7 +183,7 @@ contract SpokeDynamicConfigTest is SpokeBase { function test_updateDynamicReserveConfig_revertsWith_InvalidLiquidationFee() public { uint256 reserveId = _randomReserveId(spoke1); - uint24 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); + uint32 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); ISpoke.DynamicReserveConfig memory config = _getLatestDynamicReserveConfig(spoke1, reserveId); config.liquidationFee = vm .randomUint(PercentageMath.PERCENTAGE_FACTOR + 1, type(uint16).max) @@ -197,7 +202,7 @@ contract SpokeDynamicConfigTest is SpokeBase { .toUint16(); uint256 reserveId = _randomReserveId(spoke1); - uint24 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); + uint32 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); ISpoke.DynamicReserveConfig memory config = _getLatestDynamicReserveConfig(spoke1, reserveId); config.collateralFactor = collateralFactor; @@ -219,10 +224,13 @@ contract SpokeDynamicConfigTest is SpokeBase { address caller ) public { vm.assume( - caller != SPOKE_ADMIN && caller != ADMIN && caller != _getProxyAdminAddress(address(spoke1)) + caller != SPOKE_ADMIN && + caller != ADMIN && + caller != SPOKE_CONFIGURATOR && + caller != _getProxyAdminAddress(address(spoke1)) ); uint256 reserveId = _randomReserveId(spoke1); - uint24 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); + uint32 dynamicConfigKey = _randomInitializedConfigKey(spoke1, reserveId); ISpoke.DynamicReserveConfig memory dynConf = ISpoke.DynamicReserveConfig({ collateralFactor: 80_00, maxLiquidationBonus: 100_00, @@ -236,12 +244,12 @@ contract SpokeDynamicConfigTest is SpokeBase { spoke1.updateDynamicReserveConfig(reserveId, dynamicConfigKey, dynConf); } - function test_updateDynamicReserveConfig_revertsWith_ConfigKeyUninitialized() public { + function test_updateDynamicReserveConfig_revertsWith_DynamicConfigKeyUninitialized() public { uint256 reserveId = _randomReserveId(spoke1); - uint24 dynamicConfigKey = _randomUninitializedConfigKey(spoke1, reserveId); + uint32 dynamicConfigKey = _randomUninitializedConfigKey(spoke1, reserveId); ISpoke.DynamicReserveConfig memory dynConf = _getLatestDynamicReserveConfig(spoke1, reserveId); - vm.expectRevert(ISpoke.ConfigKeyUninitialized.selector); + vm.expectRevert(ISpoke.DynamicConfigKeyUninitialized.selector); vm.prank(SPOKE_ADMIN); spoke1.updateDynamicReserveConfig(reserveId, dynamicConfigKey, dynConf); } @@ -253,7 +261,7 @@ contract SpokeDynamicConfigTest is SpokeBase { liquidationFee: 15_00 }); uint256 reserveId = _randomReserveId(spoke1); - uint24 expectedConfigKey = _nextDynamicConfigKey(spoke1, reserveId); + uint32 expectedConfigKey = _nextDynamicConfigKey(spoke1, reserveId); vm.expectEmit(address(spoke1)); emit ISpoke.AddDynamicReserveConfig(reserveId, expectedConfigKey, dynConf); @@ -279,7 +287,7 @@ contract SpokeDynamicConfigTest is SpokeBase { } assertEq(spoke1.getReserve(reserveId).dynamicConfigKey, count); - uint24 dynamicConfigKey = vm.randomUint(0, count).toUint24(); + uint32 dynamicConfigKey = vm.randomUint(0, count).toUint32(); dynConf.liquidationFee = _randomBps(); vm.expectEmit(address(spoke1)); @@ -298,7 +306,7 @@ contract SpokeDynamicConfigTest is SpokeBase { DynamicConfig[] memory configs = _getSpokeDynConfigKeys(spoke1); for (uint256 reserveId; reserveId < spoke1.getReserveCount(); ++reserveId) { - uint24 dynamicConfigKey = _nextDynamicConfigKey(spoke1, reserveId); + uint32 dynamicConfigKey = _nextDynamicConfigKey(spoke1, reserveId); ISpoke.DynamicReserveConfig memory dynConf = _getLatestDynamicReserveConfig( spoke1, @@ -318,17 +326,20 @@ contract SpokeDynamicConfigTest is SpokeBase { // more realistic, update config keys in a random order function test_fuzz_addDynamicReserveConfig_trailing_order(bytes32) public { DynamicConfig[] memory configs = _getSpokeDynConfigKeys(spoke1); - uint256 runs = vm.randomUint(1, 100); // [1,100] iterations each fuzz run + uint256 runs = vm.randomUint(1, 10); // [1,10] iterations each fuzz run while (--runs != 0) { uint256 reserveId = _randomReserveId(spoke1); - uint24 dynamicConfigKey = _nextDynamicConfigKey(spoke1, reserveId); + uint32 dynamicConfigKey = _nextDynamicConfigKey(spoke1, reserveId); ISpoke.DynamicReserveConfig memory dynConf = _getLatestDynamicReserveConfig( spoke1, reserveId ); - dynConf.collateralFactor = _randomBps(); + dynConf.collateralFactor = vm.randomUint(0, PercentageMath.PERCENTAGE_FACTOR - 1).toUint16(); + dynConf.maxLiquidationBonus = vm + .randomUint(MIN_LIQUIDATION_BONUS, _maxLiquidationBonusUpperBound(dynConf.collateralFactor)) + .toUint32(); vm.expectEmit(address(spoke1)); emit ISpoke.AddDynamicReserveConfig(reserveId, dynamicConfigKey, dynConf); @@ -343,11 +354,11 @@ contract SpokeDynamicConfigTest is SpokeBase { // update duplicated config values function test_fuzz_addDynamicReserveConfig_spaced_dup_updates(bytes32) public { DynamicConfig[] memory configs = _getSpokeDynConfigKeys(spoke1); - uint256 runs = vm.randomUint(1, 100); // [1,100] iterations each fuzz run + uint256 runs = vm.randomUint(1, 10); // [1,10] iterations each fuzz run while (--runs != 0) { uint256 reserveId = _randomReserveId(spoke1); - uint24 dynamicConfigKey = _nextDynamicConfigKey(spoke1, reserveId); + uint32 dynamicConfigKey = _nextDynamicConfigKey(spoke1, reserveId); ISpoke.DynamicReserveConfig memory dynConf = _getLatestDynamicReserveConfig( spoke1, @@ -355,9 +366,12 @@ contract SpokeDynamicConfigTest is SpokeBase { ); dynConf.collateralFactor = vm.randomUint() % 2 == 0 ? spoke1 - .getDynamicReserveConfig(reserveId, vm.randomUint(0, dynamicConfigKey - 1).toUint24()) + .getDynamicReserveConfig(reserveId, vm.randomUint(0, dynamicConfigKey - 1).toUint32()) .collateralFactor : _randomCollateralFactor(spoke1, reserveId); + dynConf.maxLiquidationBonus = vm + .randomUint(MIN_LIQUIDATION_BONUS, _maxLiquidationBonusUpperBound(dynConf.collateralFactor)) + .toUint32(); vm.expectEmit(address(spoke1)); emit ISpoke.AddDynamicReserveConfig(reserveId, dynamicConfigKey, dynConf); diff --git a/tests/unit/Spoke/Spoke.Getters.t.sol b/tests/unit/Spoke/Spoke.Getters.t.sol index 80d635e13..fc428d7d2 100644 --- a/tests/unit/Spoke/Spoke.Getters.t.sol +++ b/tests/unit/Spoke/Spoke.Getters.t.sol @@ -21,7 +21,7 @@ contract SpokeGettersTest is SpokeBase { IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK diff --git a/tests/unit/Spoke/Spoke.MultipleHub.Base.t.sol b/tests/unit/Spoke/Spoke.MultipleHub.Base.t.sol index 9c8ed245d..3cdd089b3 100644 --- a/tests/unit/Spoke/Spoke.MultipleHub.Base.t.sol +++ b/tests/unit/Spoke/Spoke.MultipleHub.Base.t.sol @@ -37,13 +37,13 @@ contract SpokeMultipleHubBase is SpokeBase { vm.startPrank(ADMIN); accessManager = IAccessManager(address(new AccessManagerEnumerable(ADMIN))); // Canonical hub and spoke - hub1 = new Hub(address(accessManager)); + hub1 = DeployUtils.deployHub(address(accessManager), hex'01'); (spoke1, oracle1) = _deploySpokeWithOracle(ADMIN, address(accessManager), 'Spoke 1 (USD)'); treasurySpoke = new TreasurySpoke(ADMIN, address(hub1)); irStrategy = new AssetInterestRateStrategy(address(hub1)); // New hub and spoke - newHub = new Hub(address(accessManager)); + newHub = DeployUtils.deployHub(address(accessManager), hex'02'); (newSpoke, newOracle) = _deploySpokeWithOracle( ADMIN, address(accessManager), diff --git a/tests/unit/Spoke/Spoke.MultipleHub.IsolationMode.t.sol b/tests/unit/Spoke/Spoke.MultipleHub.IsolationMode.t.sol index 48b5f21dd..738b1641b 100644 --- a/tests/unit/Spoke/Spoke.MultipleHub.IsolationMode.t.sol +++ b/tests/unit/Spoke/Spoke.MultipleHub.IsolationMode.t.sol @@ -66,7 +66,7 @@ contract SpokeMultipleHubIsolationModeTest is SpokeMultipleHubBase { address(newSpoke), IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK @@ -77,7 +77,7 @@ contract SpokeMultipleHubIsolationModeTest is SpokeMultipleHubBase { address(newSpoke), IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK @@ -111,7 +111,7 @@ contract SpokeMultipleHubIsolationModeTest is SpokeMultipleHubBase { address(spoke1), IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK @@ -188,7 +188,7 @@ contract SpokeMultipleHubIsolationModeTest is SpokeMultipleHubBase { address(newSpoke), IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: 0, drawCap: 100_000, riskPremiumThreshold: 1000_00 @@ -262,7 +262,7 @@ contract SpokeMultipleHubIsolationModeTest is SpokeMultipleHubBase { address(newSpoke), IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: 0, drawCap: 0, riskPremiumThreshold: 1000_00 diff --git a/tests/unit/Spoke/Spoke.MultipleHub.SiloedBorrowing.t.sol b/tests/unit/Spoke/Spoke.MultipleHub.SiloedBorrowing.t.sol index 956efa7c2..1f450b130 100644 --- a/tests/unit/Spoke/Spoke.MultipleHub.SiloedBorrowing.t.sol +++ b/tests/unit/Spoke/Spoke.MultipleHub.SiloedBorrowing.t.sol @@ -58,7 +58,7 @@ contract SpokeMultipleHubSiloedBorrowingTest is SpokeMultipleHubBase { siloedVars.assetBId, address(newSpoke), IHub.SpokeConfig({ - paused: false, + halted: false, active: true, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: siloedVars.assetBDrawCap, @@ -91,7 +91,7 @@ contract SpokeMultipleHubSiloedBorrowingTest is SpokeMultipleHubBase { address(spoke1), IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK @@ -113,7 +113,7 @@ contract SpokeMultipleHubSiloedBorrowingTest is SpokeMultipleHubBase { address(newSpoke), IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: siloedVars.assetAAddCap, drawCap: 0, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK diff --git a/tests/unit/Spoke/Spoke.MultipleHub.t.sol b/tests/unit/Spoke/Spoke.MultipleHub.t.sol index 5ceb851e0..0029fe4ad 100644 --- a/tests/unit/Spoke/Spoke.MultipleHub.t.sol +++ b/tests/unit/Spoke/Spoke.MultipleHub.t.sol @@ -59,7 +59,7 @@ contract SpokeMultipleHubTest is SpokeBase { IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK diff --git a/tests/unit/Spoke/Spoke.PositionManager.t.sol b/tests/unit/Spoke/Spoke.PositionManager.t.sol index 99a16911f..91b1d117d 100644 --- a/tests/unit/Spoke/Spoke.PositionManager.t.sol +++ b/tests/unit/Spoke/Spoke.PositionManager.t.sol @@ -12,42 +12,13 @@ contract SpokePositionManagerTest is SpokeBase { address positionManager = vm.randomAddress(); bool approve = vm.randomBool(); - // if position manager not active, then user should not be able to approve, else action should be idempotent - if (!spoke1.isPositionManagerActive(positionManager) && approve) { - vm.expectRevert(ISpoke.InactivePositionManager.selector); - } else { - vm.expectEmit(address(spoke1)); - emit ISpoke.SetUserPositionManager(user, positionManager, approve); - } + vm.expectEmit(address(spoke1)); + emit ISpoke.SetUserPositionManager(user, positionManager, approve); vm.prank(user); spoke1.setUserPositionManager(positionManager, approve); } - function test_setApproval_revertsWith_InactivePositionManager() public { - assertFalse(spoke1.isPositionManagerActive(POSITION_MANAGER)); - vm.expectRevert(ISpoke.InactivePositionManager.selector); - spoke1.setUserPositionManager(POSITION_MANAGER, true); - } - - function test_disableApproval_on_InactivePositionManager() public { - _approvePositionManager(alice); - assertTrue(spoke1.isPositionManager(alice, POSITION_MANAGER)); - assertTrue(spoke1.isPositionManagerActive(POSITION_MANAGER)); - - _disablePositionManager(); - assertFalse(spoke1.isPositionManager(alice, POSITION_MANAGER)); // since posm is not active - assertFalse(spoke1.isPositionManagerActive(POSITION_MANAGER)); - - vm.expectEmit(address(spoke1)); - emit ISpoke.SetUserPositionManager(alice, POSITION_MANAGER, false); - vm.prank(alice); - spoke1.setUserPositionManager(POSITION_MANAGER, false); - - assertFalse(spoke1.isPositionManager(alice, POSITION_MANAGER)); - assertFalse(spoke1.isPositionManagerActive(POSITION_MANAGER)); - } - function test_renouncePositionManagerRole() public { vm.setArbitraryStorage(address(spoke1)); diff --git a/tests/unit/Spoke/Spoke.Repay.Scenario.t.sol b/tests/unit/Spoke/Spoke.Repay.Scenario.t.sol index 3de6ff8b1..5e6add00d 100644 --- a/tests/unit/Spoke/Spoke.Repay.Scenario.t.sol +++ b/tests/unit/Spoke/Spoke.Repay.Scenario.t.sol @@ -943,8 +943,7 @@ contract SpokeRepayScenarioTest is SpokeBase { _wethReserveId(spoke1), _daiReserveId(spoke1), action1.borrowAmount - ) + - 1; + ) + 1; Utils.supplyCollateral(spoke1, _wethReserveId(spoke1), bob, action1.supplyAmount, bob); // Alice supply dai @@ -1326,9 +1325,9 @@ contract SpokeRepayScenarioTest is SpokeBase { _assumeValidSupplier(caller); vm.assume(caller != derl); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); - userBorrowing = bound(userBorrowing, 0, MAX_SUPPLY_AMOUNT / 2 - 1); // Allow some buffer from borrow cap + userBorrowing = bound(userBorrowing, 0, _calculateMaxSupplyAmount(spoke1, reserveId) / 2 - 1); // Allow some buffer from borrow cap skipTime = bound(skipTime, 0, MAX_SKIP_TIME).toUint40(); - assets = bound(assets, 1, MAX_SUPPLY_AMOUNT / 2 - userBorrowing); + assets = bound(assets, 1, _calculateMaxSupplyAmount(spoke1, reserveId) / 2 - userBorrowing); // Set up initial state of the vault by having derl borrow uint256 supplyAmount = _calcMinimumCollAmount(spoke1, reserveId, reserveId, userBorrowing); @@ -1344,7 +1343,7 @@ contract SpokeRepayScenarioTest is SpokeBase { IERC20 underlying = getAssetUnderlyingByReserveId(spoke1, reserveId); // Deal caller max collateral amount, approve spoke, supply - supplyAmount = MAX_SUPPLY_AMOUNT - supplyAmount; + supplyAmount = _calculateMaxSupplyAmount(spoke1, reserveId) - supplyAmount; deal(address(underlying), caller, supplyAmount); vm.prank(caller); underlying.approve(address(spoke1), supplyAmount); @@ -1376,10 +1375,10 @@ contract SpokeRepayScenarioTest is SpokeBase { address caller, uint256 assets ) public { - uint256 MAX_BORROW_AMOUNT = MAX_SUPPLY_AMOUNT / 2; _assumeValidSupplier(caller); vm.assume(caller != derl); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); + uint256 MAX_BORROW_AMOUNT = _calculateMaxSupplyAmount(spoke1, reserveId) / 2; userBorrowing = bound(userBorrowing, 0, MAX_BORROW_AMOUNT - 2); // Allow some buffer from borrow cap skipTime = bound(skipTime, 0, MAX_SKIP_TIME).toUint40(); assets = bound(assets, 1, MAX_BORROW_AMOUNT - userBorrowing - 1); // Allow some buffer from borrow cap @@ -1399,7 +1398,7 @@ contract SpokeRepayScenarioTest is SpokeBase { IERC20 underlying = getAssetUnderlyingByReserveId(spoke1, reserveId); // Set up caller initial debt position - supplyAmount = MAX_SUPPLY_AMOUNT - supplyAmount; + supplyAmount = _calculateMaxSupplyAmount(spoke1, reserveId) - supplyAmount; deal(address(underlying), caller, supplyAmount); vm.prank(caller); underlying.approve(address(spoke1), supplyAmount); diff --git a/tests/unit/Spoke/Spoke.Repay.t.sol b/tests/unit/Spoke/Spoke.Repay.t.sol index d8b52b0cf..736d352f2 100644 --- a/tests/unit/Spoke/Spoke.Repay.t.sol +++ b/tests/unit/Spoke/Spoke.Repay.t.sol @@ -58,6 +58,47 @@ contract SpokeRepayTest is SpokeBase { vm.stopPrank(); } + function test_repay_revertsWith_ReentrancyGuardReentrantCall() public { + uint256 amount = 100e18; + + Utils.supplyCollateral({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: amount * 2, + onBehalfOf: bob + }); + + Utils.borrow({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: amount, + onBehalfOf: bob + }); + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke1), + ISpokeBase.repay.selector + ); + vm.mockFunction( + address(_hub(spoke1, _daiReserveId(spoke1))), + address(reentrantCaller), + abi.encodeCall( + IHubBase.restore, + ( + daiAssetId, + amount, + _getExpectedPremiumDeltaForRestore(spoke1, bob, _daiReserveId(spoke1), amount) + ) + ) + ); + + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + spoke1.repay(_daiReserveId(spoke1), amount, bob); + } + function test_repay() public { uint256 daiSupplyAmount = 100e18; uint256 wethSupplyAmount = 10e18; @@ -1394,10 +1435,10 @@ contract SpokeRepayTest is SpokeBase { RepayMultipleLocal memory usdxInfo; RepayMultipleLocal memory wbtcInfo; - daiInfo.borrowAmount = bound(daiBorrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); - wethInfo.borrowAmount = bound(wethBorrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); - usdxInfo.borrowAmount = bound(usdxBorrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); - wbtcInfo.borrowAmount = bound(wbtcBorrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); + daiInfo.borrowAmount = bound(daiBorrowAmount, 1, MAX_SUPPLY_AMOUNT_DAI / 2); + wethInfo.borrowAmount = bound(wethBorrowAmount, 1, MAX_SUPPLY_AMOUNT_WETH / 2); + usdxInfo.borrowAmount = bound(usdxBorrowAmount, 1, MAX_SUPPLY_AMOUNT_USDX / 2); + wbtcInfo.borrowAmount = bound(wbtcBorrowAmount, 1, MAX_SUPPLY_AMOUNT_WBTC / 2); repayPortion = bound(repayPortion, 0, PercentageMath.PERCENTAGE_FACTOR); skipTime = bound(skipTime, 1, MAX_SKIP_TIME).toUint40(); diff --git a/tests/unit/Spoke/Spoke.ReserveConfig.t.sol b/tests/unit/Spoke/Spoke.ReserveConfig.t.sol new file mode 100644 index 000000000..edcbf5847 --- /dev/null +++ b/tests/unit/Spoke/Spoke.ReserveConfig.t.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/Spoke/SpokeBase.t.sol'; + +contract SpokeReserveConfigTest is SpokeBase { + function setUp() public override { + super.setUp(); + _openSupplyPosition(spoke1, _daiReserveId(spoke1), 100e18); + } + + function test_supply_paused_frozen_scenarios() public { + uint256 daiReserveId = _daiReserveId(spoke1); + uint256 amount = 100e18; + + // paused / frozen; reverts + _updateReservePausedFlag(spoke1, daiReserveId, true); + _updateReserveFrozenFlag(spoke1, daiReserveId, true); + vm.expectRevert(ISpoke.ReservePaused.selector); + Utils.supply(spoke1, daiReserveId, bob, amount, bob); + + // not paused / frozen; reverts + _updateReservePausedFlag(spoke1, daiReserveId, false); + _updateReserveFrozenFlag(spoke1, daiReserveId, true); + vm.expectRevert(ISpoke.ReserveFrozen.selector); + Utils.supply(spoke1, daiReserveId, bob, amount, bob); + + // paused / not frozen; reverts + _updateReservePausedFlag(spoke1, daiReserveId, true); + _updateReserveFrozenFlag(spoke1, daiReserveId, false); + vm.expectRevert(ISpoke.ReservePaused.selector); + Utils.supply(spoke1, daiReserveId, bob, amount, bob); + + // not paused / not frozen; succeeds + _updateReservePausedFlag(spoke1, daiReserveId, false); + _updateReserveFrozenFlag(spoke1, daiReserveId, false); + deal(spoke1, daiReserveId, bob, amount); + Utils.approve(spoke1, daiReserveId, bob, amount); + Utils.supply(spoke1, daiReserveId, bob, amount, bob); + } + + function test_withdraw_paused_scenarios() public { + uint256 daiReserveId = _daiReserveId(spoke1); + uint256 supplyAmount = 100e18; + uint256 withdrawAmount = 1e18; + + // ensure user can withdraw + deal(spoke1, daiReserveId, bob, supplyAmount); + Utils.approve(spoke1, daiReserveId, bob, supplyAmount); + Utils.supplyCollateral(spoke1, daiReserveId, bob, supplyAmount, bob); + + // frozen does not matter + _updateReserveFrozenFlag(spoke1, daiReserveId, true); + + // paused; reverts + _updateReservePausedFlag(spoke1, daiReserveId, true); + vm.expectRevert(ISpoke.ReservePaused.selector); + Utils.withdraw(spoke1, daiReserveId, bob, withdrawAmount, bob); + + // unpaused; succeeds + _updateReservePausedFlag(spoke1, daiReserveId, false); + Utils.withdraw(spoke1, daiReserveId, bob, withdrawAmount, bob); + } + + function test_borrow_fuzz_borrowable_paused_frozen_scenarios( + bool borrowable, + bool paused, + bool frozen + ) public { + _increaseCollateralSupply(spoke1, _daiReserveId(spoke1), 100e18, bob); + uint256 daiReserveId = _daiReserveId(spoke1); + uint256 amount = 1; + + // paused / borrowable / frozen; reverts + _updateReservePausedFlag(spoke1, daiReserveId, paused); + _updateReserveBorrowableFlag(spoke1, daiReserveId, borrowable); + _updateReserveFrozenFlag(spoke1, daiReserveId, frozen); + if (paused) { + vm.expectRevert(ISpoke.ReservePaused.selector); + } else if (frozen) { + vm.expectRevert(ISpoke.ReserveFrozen.selector); + } else if (!borrowable) { + vm.expectRevert(ISpoke.ReserveNotBorrowable.selector); + } + Utils.borrow(spoke1, daiReserveId, bob, amount, bob); + } + + function test_repay_fuzz_paused_scenarios(bool frozen) public { + uint256 daiReserveId = _daiReserveId(spoke1); + + // create a simple debt position for bob + uint256 wethReserveId = _wethReserveId(spoke1); + uint256 wethCollateral = 10e18; + uint256 daiLiquidity = 1_000e18; + uint256 borrowAmount = 100e18; + + deal(spoke1, wethReserveId, bob, wethCollateral); + Utils.approve(spoke1, wethReserveId, bob, wethCollateral); + Utils.supplyCollateral(spoke1, wethReserveId, bob, wethCollateral, bob); + + deal(spoke1, daiReserveId, alice, daiLiquidity); + Utils.approve(spoke1, daiReserveId, alice, daiLiquidity); + Utils.supply(spoke1, daiReserveId, alice, daiLiquidity, alice); + + Utils.borrow(spoke1, daiReserveId, bob, borrowAmount, bob); + Utils.approve(spoke1, daiReserveId, bob, UINT256_MAX); + + _updateReserveFrozenFlag(spoke1, daiReserveId, frozen); + + // paused; reverts + _updateReservePausedFlag(spoke1, daiReserveId, true); + vm.expectRevert(ISpoke.ReservePaused.selector); + Utils.repay(spoke1, daiReserveId, bob, borrowAmount, bob); + + // unpaused; succeeds + _updateReservePausedFlag(spoke1, daiReserveId, false); + Utils.repay(spoke1, daiReserveId, bob, borrowAmount, bob); + } + + function test_setUsingAsCollateral_fuzz_paused_frozen_scenarios(bool frozen) public { + uint256 daiReserveId = _daiReserveId(spoke1); + + _updateReserveFrozenFlag(spoke1, daiReserveId, frozen); + + // paused; reverts + _updateReservePausedFlag(spoke1, daiReserveId, true); + vm.expectRevert(ISpoke.ReservePaused.selector); + Utils.setUsingAsCollateral(spoke1, daiReserveId, alice, true, alice); + + _updateReserveFrozenFlag(spoke1, daiReserveId, false); + _updateReservePausedFlag(spoke1, daiReserveId, false); + + // alice enables collateral + Utils.setUsingAsCollateral(spoke1, daiReserveId, alice, true, alice); + assertTrue(_isUsingAsCollateral(spoke1, daiReserveId, alice), 'alice using as collateral'); + + // frozen: disallow when enabling, allow when disabling + _updateReserveFrozenFlag(spoke1, daiReserveId, true); + vm.expectRevert(ISpoke.ReserveFrozen.selector); + Utils.setUsingAsCollateral(spoke1, daiReserveId, bob, true, bob); + + Utils.setUsingAsCollateral(spoke1, daiReserveId, alice, false, alice); + assertFalse(_isUsingAsCollateral(spoke1, daiReserveId, alice)); + } +} diff --git a/tests/unit/Spoke/Spoke.RiskPremium.EdgeCases.t.sol b/tests/unit/Spoke/Spoke.RiskPremium.EdgeCases.t.sol index 74fd99363..b2aef95ae 100644 --- a/tests/unit/Spoke/Spoke.RiskPremium.EdgeCases.t.sol +++ b/tests/unit/Spoke/Spoke.RiskPremium.EdgeCases.t.sol @@ -343,8 +343,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Bob's current risk premium should be greater than or equal collateral risk of dai, since debt is not fully covered by it (and due to rounding) assertGt( - _getValue(spoke2, _wethReserveId(spoke2), wethBorrowAmount), - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), wethBorrowAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), 'Weth borrow amount greater than dai supply amount' ); assertGe( @@ -364,8 +364,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { uint256 daiSupplied = spoke2.getUserSuppliedAssets(_daiReserveId(spoke2), bob); uint256 bobWethDebt = spoke2.getUserTotalDebt(_wethReserveId(spoke2), bob); assertGt( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplied), - _getValue(spoke2, _wethReserveId(spoke2), bobWethDebt), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplied), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), bobWethDebt), 'Bob dai collateral exceeds weth debt after interest accrual' ); @@ -455,8 +455,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Bob's current risk premium should be equal to collateral risk of dai, since debt is fully covered by it assertEq( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), - _getValue(spoke2, _wethReserveId(spoke2), borrowAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), borrowAmount), 'Bob dai collateral equals weth debt' ); assertEq( @@ -475,8 +475,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Ensure debt has grown beyond dai collateral uint256 bobDebt = spoke2.getUserTotalDebt(_wethReserveId(spoke2), bob); assertGt( - _getValue(spoke2, _wethReserveId(spoke2), bobDebt), - _getValue( + _convertAmountToValue(spoke2, _wethReserveId(spoke2), bobDebt), + _convertAmountToValue( spoke2, _daiReserveId(spoke2), spoke2.getUserSuppliedAssets(_daiReserveId(spoke2), bob) @@ -562,8 +562,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Bob's current risk premium should be equal to collateral risk of dai, since debt is fully covered by it assertEq( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), - _getValue(spoke2, _wethReserveId(spoke2), wethBorrowAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), wethBorrowAmount), 'Bob weth collateral equals dai debt' ); assertEq( @@ -602,8 +602,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Ensure Bob's weth debt has grown beyond dai collateral uint256 bobDebt = spoke2.getUserTotalDebt(_wethReserveId(spoke2), bob); assertGt( - _getValue(spoke2, _wethReserveId(spoke2), bobDebt), - _getValue(spoke2, _daiReserveId(spoke2), bobDaiCollateral), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), bobDebt), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), bobDaiCollateral), 'Bob weth debt exceeds dai collateral after 1 year' ); @@ -670,8 +670,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Bob's current risk premium should be equal to collateral risk of dai, since debt is fully covered by it assertEq( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), - _getValue(spoke2, _daiReserveId(spoke2), initialBorrowAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), initialBorrowAmount), 'Bob dai collateral equals dai debt' ); assertEq( @@ -699,8 +699,12 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Now dai collateral is insufficient to cover the debt assertLt( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), - _getValue(spoke2, _daiReserveId(spoke2), spoke2.getUserTotalDebt(_daiReserveId(spoke2), bob)), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue( + spoke2, + _daiReserveId(spoke2), + spoke2.getUserTotalDebt(_daiReserveId(spoke2), bob) + ), 'Bob wbtc collateral less than dai debt' ); @@ -766,8 +770,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Bob's current risk premium should be equal to collateral risk of weth, since debt is fully covered by it assertGt( - _getValue(spoke1, _wethReserveId(spoke1), wethSupplyAmount), - _getValue(spoke1, _daiReserveId(spoke1), borrowAmount), + _convertAmountToValue(spoke1, _wethReserveId(spoke1), wethSupplyAmount), + _convertAmountToValue(spoke1, _daiReserveId(spoke1), borrowAmount), 'Bob weth collateral enough to cover dai debt' ); assertEq( @@ -851,8 +855,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Bob's current risk premium should be greater than or equal to collateral risk of dai, since debt is not fully covered by it (and due to rounding) assertLt( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), - _getValue(spoke2, _wethReserveId(spoke2), borrowAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), borrowAmount), 'Bob dai collateral less than weth debt' ); assertGe( @@ -871,8 +875,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Now risk premium should equal collateral risk of dai since debt is fully covered by it assertGe( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), - _getValue(spoke2, _wethReserveId(spoke2), borrowAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), borrowAmount), 'Bob dai collateral greater than weth debt' ); assertEq( diff --git a/tests/unit/Spoke/Spoke.RiskPremium.Scenario.t.sol b/tests/unit/Spoke/Spoke.RiskPremium.Scenario.t.sol index 5c3fccd77..cbb586340 100644 --- a/tests/unit/Spoke/Spoke.RiskPremium.Scenario.t.sol +++ b/tests/unit/Spoke/Spoke.RiskPremium.Scenario.t.sol @@ -8,7 +8,7 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { using SharesMath for uint256; using WadRayMath for uint256; using PercentageMath for *; - using SafeCast for uint256; + using SafeCast for *; struct GeneralLocalVars { uint256 usdxSupplyAmount; @@ -148,7 +148,7 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { uint256 accruedDaiDebt = vars.daiBorrowAmount.rayMulUp( MathUtils.calculateLinearInterest( - hub1.getAssetDrawnRate(daiAssetId).toUint96(), + hub1.getAsset(daiAssetId).drawnRate.toUint96(), vars.lastUpdateTimestamp ) - WadRayMath.RAY ); @@ -160,8 +160,16 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { // Now since debt has grown, weth supply is not enough to cover debt, hence rp changes // usdx is enough to cover remaining debt - uint256 daiDebtValue = _getDebtValue(spoke1, reservesIds.dai, accruedDaiDebt + daiPremiumDebt); - uint256 usdxSupplyValue = _getValue(spoke1, reservesIds.usdx, vars.usdxSupplyAmount); + uint256 daiDebtValue = _convertAmountToValue( + spoke1, + reservesIds.dai, + accruedDaiDebt + daiPremiumDebt + ); + uint256 usdxSupplyValue = _convertAmountToValue( + spoke1, + reservesIds.usdx, + vars.usdxSupplyAmount + ); assertLt(daiDebtValue, usdxSupplyValue); vars.expectedUserRiskPremium = _calculateExpectedUserRP(spoke1, alice); @@ -270,7 +278,7 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { Rates memory rates; // Get the base rate of dai - rates.baseRateDai = hub1.getAssetDrawnRate(daiAssetId).toUint96(); + rates.baseRateDai = hub1.getAsset(daiAssetId).drawnRate.toUint96(); // Check Bob's starting dai debt (debtChecks.actualDrawnDebt, debtChecks.actualPremium) = spoke1.getUserDebt( @@ -283,7 +291,7 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { assertEq(debtChecks.actualPremium, 0, 'Bob dai premium before'); // Get the base rate of usdx - rates.baseRateUsdx = hub1.getAssetDrawnRate(usdxAssetId).toUint96(); + rates.baseRateUsdx = hub1.getAsset(usdxAssetId).drawnRate.toUint96(); // Check Bob's starting usdx debt (debtChecks.actualDrawnDebt, debtChecks.actualPremium) = spoke1.getUserDebt( @@ -571,10 +579,10 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { uint16 usdxCollateralRisk, uint40[3] memory timeSkip ) public { - bobDaiAction = _boundUserBorrowAction(bobDaiAction); - bobUsdxAction = _boundUserBorrowAction(bobUsdxAction); - aliceDaiAction = _boundUserBorrowAction(aliceDaiAction); - aliceUsdxAction = _boundUserBorrowAction(aliceUsdxAction); + bobDaiAction = _boundUserBorrowAction(bobDaiAction, MAX_SUPPLY_AMOUNT_DAI); + bobUsdxAction = _boundUserBorrowAction(bobUsdxAction, MAX_SUPPLY_AMOUNT_USDX); + aliceDaiAction = _boundUserBorrowAction(aliceDaiAction, MAX_SUPPLY_AMOUNT_DAI); + aliceUsdxAction = _boundUserBorrowAction(aliceUsdxAction, MAX_SUPPLY_AMOUNT_USDX); daiCollateralRisk = bound(daiCollateralRisk, 0, MAX_COLLATERAL_RISK_BPS).toUint16(); usdxCollateralRisk = bound(usdxCollateralRisk, 0, MAX_COLLATERAL_RISK_BPS).toUint16(); @@ -751,7 +759,7 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { // Bob's dai debt after 1 year if (bobDaiInfo.borrowAmount > 0) { bobDaiInfo.drawnDebt = MathUtils - .calculateLinearInterest(hub1.getAssetDrawnRate(daiAssetId).toUint96(), startTime) + .calculateLinearInterest(hub1.getAsset(daiAssetId).drawnRate.toUint96(), startTime) .rayMulUp(bobDaiInfo.borrowAmount); (debtChecks.actualDrawnDebt, ) = spoke1.getUserDebt(_daiReserveId(spoke1), bob); @@ -761,7 +769,7 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { // Bob's usdx debt after 1 year if (bobUsdxInfo.borrowAmount > 0) { bobUsdxInfo.drawnDebt = MathUtils - .calculateLinearInterest(hub1.getAssetDrawnRate(usdxAssetId).toUint96(), startTime) + .calculateLinearInterest(hub1.getAsset(usdxAssetId).drawnRate.toUint96(), startTime) .rayMulUp(bobUsdxInfo.borrowAmount); (debtChecks.actualDrawnDebt, ) = spoke1.getUserDebt(_usdxReserveId(spoke1), bob); @@ -771,7 +779,7 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { // Alice's dai debt after 1 year if (aliceDaiInfo.borrowAmount > 0) { aliceDaiInfo.drawnDebt = MathUtils - .calculateLinearInterest(hub1.getAssetDrawnRate(daiAssetId).toUint96(), startTime) + .calculateLinearInterest(hub1.getAsset(daiAssetId).drawnRate.toUint96(), startTime) .rayMulUp(aliceDaiInfo.borrowAmount); (debtChecks.actualDrawnDebt, ) = spoke1.getUserDebt(_daiReserveId(spoke1), alice); @@ -781,7 +789,7 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { // Alice's usdx debt after 1 year if (aliceUsdxInfo.borrowAmount > 0) { aliceUsdxInfo.drawnDebt = MathUtils - .calculateLinearInterest(hub1.getAssetDrawnRate(usdxAssetId).toUint96(), startTime) + .calculateLinearInterest(hub1.getAsset(usdxAssetId).drawnRate.toUint96(), startTime) .rayMulUp(aliceUsdxInfo.borrowAmount); (debtChecks.actualDrawnDebt, debtChecks.actualPremium) = spoke1.getUserDebt( @@ -834,7 +842,7 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { if ( aliceUsdxInfo.borrowAmount > 2 && spoke1.getUserSuppliedAssets(_usdxReserveId(spoke1), alice) > - spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice) * 3 && + spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice) * 3 && _getUserHealthFactor(spoke1, alice) > WadRayMath.WAD ) { // Store Bob old premium drawn shares before Alice borrow @@ -904,10 +912,10 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { ) public { skipTime = bound(skipTime, 1, MAX_SKIP_TIME).toUint40(); - daiAmounts.supplyAmount = bound(daiAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT); - wethAmounts.supplyAmount = bound(wethAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxAmounts.supplyAmount = bound(usdxAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT); - wbtcAmounts.supplyAmount = bound(wbtcAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT); + daiAmounts.supplyAmount = bound(daiAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI); + wethAmounts.supplyAmount = bound(wethAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH); + usdxAmounts.supplyAmount = bound(usdxAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX); + wbtcAmounts.supplyAmount = bound(wbtcAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT_WBTC); daiAmounts.borrowAmount = bound(daiAmounts.borrowAmount, 0, daiAmounts.supplyAmount / 2); wethAmounts.borrowAmount = bound(wethAmounts.borrowAmount, 0, wethAmounts.supplyAmount / 2); @@ -916,15 +924,15 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { // Ensure supplied value is at least double borrowed value to pass hf checks vm.assume( - _getValue(spoke1, _daiReserveId(spoke1), daiAmounts.supplyAmount) + - _getValue(spoke1, _wethReserveId(spoke1), wethAmounts.supplyAmount) + - _getValue(spoke1, _usdxReserveId(spoke1), usdxAmounts.supplyAmount) + - _getValue(spoke1, _wbtcReserveId(spoke1), wbtcAmounts.supplyAmount) >= + _convertAmountToValue(spoke1, _daiReserveId(spoke1), daiAmounts.supplyAmount) + + _convertAmountToValue(spoke1, _wethReserveId(spoke1), wethAmounts.supplyAmount) + + _convertAmountToValue(spoke1, _usdxReserveId(spoke1), usdxAmounts.supplyAmount) + + _convertAmountToValue(spoke1, _wbtcReserveId(spoke1), wbtcAmounts.supplyAmount) >= 2 * - (_getValue(spoke1, _daiReserveId(spoke1), daiAmounts.borrowAmount) + - _getValue(spoke1, _wethReserveId(spoke1), wethAmounts.borrowAmount) + - _getValue(spoke1, _usdxReserveId(spoke1), usdxAmounts.borrowAmount) + - _getValue(spoke1, _wbtcReserveId(spoke1), wbtcAmounts.borrowAmount)) + (_convertAmountToValue(spoke1, _daiReserveId(spoke1), daiAmounts.borrowAmount) + + _convertAmountToValue(spoke1, _wethReserveId(spoke1), wethAmounts.borrowAmount) + + _convertAmountToValue(spoke1, _usdxReserveId(spoke1), usdxAmounts.borrowAmount) + + _convertAmountToValue(spoke1, _wbtcReserveId(spoke1), wbtcAmounts.borrowAmount)) ); // Bob supplies and draws all assets on spoke1 @@ -973,9 +981,10 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { } function _boundUserBorrowAction( - UserBorrowAction memory action + UserBorrowAction memory action, + uint256 maxSupplyAmount ) internal pure returns (UserBorrowAction memory) { - action.supplyAmount = bound(action.supplyAmount, 2, MAX_SUPPLY_AMOUNT / 2); + action.supplyAmount = bound(action.supplyAmount, 2, maxSupplyAmount / 2); action.borrowAmount = bound(action.borrowAmount, 1, action.supplyAmount / 2); return action; } diff --git a/tests/unit/Spoke/Spoke.RiskPremium.t.sol b/tests/unit/Spoke/Spoke.RiskPremium.t.sol index b0f84feb7..abc84ce55 100644 --- a/tests/unit/Spoke/Spoke.RiskPremium.t.sol +++ b/tests/unit/Spoke/Spoke.RiskPremium.t.sol @@ -86,7 +86,7 @@ contract SpokeRiskPremiumTest is SpokeBase { function test_getUserRiskPremium_fuzz_single_reserve_collateral_borrowed_amount( uint256 borrowAmount ) public { - borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); + borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT_DAI / 2); ReserveInfoLocal memory daiInfo; daiInfo.reserveId = _daiReserveId(spoke1); @@ -111,8 +111,8 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 borrowAmount, uint256 additionalSupplyAmount ) public { - borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); - additionalSupplyAmount = bound(additionalSupplyAmount, 1, MAX_SUPPLY_AMOUNT); + borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT_DAI / 2); + additionalSupplyAmount = bound(additionalSupplyAmount, 1, MAX_SUPPLY_AMOUNT_USDX); ReserveInfoLocal memory daiInfo; ReserveInfoLocal memory usdxInfo; @@ -172,11 +172,15 @@ contract SpokeRiskPremiumTest is SpokeBase { _mockReservePrice(spoke2, _usdzReserveId(spoke2), 100000e8); // Check that debt has outgrown collateral - uint256 collateralValue = _getValue(spoke2, _wbtcReserveId(spoke2), wbtcSupplyAmount) + - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount) + - _getValue(spoke2, _usdxReserveId(spoke2), usdxSupplyAmount) + - _getValue(spoke2, _wethReserveId(spoke2), wethSupplyAmount); - uint256 debtValue = _getValue(spoke2, _usdzReserveId(spoke2), borrowAmount); + uint256 collateralValue = _convertAmountToValue( + spoke2, + _wbtcReserveId(spoke2), + wbtcSupplyAmount + ) + + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount) + + _convertAmountToValue(spoke2, _usdxReserveId(spoke2), usdxSupplyAmount) + + _convertAmountToValue(spoke2, _wethReserveId(spoke2), wethSupplyAmount); + uint256 debtValue = _convertAmountToValue(spoke2, _usdzReserveId(spoke2), borrowAmount); assertGt(debtValue, collateralValue, 'debt outgrows collateral'); assertFalse(_isHealthy(spoke2, bob)); @@ -252,9 +256,9 @@ contract SpokeRiskPremiumTest is SpokeBase { // Weth is enough to cover the total debt assertGe( - _getValue(spoke1, wethInfo.reserveId, wethInfo.supplyAmount), - _getValue(spoke1, daiInfo.reserveId, daiInfo.borrowAmount) + - _getValue(spoke1, usdxInfo.reserveId, usdxInfo.borrowAmount), + _convertAmountToValue(spoke1, wethInfo.reserveId, wethInfo.supplyAmount), + _convertAmountToValue(spoke1, daiInfo.reserveId, daiInfo.borrowAmount) + + _convertAmountToValue(spoke1, usdxInfo.reserveId, usdxInfo.borrowAmount), 'weth supply covers debt' ); uint256 expectedUserRiskPremium = wethInfo.collateralRisk; @@ -300,8 +304,8 @@ contract SpokeRiskPremiumTest is SpokeBase { // usdz is enough to cover the total debt assertGe( - _getValue(spoke2, usdzInfo.reserveId, usdzInfo.supplyAmount), - _getValue(spoke2, daiInfo.reserveId, daiInfo.borrowAmount), + _convertAmountToValue(spoke2, usdzInfo.reserveId, usdzInfo.supplyAmount), + _convertAmountToValue(spoke2, daiInfo.reserveId, daiInfo.borrowAmount), 'usdz supply covers debt' ); @@ -402,9 +406,9 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 usdxSupplyAmount, uint256 wethBorrowAmount ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; - daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); + uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT_WETH / 2; + daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI); + usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX); wethBorrowAmount = bound(wethBorrowAmount, 0, totalBorrowAmount); @@ -418,7 +422,7 @@ contract SpokeRiskPremiumTest is SpokeBase { daiInfo.supplyAmount = daiSupplyAmount; usdxInfo.supplyAmount = usdxSupplyAmount; - wethInfo.supplyAmount = MAX_SUPPLY_AMOUNT; + wethInfo.supplyAmount = MAX_SUPPLY_AMOUNT_WETH; // Borrow all value in weth wethInfo.borrowAmount = wethBorrowAmount; @@ -460,11 +464,10 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 wethSupplyAmount, uint256 wbtcBorrowAmount ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; - daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wbtcBorrowAmount = bound(wbtcBorrowAmount, 0, totalBorrowAmount); + daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 2); + wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 2); + usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 2); + wbtcBorrowAmount = bound(wbtcBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WBTC / 2); ReserveInfoLocal memory daiInfo; ReserveInfoLocal memory wethInfo; @@ -479,7 +482,7 @@ contract SpokeRiskPremiumTest is SpokeBase { daiInfo.supplyAmount = daiSupplyAmount; wethInfo.supplyAmount = wethSupplyAmount; usdxInfo.supplyAmount = usdxSupplyAmount; - wbtcInfo.supplyAmount = MAX_SUPPLY_AMOUNT; + wbtcInfo.supplyAmount = MAX_SUPPLY_AMOUNT_WBTC; wbtcInfo.borrowAmount = wbtcBorrowAmount; @@ -526,14 +529,10 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 wbtcSupplyAmount, uint256 borrowAmount ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; - - daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wbtcSupplyAmount = bound(wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - - borrowAmount = bound(borrowAmount, 0, totalBorrowAmount); + daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 2); + wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 2); + usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 2); + wbtcSupplyAmount = bound(wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WBTC / 2); ReserveInfoLocal memory daiInfo; ReserveInfoLocal memory usdxInfo; @@ -553,7 +552,7 @@ contract SpokeRiskPremiumTest is SpokeBase { wbtcInfo.supplyAmount = wbtcSupplyAmount; // Borrow all value in usdz - usdzInfo.borrowAmount = borrowAmount; + usdzInfo.borrowAmount = bound(borrowAmount, 0, MAX_SUPPLY_AMOUNT_WBTC / 2); daiInfo.collateralRisk = _getCollateralRisk(spoke2, daiInfo.reserveId); wethInfo.collateralRisk = _getCollateralRisk(spoke2, wethInfo.reserveId); @@ -561,7 +560,7 @@ contract SpokeRiskPremiumTest is SpokeBase { wbtcInfo.collateralRisk = _getCollateralRisk(spoke2, wbtcInfo.reserveId); // Handle supplying max of both dai and usdz - deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT * 2); + deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT_DAI * 2); // Bob supply wbtc into spoke2 if (wbtcInfo.supplyAmount > 0) { @@ -584,7 +583,7 @@ contract SpokeRiskPremiumTest is SpokeBase { } // Bob supply usdz into spoke2 - Utils.supplyCollateral(spoke2, usdzInfo.reserveId, bob, MAX_SUPPLY_AMOUNT, bob); + Utils.supplyCollateral(spoke2, usdzInfo.reserveId, bob, MAX_SUPPLY_AMOUNT_USDZ, bob); // Bob draw usdz if (usdzInfo.borrowAmount > 0) { @@ -608,7 +607,7 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 borrowAmount, uint256 newUsdxPrice ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; + uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT_USDZ / 2; newUsdxPrice = bound(newUsdxPrice, 1, 1e16); @@ -635,7 +634,7 @@ contract SpokeRiskPremiumTest is SpokeBase { wethInfo.supplyAmount = wethSupplyAmount; usdxInfo.supplyAmount = usdxSupplyAmount; wbtcInfo.supplyAmount = wbtcSupplyAmount; - usdzInfo.supplyAmount = MAX_SUPPLY_AMOUNT; + usdzInfo.supplyAmount = MAX_SUPPLY_AMOUNT_USDZ; // Borrow all value in usdz usdzInfo.borrowAmount = borrowAmount; @@ -647,7 +646,7 @@ contract SpokeRiskPremiumTest is SpokeBase { usdzInfo.collateralRisk = _getCollateralRisk(spoke2, usdzInfo.reserveId); // Handle supplying max of both dai and usdz - deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT * 2); + deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT_DAI * 2); // Bob supply wbtc into spoke2 if (wbtcInfo.supplyAmount > 0) { @@ -703,15 +702,15 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 borrowAmount, uint24 newCrValue ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; + uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT_USDZ / 2; // Bound collateral risk to below usdz so reserve is still used in rp calc newCrValue = bound(newCrValue, 0, 99_99).toUint24(); - daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wbtcSupplyAmount = bound(wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT); + daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI); + wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH); + usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX); + wbtcSupplyAmount = bound(wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WBTC); borrowAmount = bound(borrowAmount, 0, totalBorrowAmount); @@ -731,7 +730,7 @@ contract SpokeRiskPremiumTest is SpokeBase { wethInfo.supplyAmount = wethSupplyAmount; usdxInfo.supplyAmount = usdxSupplyAmount; wbtcInfo.supplyAmount = wbtcSupplyAmount; - usdzInfo.supplyAmount = MAX_SUPPLY_AMOUNT; + usdzInfo.supplyAmount = MAX_SUPPLY_AMOUNT_USDZ; // Borrow all value in usdz usdzInfo.borrowAmount = borrowAmount; @@ -743,7 +742,7 @@ contract SpokeRiskPremiumTest is SpokeBase { usdzInfo.collateralRisk = _getCollateralRisk(spoke2, usdzInfo.reserveId); // Handle supplying max of both dai and usdz - deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT * 2); + deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT_DAI * 2); // Bob supply wbtc into spoke2 if (wbtcInfo.supplyAmount > 0) { @@ -894,12 +893,9 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 usdxSupplyAmount, uint256 borrowAmount ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; - daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - - borrowAmount = bound(borrowAmount, 0, totalBorrowAmount); + daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 2); + wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 2); + usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 2); ReserveInfoLocal memory daiInfo; ReserveInfoLocal memory wethInfo; @@ -914,9 +910,9 @@ contract SpokeRiskPremiumTest is SpokeBase { daiInfo.supplyAmount = daiSupplyAmount; wethInfo.supplyAmount = wethSupplyAmount; usdxInfo.supplyAmount = usdxSupplyAmount; - wbtcInfo.supplyAmount = MAX_SUPPLY_AMOUNT; + wbtcInfo.supplyAmount = MAX_SUPPLY_AMOUNT_WBTC; - wbtcInfo.borrowAmount = borrowAmount; + wbtcInfo.borrowAmount = bound(borrowAmount, 0, MAX_SUPPLY_AMOUNT_WBTC / 2); daiInfo.collateralRisk = _getCollateralRisk(spoke3, daiInfo.reserveId); wethInfo.collateralRisk = _getCollateralRisk(spoke3, wethInfo.reserveId); @@ -1002,13 +998,12 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 wbtcBorrowamount, uint256 wethBorrowAmount ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; - daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); + daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 2); + wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 2); + usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 2); - wbtcBorrowamount = bound(wbtcBorrowamount, 0, totalBorrowAmount); - wethBorrowAmount = bound(wethBorrowAmount, 0, totalBorrowAmount); + wbtcBorrowamount = bound(wbtcBorrowamount, 0, MAX_SUPPLY_AMOUNT_WBTC / 2); + wethBorrowAmount = bound(wethBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 2); ReserveInfoLocal memory daiInfo; ReserveInfoLocal memory wethInfo; @@ -1023,7 +1018,7 @@ contract SpokeRiskPremiumTest is SpokeBase { daiInfo.supplyAmount = daiSupplyAmount; wethInfo.supplyAmount = wethSupplyAmount; usdxInfo.supplyAmount = usdxSupplyAmount; - wbtcInfo.supplyAmount = MAX_SUPPLY_AMOUNT; + wbtcInfo.supplyAmount = MAX_SUPPLY_AMOUNT_WBTC; wbtcInfo.borrowAmount = wbtcBorrowamount; wethInfo.borrowAmount = wethBorrowAmount; @@ -1051,8 +1046,12 @@ contract SpokeRiskPremiumTest is SpokeBase { Utils.supplyCollateral(spoke3, wbtcInfo.reserveId, bob, wbtcInfo.supplyAmount, bob); // Alice supply remaining weth into spoke3 - if (MAX_SUPPLY_AMOUNT - wethInfo.supplyAmount > 0) { - _openSupplyPosition(spoke3, wethInfo.reserveId, MAX_SUPPLY_AMOUNT - wethInfo.supplyAmount); + if (MAX_SUPPLY_AMOUNT_WETH - wethInfo.supplyAmount > 0) { + _openSupplyPosition( + spoke3, + wethInfo.reserveId, + MAX_SUPPLY_AMOUNT_WETH - wethInfo.supplyAmount + ); } // Bob draw wbtc diff --git a/tests/unit/Spoke/Spoke.SetUserPositionManagerWithSig.t.sol b/tests/unit/Spoke/Spoke.SetUserPositionManagerWithSig.t.sol index 3ff896f34..b01bb242a 100644 --- a/tests/unit/Spoke/Spoke.SetUserPositionManagerWithSig.t.sol +++ b/tests/unit/Spoke/Spoke.SetUserPositionManagerWithSig.t.sol @@ -3,10 +3,13 @@ pragma solidity ^0.8.0; import 'tests/unit/Spoke/SpokeBase.t.sol'; +import {EIP712Hash} from 'src/spoke/libraries/EIP712Hash.sol'; -contract SpokeSetUserPositionManagerWithSigTest is SpokeBase { +contract SpokeSetUserPositionManagersWithSigTest is SpokeBase { using SafeCast for *; + mapping(address positionManager => bool approve) internal _lookup; + function setUp() public override { super.setUp(); vm.prank(SPOKE_ADMIN); @@ -66,68 +69,65 @@ contract SpokeSetUserPositionManagerWithSigTest is SpokeBase { assertEq(spoke.DOMAIN_SEPARATOR(), expectedDomainSeparator); } - function test_setUserPositionManager_typeHash() public pure { + function test_setUserPositionManager_typeHash() public view { assertEq( - Constants.SET_USER_POSITION_MANAGER_TYPEHASH, - vm.eip712HashType('SetUserPositionManager') + EIP712Hash.SET_USER_POSITION_MANAGERS_TYPEHASH, + vm.eip712HashType('SetUserPositionManagers') ); assertEq( - Constants.SET_USER_POSITION_MANAGER_TYPEHASH, + EIP712Hash.SET_USER_POSITION_MANAGERS_TYPEHASH, keccak256( - 'SetUserPositionManager(address positionManager,address user,bool approve,uint256 nonce,uint256 deadline)' + 'SetUserPositionManagers(address onBehalfOf,PositionManagerUpdate[] updates,uint256 nonce,uint256 deadline)PositionManagerUpdate(address positionManager,bool approve)' ) ); + assertEq( + EIP712Hash.SET_USER_POSITION_MANAGERS_TYPEHASH, + spoke1.SET_USER_POSITION_MANAGERS_TYPEHASH() + ); + } + + function test_positionManagerUpdate_typeHash() public pure { + assertEq( + EIP712Hash.POSITION_MANAGER_UPDATE, + keccak256('PositionManagerUpdate(address positionManager,bool approve)') + ); + assertEq(EIP712Hash.POSITION_MANAGER_UPDATE, vm.eip712HashType('PositionManagerUpdate')); } - function test_setUserPositionManagerWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() + function test_setUserPositionManagersWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { - (, uint256 alicePk) = makeAddrAndKey('alice'); uint256 deadline = _warpAfterRandomDeadline(); - EIP712Types.SetUserPositionManager memory params = _setUserPositionManagerData(alice, deadline); + ISpoke.SetUserPositionManagers memory params = _setUserPositionManagerData(alice, deadline); bytes32 digest = _getTypedDataHash(spoke1, params); (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, digest); bytes memory signature = abi.encodePacked(r, s, v); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); - spoke1.setUserPositionManagerWithSig( - params.positionManager, - params.user, - params.approve, - params.nonce, - params.deadline, - signature - ); + spoke1.setUserPositionManagersWithSig(params, signature); } - function test_setUserPositionManagerWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() + function test_setUserPositionManagersWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); vm.assume(randomUser != alice); uint256 deadline = _warpAfterRandomDeadline(); - EIP712Types.SetUserPositionManager memory params = _setUserPositionManagerData(alice, deadline); + ISpoke.SetUserPositionManagers memory params = _setUserPositionManagerData(alice, deadline); bytes32 digest = _getTypedDataHash(spoke1, params); (uint8 v, bytes32 r, bytes32 s) = vm.sign(randomUserPk, digest); bytes memory signature = abi.encodePacked(r, s, v); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); - spoke1.setUserPositionManagerWithSig( - params.positionManager, - params.user, - params.approve, - params.nonce, - params.deadline, - signature - ); + spoke1.setUserPositionManagersWithSig(params, signature); } - function test_setUserPositionManagerWithSig_revertsWith_InvalidAccountNonce(bytes32) public { + function test_setUserPositionManagersWithSig_revertsWith_InvalidAccountNonce(bytes32) public { (address user, uint256 userPk) = makeAddrAndKey(string(vm.randomBytes(32))); vm.label(user, 'user'); address positionManager = vm.randomAddress(); @@ -136,62 +136,116 @@ contract SpokeSetUserPositionManagerWithSigTest is SpokeBase { uint256 deadline = _warpBeforeRandomDeadline(); uint192 nonceKey = _randomNonceKey(); - EIP712Types.SetUserPositionManager memory params = _setUserPositionManagerData(user, deadline); - uint256 currentNonce = _burnRandomNoncesAtKey(spoke1, params.user, nonceKey); - params.nonce = _getRandomInvalidNonceAtKey(spoke1, params.user, nonceKey); + ISpoke.SetUserPositionManagers memory params = _setUserPositionManagerData(user, deadline); + uint256 currentNonce = _burnRandomNoncesAtKey(spoke1, params.onBehalfOf, nonceKey); + params.nonce = _getRandomInvalidNonceAtKey(spoke1, params.onBehalfOf, nonceKey); bytes32 digest = _getTypedDataHash(spoke1, params); (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPk, digest); bytes memory signature = abi.encodePacked(r, s, v); vm.expectRevert( - abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, params.user, currentNonce) + abi.encodeWithSelector( + INoncesKeyed.InvalidAccountNonce.selector, + params.onBehalfOf, + currentNonce + ) ); vm.prank(vm.randomAddress()); - spoke1.setUserPositionManagerWithSig( - params.positionManager, - params.user, - params.approve, - params.nonce, - params.deadline, - signature - ); + spoke1.setUserPositionManagersWithSig(params, signature); } - function test_setUserPositionManagerWithSig() public { + function test_setUserPositionManagersWithSig() public { (address user, uint256 userPk) = makeAddrAndKey(string(vm.randomBytes(32))); vm.label(user, 'user'); uint256 deadline = _warpBeforeRandomDeadline(); - EIP712Types.SetUserPositionManager memory params = _setUserPositionManagerData(user, deadline); - params.nonce = _burnRandomNoncesAtKey(spoke1, params.user); + ISpoke.SetUserPositionManagers memory params = _setUserPositionManagerData(user, deadline); + params.nonce = _burnRandomNoncesAtKey(spoke1, params.onBehalfOf); bytes32 digest = _getTypedDataHash(spoke1, params); (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPk, digest); bytes memory signature = abi.encodePacked(r, s, v); vm.expectEmit(address(spoke1)); - emit ISpoke.SetUserPositionManager(params.user, params.positionManager, params.approve); + emit ISpoke.SetUserPositionManager( + params.onBehalfOf, + params.updates[0].positionManager, + params.updates[0].approve + ); vm.prank(vm.randomAddress()); - spoke1.setUserPositionManagerWithSig( - params.positionManager, - params.user, - params.approve, - params.nonce, - params.deadline, - signature + spoke1.setUserPositionManagersWithSig(params, signature); + + _assertNonceIncrement(spoke1, params.onBehalfOf, params.nonce); + assertEq( + spoke1.isPositionManager(params.onBehalfOf, params.updates[0].positionManager), + params.updates[0].approve ); + } + + function test_setUserPositionManagersWithSig_zero_updates() public { + (address user, uint256 userPk) = makeAddrAndKey(string(vm.randomBytes(32))); + vm.label(user, 'user'); + uint256 deadline = _warpBeforeRandomDeadline(); + ISpoke.SetUserPositionManagers memory params = _setUserPositionManagerData(user, deadline); + params.updates = new ISpoke.PositionManagerUpdate[](0); + params.nonce = _burnRandomNoncesAtKey(spoke1, params.onBehalfOf); + + bytes32 digest = _getTypedDataHash(spoke1, params); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.recordLogs(); + + vm.prank(vm.randomAddress()); + spoke1.setUserPositionManagersWithSig(params, signature); - _assertNonceIncrement(spoke1, params.user, params.nonce); - assertEq(spoke1.isPositionManager(params.user, params.positionManager), params.approve); + assertEq(vm.getRecordedLogs().length, 0); + _assertNonceIncrement(spoke1, params.onBehalfOf, params.nonce); } - function test_setUserPositionManagerWithSig_ERC1271_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() + function test_setUserPositionManagersWithSig_multiple_updates( + ISpoke.PositionManagerUpdate[] memory updates + ) public { + vm.assume(updates.length < 1024); // for performance + vm.setArbitraryStorage(address(spoke1)); // arbitrary nonce, position manager active state + (address user, uint256 userPk) = makeAddrAndKey(string(vm.randomBytes(32))); + vm.label(user, 'user'); + uint256 deadline = _warpBeforeRandomDeadline(); + ISpoke.SetUserPositionManagers memory params = _setUserPositionManagerData(user, deadline); + params.updates = updates; + + bytes32 digest = _getTypedDataHash(spoke1, params); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + for (uint256 i; i < updates.length; ++i) { + address positionManager = params.updates[i].positionManager; + bool approve = params.updates[i].approve; + vm.expectEmit(address(spoke1)); + emit ISpoke.SetUserPositionManager(params.onBehalfOf, positionManager, approve); + // overwrite cached lookup such that latest state is checked for duplicated entries + _lookup[positionManager] = approve && spoke1.isPositionManagerActive(positionManager); + } + + vm.prank(vm.randomAddress()); + spoke1.setUserPositionManagersWithSig(params, signature); + + _assertNonceIncrement(spoke1, params.onBehalfOf, params.nonce); + for (uint256 i; i < updates.length; ++i) { + address positionManager = params.updates[i].positionManager; + assertEq( + spoke1.isPositionManager(params.onBehalfOf, positionManager), + (positionManager == user) || _lookup[positionManager] + ); + } + } + + function test_setUserPositionManagersWithSig_ERC1271_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { - (, uint256 alicePk) = makeAddrAndKey('alice'); MockERC1271Wallet smartWallet = new MockERC1271Wallet(alice); - EIP712Types.SetUserPositionManager memory params = _setUserPositionManagerData( + ISpoke.SetUserPositionManagers memory params = _setUserPositionManagerData( address(smartWallet), _warpAfterRandomDeadline() ); @@ -203,39 +257,31 @@ contract SpokeSetUserPositionManagerWithSigTest is SpokeBase { (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, digest); bytes memory signature = abi.encodePacked(r, s, v); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); - spoke1.setUserPositionManagerWithSig( - params.positionManager, - params.user, - params.approve, - params.nonce, - params.deadline, - signature - ); + spoke1.setUserPositionManagersWithSig(params, signature); } - function test_setUserPositionManagerWithSig_ERC1271_revertsWith_InvalidSignature_dueTo_InvalidHash() + function test_setUserPositionManagersWithSig_ERC1271_revertsWith_InvalidSignature_dueTo_InvalidHash() public { - (, uint256 alicePk) = makeAddrAndKey('alice'); address maliciousManager = makeAddr('maliciousManager'); MockERC1271Wallet smartWallet = new MockERC1271Wallet(alice); vm.prank(SPOKE_ADMIN); spoke1.updatePositionManager(maliciousManager, true); uint256 deadline = _warpAfterRandomDeadline(); - EIP712Types.SetUserPositionManager memory params = _setUserPositionManagerData( + ISpoke.SetUserPositionManagers memory params = _setUserPositionManagerData( address(smartWallet), deadline ); bytes32 digest = _getTypedDataHash(spoke1, params); - EIP712Types.SetUserPositionManager memory invalidParams = _setUserPositionManagerData( + ISpoke.SetUserPositionManagers memory invalidParams = _setUserPositionManagerData( address(smartWallet), deadline ); - invalidParams.positionManager = maliciousManager; + invalidParams.updates[0].positionManager = maliciousManager; (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(spoke1, invalidParams)); bytes memory signature = abi.encodePacked(r, s, v); @@ -243,27 +289,21 @@ contract SpokeSetUserPositionManagerWithSigTest is SpokeBase { vm.prank(alice); smartWallet.approveHash(digest); - vm.expectRevert(ISpoke.InvalidSignature.selector); + invalidParams.nonce = params.nonce; + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); - spoke1.setUserPositionManagerWithSig( - invalidParams.positionManager, - invalidParams.user, - invalidParams.approve, - params.nonce, - invalidParams.deadline, - signature - ); + spoke1.setUserPositionManagersWithSig(invalidParams, signature); } - function test_setUserPositionManagerWithSig_ERC1271_revertsWith_InvalidAccountNonce( + function test_setUserPositionManagersWithSig_ERC1271_revertsWith_InvalidAccountNonce( bytes32 ) public { - (, uint256 alicePk) = makeAddrAndKey('alice'); MockERC1271Wallet smartWallet = new MockERC1271Wallet(alice); uint256 deadline = _warpBeforeRandomDeadline(); uint192 nonceKey = _randomNonceKey(); - EIP712Types.SetUserPositionManager memory params = _setUserPositionManagerData( + ISpoke.SetUserPositionManagers memory params = _setUserPositionManagerData( address(smartWallet), deadline ); @@ -286,17 +326,10 @@ contract SpokeSetUserPositionManagerWithSigTest is SpokeBase { ) ); vm.prank(vm.randomAddress()); - spoke1.setUserPositionManagerWithSig( - params.positionManager, - params.user, - params.approve, - params.nonce, - params.deadline, - signature - ); + spoke1.setUserPositionManagersWithSig(params, signature); } - function test_setUserPositionManagerWithSig_ERC1271() public { + function test_setUserPositionManagersWithSig_ERC1271() public { (address user, uint256 userPk) = makeAddrAndKey(string(vm.randomBytes(32))); MockERC1271Wallet smartWallet = new MockERC1271Wallet(user); vm.label(user, 'user'); @@ -306,7 +339,7 @@ contract SpokeSetUserPositionManagerWithSigTest is SpokeBase { spoke1.updatePositionManager(positionManager, true); uint256 deadline = _warpBeforeRandomDeadline(); - EIP712Types.SetUserPositionManager memory params = _setUserPositionManagerData( + ISpoke.SetUserPositionManagers memory params = _setUserPositionManagerData( address(smartWallet), deadline ); @@ -319,30 +352,31 @@ contract SpokeSetUserPositionManagerWithSigTest is SpokeBase { bytes memory signature = abi.encodePacked(r, s, v); vm.expectEmit(address(spoke1)); - emit ISpoke.SetUserPositionManager(params.user, params.positionManager, params.approve); + emit ISpoke.SetUserPositionManager( + params.onBehalfOf, + params.updates[0].positionManager, + params.updates[0].approve + ); vm.prank(vm.randomAddress()); - spoke1.setUserPositionManagerWithSig( - params.positionManager, - params.user, - params.approve, - params.nonce, - params.deadline, - signature - ); + spoke1.setUserPositionManagersWithSig(params, signature); - _assertNonceIncrement(spoke1, params.user, params.nonce); - assertEq(spoke1.isPositionManager(params.user, params.positionManager), params.approve); + _assertNonceIncrement(spoke1, params.onBehalfOf, params.nonce); + assertEq( + spoke1.isPositionManager(params.onBehalfOf, params.updates[0].positionManager), + params.updates[0].approve + ); } function _setUserPositionManagerData( address user, uint256 deadline - ) internal returns (EIP712Types.SetUserPositionManager memory) { - EIP712Types.SetUserPositionManager memory params = EIP712Types.SetUserPositionManager({ - positionManager: POSITION_MANAGER, - user: user, - approve: vm.randomBool(), + ) internal returns (ISpoke.SetUserPositionManagers memory) { + ISpoke.PositionManagerUpdate[] memory updates = new ISpoke.PositionManagerUpdate[](1); + updates[0] = ISpoke.PositionManagerUpdate(POSITION_MANAGER, true); + ISpoke.SetUserPositionManagers memory params = ISpoke.SetUserPositionManagers({ + onBehalfOf: user, + updates: updates, nonce: spoke1.nonces(user, _randomNonceKey()), deadline: deadline }); diff --git a/tests/unit/Spoke/Spoke.SetUsingAsCollateral.t.sol b/tests/unit/Spoke/Spoke.SetUsingAsCollateral.t.sol index f5e440fd7..b5bc7191c 100644 --- a/tests/unit/Spoke/Spoke.SetUsingAsCollateral.t.sol +++ b/tests/unit/Spoke/Spoke.SetUsingAsCollateral.t.sol @@ -4,15 +4,19 @@ pragma solidity ^0.8.0; import 'tests/unit/Spoke/SpokeBase.t.sol'; -contract SpokeConfigTest is SpokeBase { +contract SpokeSetUsingAsCollateralTest is SpokeBase { using SafeCast for uint256; using ReserveFlagsMap for ReserveFlags; function test_setUsingAsCollateral_revertsWith_ReserveNotListed() public { uint256 reserveCount = spoke1.getReserveCount(); - vm.prank(alice); vm.expectRevert(ISpoke.ReserveNotListed.selector); + vm.prank(alice); spoke1.setUsingAsCollateral(reserveCount, true, alice); + + vm.expectRevert(ISpoke.ReserveNotListed.selector); + vm.prank(alice); + spoke1.setUsingAsCollateral(reserveCount, false, alice); } function test_setUsingAsCollateral_revertsWith_ReserveFrozen() public { @@ -24,7 +28,7 @@ contract SpokeConfigTest is SpokeBase { assertTrue(_isUsingAsCollateral(spoke1, daiReserveId, alice), 'alice using as collateral'); assertFalse(_isUsingAsCollateral(spoke1, daiReserveId, bob), 'bob not using as collateral'); - updateReserveFrozenFlag(spoke1, daiReserveId, true); + _updateReserveFrozenFlag(spoke1, daiReserveId, true); assertTrue(spoke1.getReserve(daiReserveId).flags.frozen(), 'reserve status frozen'); // disallow when activating @@ -52,6 +56,47 @@ contract SpokeConfigTest is SpokeBase { spoke1.setUsingAsCollateral(daiReserveId, true, alice); } + function test_setUsingAsCollateral_revertsWith_ReentrancyGuardReentrantCall() public { + Utils.supplyCollateral({ + spoke: spoke1, + reserveId: _wethReserveId(spoke1), + caller: bob, + amount: 1e18, + onBehalfOf: bob + }); + + Utils.supplyCollateral({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: 100e18, + onBehalfOf: bob + }); + + Utils.borrow({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: 100e18, + onBehalfOf: bob + }); + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke1), + ISpoke.setUsingAsCollateral.selector + ); + + // reentrant hub.refreshPremium call + vm.mockFunction( + address(_hub(spoke1, _daiReserveId(spoke1))), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.refreshPremium.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + spoke1.setUsingAsCollateral(_daiReserveId(spoke1), false, bob); + } + /// no action taken when collateral status is unchanged function test_setUsingAsCollateral_collateralStatusUnchanged() public { uint256 daiReserveId = _daiReserveId(spoke1); @@ -131,4 +176,76 @@ contract SpokeConfigTest is SpokeBase { 'wrong usingAsCollateral' ); } + + function test_setUsingAsCollateral_revertsWith_MaximumUserReservesExceeded() public { + uint16 maxUserReservesLimit = (spoke1.getReserveCount() - 1).toUint16(); + _updateMaxUserReservesLimit(spoke1, maxUserReservesLimit); + assertEq(spoke1.MAX_USER_RESERVES_LIMIT(), maxUserReservesLimit, 'Reserve limit adjusted'); + assertGt(spoke1.getReserveCount(), maxUserReservesLimit, 'More reserves than limit'); + + for (uint256 i = 0; i < maxUserReservesLimit; ++i) { + Utils.supplyCollateral(spoke1, i, bob, 1e18, bob); + } + ISpoke.UserAccountData memory accountData = spoke1.getUserAccountData(bob); + assertEq( + accountData.activeCollateralCount, + maxUserReservesLimit, + 'Bob has reached the collateral limit' + ); + + vm.expectRevert(ISpoke.MaximumUserReservesExceeded.selector); + vm.prank(bob); + spoke1.setUsingAsCollateral(maxUserReservesLimit, true, bob); + } + + /// @dev Test that enables collaterals up to the user reserves limit, disables one reserve, and then enables again + function test_setUsingAsCollateral_to_limit_disable_enable_again() public { + uint16 maxUserReservesLimit = (spoke1.getReserveCount() - 1).toUint16(); + _updateMaxUserReservesLimit(spoke1, maxUserReservesLimit); + assertEq(spoke1.MAX_USER_RESERVES_LIMIT(), maxUserReservesLimit, 'Reserve limit adjusted'); + assertGt(spoke1.getReserveCount(), maxUserReservesLimit, 'More reserves than limit'); + + for (uint256 i = 0; i < maxUserReservesLimit; ++i) { + Utils.supplyCollateral(spoke1, i, bob, 1e18, bob); + } + + ISpoke.UserAccountData memory accountData = spoke1.getUserAccountData(bob); + assertEq( + accountData.activeCollateralCount, + maxUserReservesLimit, + 'Bob has reached the collateral limit' + ); + + Utils.setUsingAsCollateral(spoke1, 0, bob, false, bob); + + accountData = spoke1.getUserAccountData(bob); + assertEq( + accountData.activeCollateralCount, + maxUserReservesLimit - 1, + 'Bob has disabled one collateral' + ); + + Utils.supplyCollateral(spoke1, maxUserReservesLimit, bob, 1e18, bob); + + accountData = spoke1.getUserAccountData(bob); + assertEq( + accountData.activeCollateralCount, + maxUserReservesLimit, + 'Bob has reached the collateral limit' + ); + } + + /// @dev Test showing that when the collateral limit is max, all reserves can be enabled as collateral. + function test_setUsingAsCollateral_unlimited_whenLimitIsMax() public { + assertEq(spoke1.MAX_USER_RESERVES_LIMIT(), Constants.MAX_ALLOWED_USER_RESERVES_LIMIT); + + uint256 collateralsToEnable = spoke1.getReserveCount(); + + for (uint256 i = 0; i < collateralsToEnable; ++i) { + Utils.supplyCollateral(spoke1, i, bob, 1e18, bob); + } + + ISpoke.UserAccountData memory accountData = spoke1.getUserAccountData(bob); + assertEq(accountData.activeCollateralCount, collateralsToEnable); + } } diff --git a/tests/unit/Spoke/Spoke.Supply.t.sol b/tests/unit/Spoke/Spoke.Supply.t.sol index 947b8e174..0ba58e388 100644 --- a/tests/unit/Spoke/Spoke.Supply.t.sol +++ b/tests/unit/Spoke/Spoke.Supply.t.sol @@ -33,7 +33,7 @@ contract SpokeSupplyTest is SpokeBase { uint256 daiReserveId = _daiReserveId(spoke1); uint256 amount = 100e18; - updateReserveFrozenFlag(spoke1, daiReserveId, true); + _updateReserveFrozenFlag(spoke1, daiReserveId, true); assertTrue(spoke1.getReserve(daiReserveId).flags.frozen()); vm.expectRevert(ISpoke.ReserveFrozen.selector); @@ -85,6 +85,24 @@ contract SpokeSupplyTest is SpokeBase { spoke1.supply(_daiReserveId(spoke1), amount, bob); } + function test_supply_revertsWith_ReentrancyGuardReentrantCall() public { + uint256 amount = 100e18; + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke1), + ISpokeBase.supply.selector + ); + + vm.mockFunction( + address(_hub(spoke1, _daiReserveId(spoke1))), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.add.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + spoke1.supply(_daiReserveId(spoke1), amount, bob); + } + function test_supply() public { uint256 amount = 100e18; TestUserData[2] memory bobData; diff --git a/tests/unit/Spoke/Spoke.UpdateUserDynamicConfig.t.sol b/tests/unit/Spoke/Spoke.UpdateUserDynamicConfig.t.sol new file mode 100644 index 000000000..7edfccf81 --- /dev/null +++ b/tests/unit/Spoke/Spoke.UpdateUserDynamicConfig.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/Spoke/SpokeBase.t.sol'; + +contract SpokeUpdateUserDynamicConfigTest is SpokeBase { + function test_updateUserDynamicConfig_revertsWith_ReentrancyGuardReentrantCall() public { + Utils.supplyCollateral({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: 1000e18, + onBehalfOf: bob + }); + + Utils.borrow({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: 100e18, + onBehalfOf: bob + }); + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke1), + ISpoke.updateUserDynamicConfig.selector + ); + + // reentrant hub.refreshPremium call + vm.mockFunction( + address(_hub(spoke1, _daiReserveId(spoke1))), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.refreshPremium.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + spoke1.updateUserDynamicConfig(bob); + } +} diff --git a/tests/unit/Spoke/Spoke.UpdateUserRiskPremium.t.sol b/tests/unit/Spoke/Spoke.UpdateUserRiskPremium.t.sol new file mode 100644 index 000000000..73759a88a --- /dev/null +++ b/tests/unit/Spoke/Spoke.UpdateUserRiskPremium.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/Spoke/SpokeBase.t.sol'; + +contract SpokeUpdateUserRiskPremiumTest is SpokeBase { + function test_updateUserRiskPremium_revertsWith_ReentrancyGuardReentrantCall() public { + Utils.supplyCollateral({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: 1000e18, + onBehalfOf: bob + }); + + Utils.borrow({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: 100e18, + onBehalfOf: bob + }); + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke1), + ISpoke.updateUserRiskPremium.selector + ); + + // reentrant hub.refreshPremium call + vm.mockFunction( + address(_hub(spoke1, _daiReserveId(spoke1))), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.refreshPremium.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + spoke1.updateUserRiskPremium(bob); + } +} diff --git a/tests/unit/Spoke/Spoke.Upgradeable.t.sol b/tests/unit/Spoke/Spoke.Upgradeable.t.sol index 65fc3c3c8..bc5f9203a 100644 --- a/tests/unit/Spoke/Spoke.Upgradeable.t.sol +++ b/tests/unit/Spoke/Spoke.Upgradeable.t.sol @@ -5,9 +5,6 @@ pragma solidity ^0.8.0; import 'tests/unit/Spoke/SpokeBase.t.sol'; contract SpokeUpgradeableTest is SpokeBase { - bytes32 internal constant INITIALIZABLE_STORAGE = - 0xf0c57e16840df040f15088dc2f81fe391c3923bec73e23a9662efc9c229c6a00; - address internal proxyAdminOwner = makeAddr('proxyAdminOwner'); address internal oracle = makeAddr('AaveOracle'); @@ -21,7 +18,7 @@ contract SpokeUpgradeableTest is SpokeBase { vm.expectEmit(spokeImplAddress); emit Initializable.Initialized(type(uint64).max); - SpokeInstance spokeImpl = _deployMockSpokeInstance(revision); + ISpokeInstance spokeImpl = _deployMockSpokeInstance(revision); assertEq(address(spokeImpl), spokeImplAddress); assertEq(spokeImpl.SPOKE_REVISION(), revision); @@ -34,7 +31,7 @@ contract SpokeUpgradeableTest is SpokeBase { function test_proxy_constructor_fuzz(uint64 revision) public { revision = uint64(bound(revision, 1, type(uint64).max)); - SpokeInstance spokeImpl = _deployMockSpokeInstance(revision); + ISpokeInstance spokeImpl = _deployMockSpokeInstance(revision); address spokeProxyAddress = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); address proxyAdminAddress = vm.computeCreateAddress(spokeProxyAddress, 1); @@ -47,7 +44,7 @@ contract SpokeUpgradeableTest is SpokeBase { vm.expectEmit(spokeProxyAddress); emit IERC1967.Upgraded(address(spokeImpl)); vm.expectEmit(spokeProxyAddress); - emit ISpoke.UpdateOracle(oracle); + emit ISpoke.SetSpokeImmutables(oracle, Constants.MAX_ALLOWED_USER_RESERVES_LIMIT); vm.expectEmit(spokeProxyAddress); emit IAccessManaged.AuthorityUpdated(address(accessManager)); vm.expectEmit(spokeProxyAddress); @@ -63,7 +60,7 @@ contract SpokeUpgradeableTest is SpokeBase { new TransparentUpgradeableProxy( address(spokeImpl), proxyAdminOwner, - abi.encodeCall(Spoke.initialize, address(accessManager)) + abi.encodeCall(ISpokeInstance.initialize, address(accessManager)) ) ) ); @@ -74,17 +71,18 @@ contract SpokeUpgradeableTest is SpokeBase { assertEq(_getProxyInitializedVersion(address(spokeProxy)), revision); assertEq(spokeProxy.getLiquidationConfig(), expectedLiquidationConfig); + assertEq(spokeProxy.MAX_USER_RESERVES_LIMIT(), Constants.MAX_ALLOWED_USER_RESERVES_LIMIT); } function test_proxy_reinitialization_fuzz(uint64 initialRevision) public { initialRevision = uint64(bound(initialRevision, 1, type(uint64).max - 1)); - SpokeInstance spokeImpl = _deployMockSpokeInstance(initialRevision); + ISpokeInstance spokeImpl = _deployMockSpokeInstance(initialRevision); ITransparentUpgradeableProxy spokeProxy = ITransparentUpgradeableProxy( address( new TransparentUpgradeableProxy( address(spokeImpl), proxyAdminOwner, - abi.encodeCall(Spoke.initialize, address(accessManager)) + abi.encodeCall(ISpokeInstance.initialize, address(accessManager)) ) ) ); @@ -94,7 +92,7 @@ contract SpokeUpgradeableTest is SpokeBase { _updateTargetHealthFactor(ISpoke(address(spokeProxy)), targetHealthFactor); uint64 secondRevision = uint64(vm.randomUint(initialRevision + 1, type(uint64).max)); - SpokeInstance spokeImpl2 = _deployMockSpokeInstance(secondRevision); + ISpokeInstance spokeImpl2 = _deployMockSpokeInstance(secondRevision); vm.expectEmit(address(spokeProxy)); emit IAccessManaged.AuthorityUpdated(address(accessManager)); @@ -111,13 +109,13 @@ contract SpokeUpgradeableTest is SpokeBase { } function test_proxy_constructor_revertsWith_InvalidInitialization_ZeroRevision() public { - SpokeInstance spokeImpl = _deployMockSpokeInstance(0); + ISpokeInstance spokeImpl = _deployMockSpokeInstance(0); vm.expectRevert(Initializable.InvalidInitialization.selector); new TransparentUpgradeableProxy( address(spokeImpl), proxyAdminOwner, - abi.encodeCall(Spoke.initialize, address(accessManager)) + abi.encodeCall(ISpokeInstance.initialize, address(accessManager)) ); } @@ -126,7 +124,7 @@ contract SpokeUpgradeableTest is SpokeBase { ) public { initialRevision = uint64(bound(initialRevision, 1, type(uint64).max)); - SpokeInstance spokeImpl = _deployMockSpokeInstance(initialRevision); + ISpokeInstance spokeImpl = _deployMockSpokeInstance(initialRevision); ITransparentUpgradeableProxy spokeProxy = ITransparentUpgradeableProxy( address( new TransparentUpgradeableProxy( @@ -142,7 +140,7 @@ contract SpokeUpgradeableTest is SpokeBase { spokeProxy.upgradeToAndCall(address(spokeImpl), _getInitializeCalldata(address(accessManager))); uint64 secondRevision = uint64(vm.randomUint(0, initialRevision - 1)); - SpokeInstance spokeImpl2 = _deployMockSpokeInstance(secondRevision); + ISpokeInstance spokeImpl2 = _deployMockSpokeInstance(secondRevision); vm.expectRevert(Initializable.InvalidInitialization.selector); vm.prank(_getProxyAdminAddress(address(spokeProxy))); spokeProxy.upgradeToAndCall( @@ -152,7 +150,10 @@ contract SpokeUpgradeableTest is SpokeBase { } function test_proxy_constructor_revertsWith_InvalidAddress() public { - SpokeInstance spokeImpl = new SpokeInstance(oracle); + ISpokeInstance spokeImpl = DeployUtils.deploySpokeImplementation( + oracle, + Constants.MAX_ALLOWED_USER_RESERVES_LIMIT + ); vm.expectRevert(ISpoke.InvalidAddress.selector); new TransparentUpgradeableProxy( address(spokeImpl), @@ -162,7 +163,10 @@ contract SpokeUpgradeableTest is SpokeBase { } function test_proxy_reinitialization_revertsWith_InvalidAddress() public { - SpokeInstance spokeImpl = new SpokeInstance(oracle); + ISpokeInstance spokeImpl = DeployUtils.deploySpokeImplementation( + oracle, + Constants.MAX_ALLOWED_USER_RESERVES_LIMIT + ); ITransparentUpgradeableProxy spokeProxy = ITransparentUpgradeableProxy( address( new TransparentUpgradeableProxy( @@ -173,14 +177,17 @@ contract SpokeUpgradeableTest is SpokeBase { ) ); - SpokeInstance spokeImpl2 = _deployMockSpokeInstance(2); + ISpokeInstance spokeImpl2 = _deployMockSpokeInstance(2); vm.expectRevert(ISpoke.InvalidAddress.selector); vm.prank(_getProxyAdminAddress(address(spokeProxy))); spokeProxy.upgradeToAndCall(address(spokeImpl2), _getInitializeCalldata(address(0))); } function test_proxy_reinitialization_revertsWith_CallerNotProxyAdmin() public { - SpokeInstance spokeImpl = new SpokeInstance(oracle); + ISpokeInstance spokeImpl = DeployUtils.deploySpokeImplementation( + oracle, + Constants.MAX_ALLOWED_USER_RESERVES_LIMIT + ); ITransparentUpgradeableProxy spokeProxy = ITransparentUpgradeableProxy( address( new TransparentUpgradeableProxy( @@ -191,7 +198,7 @@ contract SpokeUpgradeableTest is SpokeBase { ) ); - SpokeInstance spokeImpl2 = _deployMockSpokeInstance(2); + ISpokeInstance spokeImpl2 = _deployMockSpokeInstance(2); vm.expectRevert(); vm.prank(makeUser()); spokeProxy.upgradeToAndCall( @@ -200,16 +207,14 @@ contract SpokeUpgradeableTest is SpokeBase { ); } - function _getProxyInitializedVersion(address proxy) internal view returns (uint64) { - bytes32 slotData = vm.load(proxy, INITIALIZABLE_STORAGE); - return uint64(uint256(slotData) & ((1 << 64) - 1)); - } - function _getInitializeCalldata(address manager) internal pure returns (bytes memory) { - return abi.encodeCall(Spoke.initialize, manager); + return abi.encodeCall(ISpokeInstance.initialize, manager); } - function _deployMockSpokeInstance(uint64 revision) internal returns (SpokeInstance) { - return SpokeInstance(address(new MockSpokeInstance(revision, oracle))); + function _deployMockSpokeInstance(uint64 revision) internal returns (ISpokeInstance) { + return + ISpokeInstance( + address(new MockSpokeInstance(revision, oracle, Constants.MAX_ALLOWED_USER_RESERVES_LIMIT)) + ); } } diff --git a/tests/unit/Spoke/Spoke.UserAccountData.t.sol b/tests/unit/Spoke/Spoke.UserAccountData.t.sol index cf28a88a8..a0307c242 100644 --- a/tests/unit/Spoke/Spoke.UserAccountData.t.sol +++ b/tests/unit/Spoke/Spoke.UserAccountData.t.sol @@ -13,7 +13,9 @@ contract SpokeUserAccountDataTest is SpokeBase { function setUp() public override { super.setUp(); spoke = MockSpoke(address(spoke1)); - address mockSpokeImpl = address(new MockSpoke(address(spoke.ORACLE()))); + address mockSpokeImpl = address( + new MockSpoke(address(spoke.ORACLE()), Constants.MAX_ALLOWED_USER_RESERVES_LIMIT) + ); vm.etch(address(spoke1), mockSpokeImpl.code); _updateCollateralFactor(spoke, _wethReserveId(spoke), 80_00); @@ -50,12 +52,12 @@ contract SpokeUserAccountDataTest is SpokeBase { false, ISpoke.UserAccountData({ totalCollateralValue: 100e26, - totalDebtValue: 75e26, + totalDebtValueRay: 75e26 * WadRayMath.RAY, avgCollateralFactor: 0.72e18, healthFactor: 0.96e18, riskPremium: 10_00, activeCollateralCount: 1, - borrowedCount: 1 + borrowCount: 1 }) ); } @@ -84,12 +86,12 @@ contract SpokeUserAccountDataTest is SpokeBase { false, ISpoke.UserAccountData({ totalCollateralValue: 100e26, - totalDebtValue: 75e26, + totalDebtValueRay: 75e26 * WadRayMath.RAY, avgCollateralFactor: 0.72e18, healthFactor: 0.96e18, riskPremium: 10_00, activeCollateralCount: 1, - borrowedCount: 1 + borrowCount: 1 }) ); } @@ -118,12 +120,12 @@ contract SpokeUserAccountDataTest is SpokeBase { true, ISpoke.UserAccountData({ totalCollateralValue: 100e26, - totalDebtValue: 75e26, + totalDebtValueRay: 75e26 * WadRayMath.RAY, avgCollateralFactor: 0.96e18, healthFactor: 1.28e18, riskPremium: 10_00, activeCollateralCount: 1, - borrowedCount: 1 + borrowCount: 1 }) ); } @@ -139,7 +141,8 @@ contract SpokeUserAccountDataTest is SpokeBase { // Supplied Assets: 1 WETH // Debt: 0.3 + 0.15 + 0.05 = 0.5 WETH = 0.5 * $2000 = $1000 // Health Factor: ($100 * 0.96 + $5000 * 0.5) / $1000 = 2.596 - // Avg Collateral Factor: (0.96 * $100 + 0.5 * $5000) / ($100 + $5000) = 0.509019608 + // Total Adjusted Collateral Value: 0.96 * $100 + 0.5 * $5000 = 2596 + // Avg Collateral Factor: $2596 / ($100 + $5000) = 0.509019607843137254 // Risk Premium: (0.1 * $100 + 0.15 * $900) / $1000 = 0.145 // Supplied Collaterals Count: 2 // Borrowed Reserves Count: 1 @@ -160,12 +163,12 @@ contract SpokeUserAccountDataTest is SpokeBase { true, ISpoke.UserAccountData({ totalCollateralValue: 5100e26, - totalDebtValue: 1000e26, - avgCollateralFactor: 0.509019608e18, + totalDebtValueRay: 1000e26 * WadRayMath.RAY, + avgCollateralFactor: 0.509019607843137254e18, healthFactor: 2.596e18, riskPremium: 14_50, activeCollateralCount: 2, - borrowedCount: 1 + borrowCount: 1 }) ); } @@ -202,12 +205,12 @@ contract SpokeUserAccountDataTest is SpokeBase { false, ISpoke.UserAccountData({ totalCollateralValue: 100e26, - totalDebtValue: 125e26, + totalDebtValueRay: 125e26 * WadRayMath.RAY, avgCollateralFactor: 0.72e18, healthFactor: 0.576e18, riskPremium: 10_00, activeCollateralCount: 1, - borrowedCount: 2 + borrowCount: 2 }) ); } @@ -242,12 +245,12 @@ contract SpokeUserAccountDataTest is SpokeBase { false, ISpoke.UserAccountData({ totalCollateralValue: 100e26, - totalDebtValue: 75e26, + totalDebtValueRay: 75e26 * WadRayMath.RAY, avgCollateralFactor: 0.72e18, healthFactor: 0.96e18, riskPremium: 10_00, activeCollateralCount: 1, - borrowedCount: 1 + borrowCount: 1 }) ); } @@ -262,23 +265,10 @@ contract SpokeUserAccountDataTest is SpokeBase { user, refreshConfig ); - assertApproxEq(userAccountData, expectedUserAccountData); + assertEq(userAccountData, expectedUserAccountData); } - function _getLastReserveConfigKey(uint256 reserveId) internal view returns (uint24) { + function _getLastReserveConfigKey(uint256 reserveId) internal view returns (uint32) { return spoke.getReserve(reserveId).dynamicConfigKey; } - - function assertApproxEq( - ISpoke.UserAccountData memory a, - ISpoke.UserAccountData memory b - ) internal pure { - assertEq(a.totalCollateralValue, b.totalCollateralValue, 'totalCollateralValue'); - assertEq(a.totalDebtValue, b.totalDebtValue, 'totalDebtValue'); - assertApproxEqAbs(a.avgCollateralFactor, b.avgCollateralFactor, 1e12, 'avgCollateralFactor'); - assertApproxEqAbs(a.healthFactor, b.healthFactor, 1e12, 'healthFactor'); - assertApproxEqAbs(a.riskPremium, b.riskPremium, 1, 'riskPremium'); - assertEq(a.activeCollateralCount, b.activeCollateralCount, 'activeCollateralCount'); - assertEq(a.borrowedCount, b.borrowedCount, 'borrowedCount'); - } } diff --git a/tests/unit/Spoke/Spoke.Withdraw.Scenario.t.sol b/tests/unit/Spoke/Spoke.Withdraw.Scenario.t.sol index 13ffc3917..1cf08b042 100644 --- a/tests/unit/Spoke/Spoke.Withdraw.Scenario.t.sol +++ b/tests/unit/Spoke/Spoke.Withdraw.Scenario.t.sol @@ -130,9 +130,18 @@ contract SpokeWithdrawScenarioTest is SpokeBase { function test_withdraw_fuzz_all_liquidity_with_interest_multi_user( MultiUserFuzzParams memory params ) public { - params.reserveId = bound(params.reserveId, 0, spokeInfo[spoke1].MAX_ALLOWED_ASSET_ID); - params.aliceAmount = bound(params.aliceAmount, 1, MAX_SUPPLY_AMOUNT - 1); - params.bobAmount = bound(params.bobAmount, 1, MAX_SUPPLY_AMOUNT - params.aliceAmount); + params.reserveId = bound(params.reserveId, 0, spoke1.getReserveCount() - 1); + vm.assume(params.reserveId != _wbtcReserveId(spoke1)); + params.aliceAmount = bound( + params.aliceAmount, + 1, + _calculateMaxSupplyAmount(spoke1, params.reserveId) - 1 + ); + params.bobAmount = bound( + params.bobAmount, + 1, + _calculateMaxSupplyAmount(spoke1, params.reserveId) - params.aliceAmount + ); params.skipTime[0] = bound(params.skipTime[0], 0, MAX_SKIP_TIME); params.skipTime[1] = bound(params.skipTime[1], 0, MAX_SKIP_TIME); params.borrowAmount = bound( @@ -168,7 +177,15 @@ contract SpokeWithdrawScenarioTest is SpokeBase { spoke: spoke1, reserveId: _wbtcReserveId(spoke1), caller: carol, - amount: params.borrowAmount, // highest value asset so that it is enough collateral + amount: _max( + 1, + _convertAssetAmount( + spoke1, + params.reserveId, + params.borrowAmount * 4, + _wbtcReserveId(spoke1) + ) + ), // highest value asset so that it is enough collateral onBehalfOf: carol }); Utils.borrow({ diff --git a/tests/unit/Spoke/Spoke.Withdraw.t.sol b/tests/unit/Spoke/Spoke.Withdraw.t.sol index 7b3793865..92cc368f7 100644 --- a/tests/unit/Spoke/Spoke.Withdraw.t.sol +++ b/tests/unit/Spoke/Spoke.Withdraw.t.sol @@ -35,6 +35,63 @@ contract SpokeWithdrawTest is SpokeBase { uint256 skipTime; } + function test_withdraw_revertsWith_ReentrancyGuardReentrantCall_hubRemove() public { + uint256 amount = 100e18; + Utils.supplyCollateral({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: amount * 10, + onBehalfOf: bob + }); + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke1), + ISpokeBase.withdraw.selector + ); + + vm.mockFunction( + address(_hub(spoke1, _daiReserveId(spoke1))), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.remove.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + spoke1.withdraw(_daiReserveId(spoke1), amount, bob); + } + + function test_withdraw_revertsWith_ReentrancyGuardReentrantCall_hubRefreshPremium() public { + uint256 amount = 100e18; + Utils.supplyCollateral({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: amount * 10, + onBehalfOf: bob + }); + Utils.borrow({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: bob, + amount: amount, + onBehalfOf: bob + }); + + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(spoke1), + ISpokeBase.withdraw.selector + ); + + vm.mockFunction( + address(_hub(spoke1, _daiReserveId(spoke1))), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.refreshPremium.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + spoke1.withdraw(_daiReserveId(spoke1), amount, bob); + } + function test_withdraw_same_block() public { uint256 amount = 100e18; @@ -163,7 +220,7 @@ contract SpokeWithdrawTest is SpokeBase { } function test_withdraw_fuzz_suppliedAmount(uint256 supplyAmount) public { - supplyAmount = bound(supplyAmount, 1, MAX_SUPPLY_AMOUNT); + supplyAmount = bound(supplyAmount, 1, MAX_SUPPLY_AMOUNT_DAI); Utils.supply({ spoke: spoke1, reserveId: _daiReserveId(spoke1), @@ -243,7 +300,7 @@ contract SpokeWithdrawTest is SpokeBase { } function test_withdraw_fuzz_all_with_interest(uint256 supplyAmount, uint256 borrowAmount) public { - supplyAmount = bound(supplyAmount, 2, MAX_SUPPLY_AMOUNT); + supplyAmount = bound(supplyAmount, 2, MAX_SUPPLY_AMOUNT_DAI); borrowAmount = bound(borrowAmount, 1, supplyAmount / 2); Utils.supplyCollateral({ @@ -512,7 +569,7 @@ contract SpokeWithdrawTest is SpokeBase { params.borrowReserveSupplyAmount = bound( params.borrowReserveSupplyAmount, 2, - MAX_SUPPLY_AMOUNT + _calculateMaxSupplyAmount(spoke1, params.reserveId) ); params.borrowAmount = bound(params.borrowAmount, 1, params.borrowReserveSupplyAmount / 2); params.rate = bound(params.rate, 1, MAX_BORROW_RATE); @@ -535,7 +592,7 @@ contract SpokeWithdrawTest is SpokeBase { TestState memory state; state.reserveId = params.reserveId; state.collateralReserveId = _wbtcReserveId(spoke1); - state.suppliedCollateralAmount = MAX_SUPPLY_AMOUNT; // ensure enough collateral + state.suppliedCollateralAmount = _calculateMaxSupplyAmount(spoke1, state.collateralReserveId); // ensure enough collateral state.borrowReserveSupplyAmount = params.borrowReserveSupplyAmount; state.borrowAmount = params.borrowAmount; state.rate = params.rate; @@ -795,7 +852,7 @@ contract SpokeWithdrawTest is SpokeBase { params.borrowReserveSupplyAmount = bound( params.borrowReserveSupplyAmount, 2, - MAX_SUPPLY_AMOUNT + _calculateMaxSupplyAmount(spoke1, params.reserveId) ); params.borrowAmount = bound(params.borrowAmount, 1, params.borrowReserveSupplyAmount / 2); params.rate = bound(params.rate, 1, MAX_BORROW_RATE); @@ -810,7 +867,7 @@ contract SpokeWithdrawTest is SpokeBase { TestState memory state; state.reserveId = params.reserveId; state.collateralReserveId = _wbtcReserveId(spoke1); - state.suppliedCollateralAmount = MAX_SUPPLY_AMOUNT; // ensure enough collateral + state.suppliedCollateralAmount = _calculateMaxSupplyAmount(spoke1, state.collateralReserveId); // ensure enough collateral state.borrowReserveSupplyAmount = params.borrowReserveSupplyAmount; state.borrowAmount = params.borrowAmount; state.rate = params.rate; @@ -951,7 +1008,7 @@ contract SpokeWithdrawTest is SpokeBase { /// can increase due to rounding, with interest accrual should strictly increase function test_fuzz_withdraw_effect_on_ex_rates(uint256 amount, uint256 delay) public { delay = bound(delay, 1, MAX_SKIP_TIME); - amount = bound(amount, 2, MAX_SUPPLY_AMOUNT / 2); + amount = bound(amount, 2, MAX_SUPPLY_AMOUNT_DAI / 2); uint256 wethSupplyAmount = _calcMinimumCollAmount( spoke1, _wethReserveId(spoke1), diff --git a/tests/unit/Spoke/SpokeBase.t.sol b/tests/unit/Spoke/SpokeBase.t.sol index b3044ee8a..866600390 100644 --- a/tests/unit/Spoke/SpokeBase.t.sol +++ b/tests/unit/Spoke/SpokeBase.t.sol @@ -97,7 +97,7 @@ contract SpokeBase is Base { uint256 totalDebtValue; uint256 healthFactor; uint256 activeCollateralCount; - uint24 dynamicConfigKey; + uint32 dynamicConfigKey; uint256 collateralFactor; uint256 collateralValue; ISpoke.UserPosition pos; @@ -152,7 +152,7 @@ contract SpokeBase is Base { } struct DynamicConfig { - uint24 key; + uint32 key; bool enabled; } @@ -196,7 +196,11 @@ contract SpokeBase is Base { uint256 amount, address user ) internal { - _openSupplyPosition(spoke, reserveId, amount); + _openSupplyPosition( + spoke, + reserveId, + _max(_hub(spoke, reserveId).previewAddByShares(_reserveAssetId(spoke, reserveId), 1), amount) + ); Utils.borrow(spoke, reserveId, user, amount, user); } @@ -553,6 +557,14 @@ contract SpokeBase is Base { userDebt.totalDebt = userDebt.drawnDebt + userDebt.premiumDebt; } + function _getUserDrawnShares( + ISpoke spoke, + uint256 reserveId, + address user + ) internal view returns (uint256) { + return spoke.getUserPosition(reserveId, user).drawnShares; + } + function _getUserDebt( ISpoke spoke, uint256 reserveId, @@ -726,11 +738,25 @@ contract SpokeBase is Base { assertEq(a.drawCap, b.drawCap, 'drawCap'); assertEq(a.riskPremiumThreshold, b.riskPremiumThreshold, 'riskPremiumThreshold'); assertEq(a.active, b.active, 'active'); - assertEq(a.paused, b.paused, 'paused'); + assertEq(a.halted, b.halted, 'halted'); assertEq(a.deficitRay, b.deficitRay, 'deficitRay'); assertEq(abi.encode(a), abi.encode(b)); // sanity check } + function assertEq( + ISpoke.UserAccountData memory a, + ISpoke.UserAccountData memory b + ) internal pure { + assertEq(a.riskPremium, b.riskPremium, 'riskPremium'); + assertEq(a.avgCollateralFactor, b.avgCollateralFactor, 'avgCollateralFactor'); + assertEq(a.totalCollateralValue, b.totalCollateralValue, 'totalCollateralValue'); + assertEq(a.totalDebtValueRay, b.totalDebtValueRay, 'totalDebtValueRay'); + assertEq(a.healthFactor, b.healthFactor, 'healthFactor'); + assertEq(a.activeCollateralCount, b.activeCollateralCount, 'activeCollateralCount'); + assertEq(a.borrowCount, b.borrowCount, 'borrowCount'); + assertEq(abi.encode(a), abi.encode(b)); // sanity check + } + function _assertUserRpUnchanged(ISpoke spoke, address user) internal view { uint256 riskPremiumPreview = spoke.getUserAccountData(user).riskPremium; uint256 riskPremiumStored = _getUserRpStored(spoke, user); @@ -759,19 +785,19 @@ contract SpokeBase is Base { return spoke.getUserLastRiskPremium(user); } - function _boundUserAction(UserAction memory action) internal pure returns (UserAction memory) { - action.borrowAmount = bound(action.borrowAmount, 1, MAX_SUPPLY_AMOUNT / 8); + function _boundUserAction(UserAction memory action) internal view returns (UserAction memory) { + action.borrowAmount = bound(action.borrowAmount, 1, MAX_SUPPLY_AMOUNT_DAI / 8); action.repayAmount = bound(action.repayAmount, 1, UINT256_MAX); return action; } - function _bound(UserAssetInfo memory info) internal pure returns (UserAssetInfo memory) { + function _bound(UserAssetInfo memory info) internal view returns (UserAssetInfo memory) { // Bound borrow amounts - info.daiInfo.borrowAmount = bound(info.daiInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT / 8); - info.wethInfo.borrowAmount = bound(info.wethInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT / 8); - info.usdxInfo.borrowAmount = bound(info.usdxInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT / 8); - info.wbtcInfo.borrowAmount = bound(info.wbtcInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT / 8); + info.daiInfo.borrowAmount = bound(info.daiInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT_DAI / 8); + info.wethInfo.borrowAmount = bound(info.wethInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT_WETH / 8); + info.usdxInfo.borrowAmount = bound(info.usdxInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT_USDX / 8); + info.wbtcInfo.borrowAmount = bound(info.wbtcInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT_WBTC / 8); // Bound repay amounts info.daiInfo.repayAmount = bound(info.daiInfo.repayAmount, 1, UINT256_MAX); @@ -818,10 +844,12 @@ contract SpokeBase is Base { // Find all reserves user has supplied, adding up total debt for (uint256 reserveId; reserveId < vars.reserveCount; ++reserveId) { - vars.totalDebtValue += _getDebtValue( + // totalDebtValue is scaled by RAY here, downscaled later + vars.totalDebtValue += _convertAmountToValue( spoke, reserveId, - spoke.getUserTotalDebt(reserveId, user) + spoke.getUserPosition(reserveId, user).drawnShares * _reserveDrawnIndex(spoke, reserveId) + + _calculatePremiumDebtRay(spoke, reserveId, user) ); if (_isUsingAsCollateral(spoke, reserveId, user)) { @@ -832,7 +860,7 @@ contract SpokeBase is Base { .getDynamicReserveConfig(reserveId, vars.dynamicConfigKey) .collateralFactor; - vars.collateralValue = _getValue( + vars.collateralValue = _convertAmountToValue( spoke, reserveId, spoke.getUserSuppliedAssets(reserveId, user) @@ -846,6 +874,8 @@ contract SpokeBase is Base { return 0; } + vars.totalDebtValue = vars.totalDebtValue.fromRayUp(); + // Gather up list of reserves as collateral to sort by collateral risk KeyValueList.List memory reserveCollateralRisk = KeyValueList.init(vars.activeCollateralCount); for (uint256 reserveId; reserveId < vars.reserveCount; ++reserveId) { @@ -862,7 +892,7 @@ contract SpokeBase is Base { // While user's normalized debt amount is non-zero, iterate through supplied reserves, and add up collateral risk while (vars.totalDebtValue > 0 && vars.idx < reserveCollateralRisk.length()) { (uint256 collateralRisk, uint256 reserveId) = reserveCollateralRisk.get(vars.idx); - vars.collateralValue = _getValue( + vars.collateralValue = _convertAmountToValue( spoke, reserveId, spoke.getUserSuppliedAssets(reserveId, user) @@ -882,7 +912,7 @@ contract SpokeBase is Base { ++vars.idx; } - return vars.riskPremium / vars.utilizedSupply; + return _divUp(vars.riskPremium, vars.utilizedSupply); } function _getSpokeDynConfigKeys(ISpoke spoke) internal view returns (DynamicConfig[] memory) { @@ -979,29 +1009,29 @@ contract SpokeBase is Base { revert('not found'); } - function _nextDynamicConfigKey(ISpoke spoke, uint256 reserveId) internal view returns (uint24) { - uint24 dynamicConfigKey = spoke.getReserve(reserveId).dynamicConfigKey; - return (dynamicConfigKey + 1) % type(uint24).max; + function _nextDynamicConfigKey(ISpoke spoke, uint256 reserveId) internal view returns (uint32) { + uint32 dynamicConfigKey = spoke.getReserve(reserveId).dynamicConfigKey; + return (dynamicConfigKey + 1) % type(uint32).max; } function _randomUninitializedConfigKey( ISpoke spoke, uint256 reserveId - ) internal returns (uint24) { - uint24 dynamicConfigKey = _nextDynamicConfigKey(spoke, reserveId); + ) internal returns (uint32) { + uint32 dynamicConfigKey = _nextDynamicConfigKey(spoke, reserveId); if (spoke.getDynamicReserveConfig(reserveId, dynamicConfigKey).maxLiquidationBonus != 0) { revert('no uninitialized config keys'); } - return vm.randomUint(dynamicConfigKey, type(uint24).max).toUint24(); + return vm.randomUint(dynamicConfigKey, type(uint32).max).toUint32(); } - function _randomInitializedConfigKey(ISpoke spoke, uint256 reserveId) internal returns (uint24) { - uint24 dynamicConfigKey = _nextDynamicConfigKey(spoke, reserveId); + function _randomInitializedConfigKey(ISpoke spoke, uint256 reserveId) internal returns (uint32) { + uint32 dynamicConfigKey = _nextDynamicConfigKey(spoke, reserveId); if (spoke.getDynamicReserveConfig(reserveId, dynamicConfigKey).maxLiquidationBonus != 0) { // all config keys are initialized - return vm.randomUint(0, type(uint24).max).toUint16(); + return vm.randomUint(0, type(uint32).max).toUint32(); } - return vm.randomUint(0, spoke.getReserve(reserveId).dynamicConfigKey).toUint16(); + return vm.randomUint(0, spoke.getReserve(reserveId).dynamicConfigKey).toUint32(); } function _maxLiquidationBonusUpperBound( @@ -1009,9 +1039,18 @@ contract SpokeBase is Base { uint256 reserveId ) internal view returns (uint32) { return - (PercentageMath.PERCENTAGE_FACTOR - 1) - .percentDivDown(_getLatestDynamicReserveConfig(spoke, reserveId).collateralFactor) - .toUint32(); + _maxLiquidationBonusUpperBound( + _getLatestDynamicReserveConfig(spoke, reserveId).collateralFactor + ).toUint32(); + } + + function _maxLiquidationBonusUpperBound( + uint256 collateralFactor + ) internal pure returns (uint256) { + return + collateralFactor == 0 + ? MIN_LIQUIDATION_BONUS + : (PercentageMath.PERCENTAGE_FACTOR - 1).percentDivDown(collateralFactor).toUint32(); } function _randomMaxLiquidationBonus(ISpoke spoke, uint256 reserveId) internal returns (uint32) { @@ -1026,13 +1065,17 @@ contract SpokeBase is Base { uint256 reserveId ) internal view returns (uint16) { return - (PercentageMath.PERCENTAGE_FACTOR - 1) - .percentDivDown(_getLatestDynamicReserveConfig(spoke, reserveId).maxLiquidationBonus) - .toUint16(); + _collateralFactorUpperBound( + _getLatestDynamicReserveConfig(spoke, reserveId).maxLiquidationBonus + ); + } + + function _collateralFactorUpperBound(uint256 maxLiquidationBonus) internal pure returns (uint16) { + return (PercentageMath.PERCENTAGE_FACTOR - 1).percentDivDown(maxLiquidationBonus).toUint16(); } function _randomCollateralFactor(ISpoke spoke, uint256 reserveId) internal returns (uint16) { - return vm.randomUint(1, _collateralFactorUpperBound(spoke, reserveId)).toUint16(); + return vm.randomUint(10_00, _collateralFactorUpperBound(spoke, reserveId)).toUint16(); } /// @dev Returns the id of the reserve corresponding to the given Liquidity Hub asset id @@ -1058,17 +1101,15 @@ contract SpokeBase is Base { uint256 desiredHf ) internal returns (uint256, uint256) { uint256 requiredDebtAmount = _getRequiredDebtAmountForHf(spoke, user, reserveId, desiredHf); - require(requiredDebtAmount <= MAX_SUPPLY_AMOUNT, 'required debt amount too high'); + require( + 0 < requiredDebtAmount && requiredDebtAmount <= MAX_SUPPLY_AMOUNT, + 'required debt amount 0 or too high' + ); _borrowWithoutHfCheck(spoke, user, reserveId, requiredDebtAmount); uint256 finalHf = _getUserHealthFactor(spoke, user); - assertApproxEqRel( - finalHf, - desiredHf, - _approxRelFromBps(1), - 'should borrow enough for HF to be ~ desiredHf' - ); + assertApproxEqAbs(finalHf, desiredHf, 0.001e18); return (finalHf, requiredDebtAmount); } @@ -1102,7 +1143,9 @@ contract SpokeBase is Base { uint256 reserveId, uint256 debtAmount ) internal { - address mockSpoke = address(new MockSpoke(spoke.ORACLE())); + address mockSpoke = address( + new MockSpoke(spoke.ORACLE(), Constants.MAX_ALLOWED_USER_RESERVES_LIMIT) + ); address implementation = _getImplementationAddress(address(spoke)); @@ -1125,4 +1168,11 @@ contract SpokeBase is Base { wbtc: _wbtcReserveId(spoke) }); } + + /// @dev Helper to etch spoke's implementation with a new maxUserReservesLimit + function _updateMaxUserReservesLimit(ISpoke spoke, uint16 newLimit) internal { + address currentImpl = _getImplementationAddress(address(spoke)); + ISpokeInstance newImpl = DeployUtils.deploySpokeImplementation(spoke.ORACLE(), newLimit); + vm.etch(currentImpl, address(newImpl).code); + } } diff --git a/tests/unit/Spoke/TreasurySpoke.t.sol b/tests/unit/Spoke/TreasurySpoke.t.sol index 381c59695..62263c032 100644 --- a/tests/unit/Spoke/TreasurySpoke.t.sol +++ b/tests/unit/Spoke/TreasurySpoke.t.sol @@ -189,9 +189,9 @@ contract TreasurySpokeTest is SpokeBase { uint256 amount, uint256 skipTime ) public { - amount = bound(amount, 1, MAX_SUPPLY_AMOUNT); - skipTime = bound(skipTime, 1, MAX_SKIP_TIME); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); + amount = bound(amount, 1, _calculateMaxSupplyAmount(spoke1, reserveId)); + skipTime = bound(skipTime, 1, MAX_SKIP_TIME); uint256 assetId = spoke1.getReserve(reserveId).assetId; updateLiquidityFee(hub1, spoke1.getReserve(reserveId).assetId, 100_00); @@ -272,6 +272,7 @@ contract TreasurySpokeTest is SpokeBase { assertEq(drawn, 0); assertEq(premium, 0); assertEq(treasurySpoke.getUserTotalDebt(reserveId, alice), 0); + assertEq(treasurySpoke.getUserPremiumDebtRay(reserveId, alice), 0); updateLiquidityFee(hub1, spoke1.getReserve(reserveId).assetId, 100_00); diff --git a/tests/unit/SpokeConfigurator.GranularAccessControl.t.sol b/tests/unit/SpokeConfigurator.GranularAccessControl.t.sol new file mode 100644 index 000000000..3d3471eed --- /dev/null +++ b/tests/unit/SpokeConfigurator.GranularAccessControl.t.sol @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/Spoke/SpokeBase.t.sol'; + +contract SpokeConfiguratorGranularAccessControlTest is SpokeBase { + using SafeCast for uint256; + + // Granular role constants + uint64 constant RESERVE_MANAGER_ROLE = 102; + uint64 constant LIQUIDATION_CONFIG_MANAGER_ROLE = 103; + uint64 constant POSITION_MANAGER_ADMIN_ROLE = 104; + + // Role holders + address RESERVE_MANAGER = makeAddr('RESERVE_MANAGER'); + address LIQUIDATION_CONFIG_MANAGER = makeAddr('LIQUIDATION_CONFIG_MANAGER'); + address POSITION_MANAGER_ADMIN = makeAddr('POSITION_MANAGER_ADMIN'); + + SpokeConfigurator spokeConfigurator; + IAccessManager manager; + + address spokeAddr; + ISpoke spoke; + uint256 reserveId; + + // Arrays storing calldata for each role's functions + bytes[] internal reserveManagerCalldata; + bytes[] internal liquidationConfigManagerCalldata; + bytes[] internal positionManagerAdminCalldata; + + function setUp() public virtual override { + super.setUp(); + + manager = IAccessManager(spoke1.authority()); + spokeConfigurator = new SpokeConfigurator(address(manager)); + + // Grant SPOKE_ADMIN_ROLE to spokeConfigurator so it can call spoke functions + vm.startPrank(ADMIN); + manager.grantRole(Roles.SPOKE_ADMIN_ROLE, address(spokeConfigurator), 0); + + // Grant granular roles to role holders + manager.grantRole(RESERVE_MANAGER_ROLE, RESERVE_MANAGER, 0); + manager.grantRole(LIQUIDATION_CONFIG_MANAGER_ROLE, LIQUIDATION_CONFIG_MANAGER, 0); + manager.grantRole(POSITION_MANAGER_ADMIN_ROLE, POSITION_MANAGER_ADMIN, 0); + + // Set up RESERVE_MANAGER_ROLE permissions (19 functions) + bytes4[] memory reserveSelectors = new bytes4[](19); + reserveSelectors[0] = ISpokeConfigurator.updateReservePriceSource.selector; + reserveSelectors[1] = ISpokeConfigurator.updateMaxReserves.selector; + reserveSelectors[2] = ISpokeConfigurator.addReserve.selector; + reserveSelectors[3] = ISpokeConfigurator.updatePaused.selector; + reserveSelectors[4] = ISpokeConfigurator.updateFrozen.selector; + reserveSelectors[5] = ISpokeConfigurator.updateBorrowable.selector; + reserveSelectors[7] = ISpokeConfigurator.updateReceiveSharesEnabled.selector; + reserveSelectors[8] = ISpokeConfigurator.updateCollateralRisk.selector; + reserveSelectors[9] = ISpokeConfigurator.addCollateralFactor.selector; + reserveSelectors[10] = ISpokeConfigurator.updateCollateralFactor.selector; + reserveSelectors[11] = ISpokeConfigurator.addMaxLiquidationBonus.selector; + reserveSelectors[12] = ISpokeConfigurator.updateMaxLiquidationBonus.selector; + reserveSelectors[13] = ISpokeConfigurator.addLiquidationFee.selector; + reserveSelectors[14] = ISpokeConfigurator.updateLiquidationFee.selector; + reserveSelectors[15] = ISpokeConfigurator.addDynamicReserveConfig.selector; + reserveSelectors[16] = ISpokeConfigurator.updateDynamicReserveConfig.selector; + reserveSelectors[17] = ISpokeConfigurator.pauseAllReserves.selector; + reserveSelectors[18] = ISpokeConfigurator.freezeAllReserves.selector; + manager.setTargetFunctionRole( + address(spokeConfigurator), + reserveSelectors, + RESERVE_MANAGER_ROLE + ); + + // Set up LIQUIDATION_CONFIG_MANAGER_ROLE permissions (4 functions) + bytes4[] memory liqSelectors = new bytes4[](4); + liqSelectors[0] = ISpokeConfigurator.updateLiquidationTargetHealthFactor.selector; + liqSelectors[1] = ISpokeConfigurator.updateHealthFactorForMaxBonus.selector; + liqSelectors[2] = ISpokeConfigurator.updateLiquidationBonusFactor.selector; + liqSelectors[3] = ISpokeConfigurator.updateLiquidationConfig.selector; + manager.setTargetFunctionRole( + address(spokeConfigurator), + liqSelectors, + LIQUIDATION_CONFIG_MANAGER_ROLE + ); + + // Set up POSITION_MANAGER_ADMIN_ROLE permissions (1 function) + bytes4[] memory pmSelectors = new bytes4[](1); + pmSelectors[0] = ISpokeConfigurator.updatePositionManager.selector; + manager.setTargetFunctionRole( + address(spokeConfigurator), + pmSelectors, + POSITION_MANAGER_ADMIN_ROLE + ); + + vm.stopPrank(); + + // Set up test data + spokeAddr = address(spoke1); + spoke = ISpoke(spokeAddr); + reserveId = 0; + + // Set max reserves to allow operations + vm.prank(RESERVE_MANAGER); + spokeConfigurator.updateMaxReserves(spokeAddr, 10); + + // Build calldata arrays for testing + _buildReserveManagerCalldata(); + _buildLiquidationConfigManagerCalldata(); + _buildPositionManagerAdminCalldata(); + } + + function _buildReserveManagerCalldata() internal { + address newPriceSource = _deployMockPriceFeed(spoke, 1000e8); + ISpoke.DynamicReserveConfig memory dynamicConfig = ISpoke.DynamicReserveConfig({ + collateralFactor: 80_00, + maxLiquidationBonus: 110_00, + liquidationFee: 5_00 + }); + + reserveManagerCalldata.push( + abi.encodeCall( + ISpokeConfigurator.updateReservePriceSource, + (spokeAddr, reserveId, newPriceSource) + ) + ); + reserveManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.updateMaxReserves, (spokeAddr, 20)) + ); + // Skipping addReserve as it requires more complex setup + reserveManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.updatePaused, (spokeAddr, reserveId, true)) + ); + reserveManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.updateFrozen, (spokeAddr, reserveId, true)) + ); + reserveManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.updateBorrowable, (spokeAddr, reserveId, false)) + ); + reserveManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.updateReceiveSharesEnabled, (spokeAddr, reserveId, false)) + ); + reserveManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.updateCollateralRisk, (spokeAddr, reserveId, 50_00)) + ); + reserveManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.addCollateralFactor, (spokeAddr, reserveId, 75_00)) + ); + reserveManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.updateCollateralFactor, (spokeAddr, reserveId, 0, 70_00)) + ); + reserveManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.addMaxLiquidationBonus, (spokeAddr, reserveId, 115_00)) + ); + reserveManagerCalldata.push( + abi.encodeCall( + ISpokeConfigurator.updateMaxLiquidationBonus, + (spokeAddr, reserveId, 0, 112_00) + ) + ); + reserveManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.addLiquidationFee, (spokeAddr, reserveId, 8_00)) + ); + reserveManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.updateLiquidationFee, (spokeAddr, reserveId, 0, 6_00)) + ); + reserveManagerCalldata.push( + abi.encodeCall( + ISpokeConfigurator.addDynamicReserveConfig, + (spokeAddr, reserveId, dynamicConfig) + ) + ); + reserveManagerCalldata.push( + abi.encodeCall( + ISpokeConfigurator.updateDynamicReserveConfig, + (spokeAddr, reserveId, 0, dynamicConfig) + ) + ); + reserveManagerCalldata.push(abi.encodeCall(ISpokeConfigurator.pauseAllReserves, (spokeAddr))); + reserveManagerCalldata.push(abi.encodeCall(ISpokeConfigurator.freezeAllReserves, (spokeAddr))); + } + + function _buildLiquidationConfigManagerCalldata() internal { + ISpoke.LiquidationConfig memory newConfig = ISpoke.LiquidationConfig({ + targetHealthFactor: Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD * 2, + healthFactorForMaxBonus: Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD / 2, + liquidationBonusFactor: 50_00 + }); + + liquidationConfigManagerCalldata.push( + abi.encodeCall( + ISpokeConfigurator.updateLiquidationTargetHealthFactor, + (spokeAddr, Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD * 2) + ) + ); + liquidationConfigManagerCalldata.push( + abi.encodeCall( + ISpokeConfigurator.updateHealthFactorForMaxBonus, + (spokeAddr, Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD / 2) + ) + ); + liquidationConfigManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.updateLiquidationBonusFactor, (spokeAddr, 50_00)) + ); + liquidationConfigManagerCalldata.push( + abi.encodeCall(ISpokeConfigurator.updateLiquidationConfig, (spokeAddr, newConfig)) + ); + } + + function _buildPositionManagerAdminCalldata() internal { + address newPM = makeAddr('NEW_POSITION_MANAGER'); + + positionManagerAdminCalldata.push( + abi.encodeCall(ISpokeConfigurator.updatePositionManager, (spokeAddr, newPM, true)) + ); + } + + function test_fuzz_unauthorized_cannotCall_reserveManagerMethods(address caller) public { + vm.assume(caller != RESERVE_MANAGER); + vm.assume(caller != address(0)); + + for (uint256 i = 0; i < reserveManagerCalldata.length; ++i) { + vm.prank(caller); + (bool ok, bytes memory ret) = address(spokeConfigurator).call(reserveManagerCalldata[i]); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, caller) + ); + } + } + + function test_fuzz_unauthorized_cannotCall_liquidationConfigManagerMethods( + address caller + ) public { + vm.assume(caller != LIQUIDATION_CONFIG_MANAGER); + vm.assume(caller != address(0)); + + for (uint256 i = 0; i < liquidationConfigManagerCalldata.length; ++i) { + vm.prank(caller); + (bool ok, bytes memory ret) = address(spokeConfigurator).call( + liquidationConfigManagerCalldata[i] + ); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, caller) + ); + } + } + + function test_fuzz_unauthorized_cannotCall_positionManagerAdminMethods(address caller) public { + vm.assume(caller != POSITION_MANAGER_ADMIN); + vm.assume(caller != address(0)); + + for (uint256 i = 0; i < positionManagerAdminCalldata.length; ++i) { + vm.prank(caller); + (bool ok, bytes memory ret) = address(spokeConfigurator).call( + positionManagerAdminCalldata[i] + ); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, caller) + ); + } + } + + function test_reserveManager_cannotCall_anyLiquidationConfigMethod() public { + for (uint256 i = 0; i < liquidationConfigManagerCalldata.length; ++i) { + vm.prank(RESERVE_MANAGER); + (bool ok, bytes memory ret) = address(spokeConfigurator).call( + liquidationConfigManagerCalldata[i] + ); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, RESERVE_MANAGER) + ); + } + } + + function test_reserveManager_cannotCall_anyPositionManagerAdminMethod() public { + for (uint256 i = 0; i < positionManagerAdminCalldata.length; ++i) { + vm.prank(RESERVE_MANAGER); + (bool ok, bytes memory ret) = address(spokeConfigurator).call( + positionManagerAdminCalldata[i] + ); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, RESERVE_MANAGER) + ); + } + } + + function test_liquidationConfigManager_cannotCall_anyReserveMethod() public { + for (uint256 i = 0; i < reserveManagerCalldata.length; ++i) { + vm.prank(LIQUIDATION_CONFIG_MANAGER); + (bool ok, bytes memory ret) = address(spokeConfigurator).call(reserveManagerCalldata[i]); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector( + IAccessManaged.AccessManagedUnauthorized.selector, + LIQUIDATION_CONFIG_MANAGER + ) + ); + } + } + + function test_liquidationConfigManager_cannotCall_anyPositionManagerAdminMethod() public { + for (uint256 i = 0; i < positionManagerAdminCalldata.length; ++i) { + vm.prank(LIQUIDATION_CONFIG_MANAGER); + (bool ok, bytes memory ret) = address(spokeConfigurator).call( + positionManagerAdminCalldata[i] + ); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector( + IAccessManaged.AccessManagedUnauthorized.selector, + LIQUIDATION_CONFIG_MANAGER + ) + ); + } + } + + function test_positionManagerAdmin_cannotCall_anyReserveMethod() public { + for (uint256 i = 0; i < reserveManagerCalldata.length; ++i) { + vm.prank(POSITION_MANAGER_ADMIN); + (bool ok, bytes memory ret) = address(spokeConfigurator).call(reserveManagerCalldata[i]); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector( + IAccessManaged.AccessManagedUnauthorized.selector, + POSITION_MANAGER_ADMIN + ) + ); + } + } + + function test_positionManagerAdmin_cannotCall_anyLiquidationConfigMethod() public { + for (uint256 i = 0; i < liquidationConfigManagerCalldata.length; ++i) { + vm.prank(POSITION_MANAGER_ADMIN); + (bool ok, bytes memory ret) = address(spokeConfigurator).call( + liquidationConfigManagerCalldata[i] + ); + assertFalse(ok); + assertEq( + ret, + abi.encodeWithSelector( + IAccessManaged.AccessManagedUnauthorized.selector, + POSITION_MANAGER_ADMIN + ) + ); + } + } + + function test_reserveManager_canCall_updatePaused() public { + vm.prank(RESERVE_MANAGER); + spokeConfigurator.updatePaused(spokeAddr, reserveId, true); + + assertTrue(spoke.getReserveConfig(reserveId).paused); + } + + function test_reserveManager_canCall_updateFrozen() public { + vm.prank(RESERVE_MANAGER); + spokeConfigurator.updateFrozen(spokeAddr, reserveId, true); + + assertTrue(spoke.getReserveConfig(reserveId).frozen); + } + + function test_reserveManager_canCall_pauseAllReserves() public { + vm.prank(RESERVE_MANAGER); + spokeConfigurator.pauseAllReserves(spokeAddr); + + for (uint256 i = 0; i < spoke.getReserveCount(); ++i) { + assertTrue(spoke.getReserveConfig(i).paused); + } + } + + function test_reserveManager_canCall_freezeAllReserves() public { + vm.prank(RESERVE_MANAGER); + spokeConfigurator.freezeAllReserves(spokeAddr); + + for (uint256 i = 0; i < spoke.getReserveCount(); ++i) { + assertTrue(spoke.getReserveConfig(i).frozen); + } + } + + function test_liquidationConfigManager_canCall_updateLiquidationTargetHealthFactor() public { + uint128 newTarget = Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD * 2; + + vm.prank(LIQUIDATION_CONFIG_MANAGER); + spokeConfigurator.updateLiquidationTargetHealthFactor(spokeAddr, newTarget); + + assertEq(spoke.getLiquidationConfig().targetHealthFactor, newTarget); + } + + function test_liquidationConfigManager_canCall_updateLiquidationConfig() public { + ISpoke.LiquidationConfig memory newConfig = ISpoke.LiquidationConfig({ + targetHealthFactor: Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD * 2, + healthFactorForMaxBonus: Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD / 2, + liquidationBonusFactor: 50_00 + }); + + vm.prank(LIQUIDATION_CONFIG_MANAGER); + spokeConfigurator.updateLiquidationConfig(spokeAddr, newConfig); + + assertEq(spoke.getLiquidationConfig(), newConfig); + } + + function test_positionManagerAdmin_canCall_updatePositionManager() public { + address newPM = makeAddr('NEW_POSITION_MANAGER'); + + vm.prank(POSITION_MANAGER_ADMIN); + spokeConfigurator.updatePositionManager(spokeAddr, newPM, true); + + assertTrue(spoke.isPositionManagerActive(newPM)); + } +} diff --git a/tests/unit/SpokeConfigurator.t.sol b/tests/unit/SpokeConfigurator.t.sol index 30e97f21a..39dc37106 100644 --- a/tests/unit/SpokeConfigurator.t.sol +++ b/tests/unit/SpokeConfigurator.t.sol @@ -8,54 +8,48 @@ contract SpokeConfiguratorTest is SpokeBase { using SafeCast for uint256; SpokeConfigurator public spokeConfigurator; - address public SPOKE_CONFIGURATOR_ADMIN = makeAddr('SPOKE_CONFIGURATOR_ADMIN'); address public spokeAddr; ISpoke public spoke; - uint256 public _reserveId; + uint256 public reserveId; uint256 public invalidReserveId; function setUp() public virtual override { super.setUp(); - spokeConfigurator = new SpokeConfigurator(SPOKE_CONFIGURATOR_ADMIN); + spokeConfigurator = new SpokeConfigurator(spoke1.authority()); + setUpSpokeConfiguratorRoles(address(spokeConfigurator), spoke1.authority()); + spokeAddr = address(spoke1); spoke = ISpoke(spokeAddr); - _reserveId = 0; + reserveId = 0; invalidReserveId = spoke.getReserveCount(); - - // Grant spokeConfigurator spoke admin role with 0 delay - vm.startPrank(ADMIN); - IAccessManager(spoke1.authority()).grantRole( - Roles.SPOKE_ADMIN_ROLE, - address(spokeConfigurator), - 0 - ); - vm.stopPrank(); } - function test_updateReservePriceSource_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateReservePriceSource_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - spokeConfigurator.updateReservePriceSource(spokeAddr, _reserveId, address(0)); + spokeConfigurator.updateReservePriceSource(spokeAddr, reserveId, address(0)); } function test_updateReservePriceSource() public { address newPriceSource = _deployMockPriceFeed(spoke, 1000e8); vm.expectCall( spokeAddr, - abi.encodeCall(ISpoke.updateReservePriceSource, (_reserveId, newPriceSource)) + abi.encodeCall(ISpoke.updateReservePriceSource, (reserveId, newPriceSource)) ); vm.expectEmit(address(spoke)); - emit ISpoke.UpdateReservePriceSource(_reserveId, newPriceSource); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); - spokeConfigurator.updateReservePriceSource(spokeAddr, _reserveId, newPriceSource); + emit ISpoke.UpdateReservePriceSource(reserveId, newPriceSource); + vm.prank(SPOKE_CONFIGURATOR); + spokeConfigurator.updateReservePriceSource(spokeAddr, reserveId, newPriceSource); } - function test_updateLiquidationTargetHealthFactor_revertsWith_OwnableUnauthorizedAccount() - public - { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateLiquidationTargetHealthFactor_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); spokeConfigurator.updateLiquidationTargetHealthFactor(spokeAddr, 0); } @@ -72,14 +66,16 @@ contract SpokeConfiguratorTest is SpokeBase { ); vm.expectEmit(address(spoke)); emit ISpoke.UpdateLiquidationConfig(expectedLiquidationConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updateLiquidationTargetHealthFactor(spokeAddr, newTargetHealthFactor); assertEq(spoke.getLiquidationConfig(), expectedLiquidationConfig); } - function test_updateHealthFactorForMaxBonus_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateHealthFactorForMaxBonus_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); spokeConfigurator.updateHealthFactorForMaxBonus(spokeAddr, 0); } @@ -96,14 +92,16 @@ contract SpokeConfiguratorTest is SpokeBase { ); vm.expectEmit(address(spoke)); emit ISpoke.UpdateLiquidationConfig(expectedLiquidationConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updateHealthFactorForMaxBonus(spokeAddr, newHealthFactorForMaxBonus); assertEq(spoke.getLiquidationConfig().healthFactorForMaxBonus, newHealthFactorForMaxBonus); } - function test_updateLiquidationBonusFactor_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateLiquidationBonusFactor_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); spokeConfigurator.updateLiquidationBonusFactor(spokeAddr, 0); } @@ -120,14 +118,16 @@ contract SpokeConfiguratorTest is SpokeBase { ); vm.expectEmit(address(spoke)); emit ISpoke.UpdateLiquidationConfig(expectedLiquidationConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updateLiquidationBonusFactor(spokeAddr, newLiquidationBonusFactor); assertEq(spoke.getLiquidationConfig(), expectedLiquidationConfig); } - function test_updateLiquidationConfig_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateLiquidationConfig_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); spokeConfigurator.updateLiquidationConfig( spokeAddr, @@ -152,14 +152,16 @@ contract SpokeConfiguratorTest is SpokeBase { ); vm.expectEmit(address(spoke)); emit ISpoke.UpdateLiquidationConfig(newLiquidationConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updateLiquidationConfig(spokeAddr, newLiquidationConfig); assertEq(spoke.getLiquidationConfig(), newLiquidationConfig); } - function test_updateMaxReserves_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateMaxReserves_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); spokeConfigurator.updateMaxReserves(spokeAddr, 0); } @@ -168,14 +170,16 @@ contract SpokeConfiguratorTest is SpokeBase { uint256 newMaxReserves = vm.randomUint(); vm.expectEmit(address(spokeConfigurator)); emit ISpokeConfigurator.UpdateMaxReserves(spokeAddr, newMaxReserves); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updateMaxReserves(spokeAddr, newMaxReserves); assertEq(spokeConfigurator.getMaxReserves(spokeAddr), newMaxReserves); } - function test_addReserve_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_addReserve_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); spokeConfigurator.addReserve({ spoke: spokeAddr, @@ -193,7 +197,7 @@ contract SpokeConfiguratorTest is SpokeBase { function test_addReserve_revertsWith_MaximumReservesReached() public { uint256 maxReserves = vm.randomUint(0, spoke.getReserveCount()); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updateMaxReserves(spokeAddr, maxReserves); address newPriceSource = _deployMockPriceFeed(spoke, 1000e8); @@ -204,7 +208,7 @@ contract SpokeConfiguratorTest is SpokeBase { maxReserves ) ); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.addReserve({ spoke: spokeAddr, hub: address(hub1), @@ -222,7 +226,7 @@ contract SpokeConfiguratorTest is SpokeBase { function test_addReserve() public { uint256 expectedReserveId = spoke.getReserveCount(); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updateMaxReserves(spokeAddr, expectedReserveId + 1); address newPriceSource = _deployMockPriceFeed(spoke, 1000e8); @@ -246,7 +250,7 @@ contract SpokeConfiguratorTest is SpokeBase { emit ISpoke.UpdateReserveConfig(expectedReserveId, config); vm.expectEmit(address(spoke)); emit ISpoke.AddDynamicReserveConfig(expectedReserveId, 0, dynamicConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); uint256 actualReserveId = spokeConfigurator.addReserve({ spoke: spokeAddr, hub: address(hub1), @@ -259,213 +263,194 @@ contract SpokeConfiguratorTest is SpokeBase { assertEq(actualReserveId, expectedReserveId); } - function test_updatePaused_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updatePaused_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - spokeConfigurator.updatePaused(spokeAddr, _reserveId, true); + spokeConfigurator.updatePaused(spokeAddr, reserveId, true); } function test_updatePaused() public { - ISpoke.ReserveConfig memory expectedReserveConfig = spoke.getReserveConfig(_reserveId); + ISpoke.ReserveConfig memory expectedReserveConfig = spoke.getReserveConfig(reserveId); for (uint256 i = 0; i < 2; i += 1) { expectedReserveConfig.paused = (i == 0) ? false : true; vm.expectCall( spokeAddr, - abi.encodeCall(ISpoke.updateReserveConfig, (_reserveId, expectedReserveConfig)) + abi.encodeCall(ISpoke.updateReserveConfig, (reserveId, expectedReserveConfig)) ); vm.expectEmit(address(spoke)); - emit ISpoke.UpdateReserveConfig(_reserveId, expectedReserveConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); - spokeConfigurator.updatePaused(spokeAddr, _reserveId, expectedReserveConfig.paused); + emit ISpoke.UpdateReserveConfig(reserveId, expectedReserveConfig); + vm.prank(SPOKE_CONFIGURATOR); + spokeConfigurator.updatePaused(spokeAddr, reserveId, expectedReserveConfig.paused); - assertEq(spoke.getReserveConfig(_reserveId), expectedReserveConfig); + assertEq(spoke.getReserveConfig(reserveId), expectedReserveConfig); } } - function test_updateFrozen_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateFrozen_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - spokeConfigurator.updateFrozen(spokeAddr, _reserveId, true); + spokeConfigurator.updateFrozen(spokeAddr, reserveId, true); } function test_updateFrozen() public { - ISpoke.ReserveConfig memory expectedReserveConfig = spoke.getReserveConfig(_reserveId); + ISpoke.ReserveConfig memory expectedReserveConfig = spoke.getReserveConfig(reserveId); for (uint256 i = 0; i < 2; i += 1) { expectedReserveConfig.frozen = (i == 0) ? false : true; vm.expectCall( spokeAddr, - abi.encodeCall(ISpoke.updateReserveConfig, (_reserveId, expectedReserveConfig)) + abi.encodeCall(ISpoke.updateReserveConfig, (reserveId, expectedReserveConfig)) ); vm.expectEmit(address(spoke)); - emit ISpoke.UpdateReserveConfig(_reserveId, expectedReserveConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); - spokeConfigurator.updateFrozen(spokeAddr, _reserveId, expectedReserveConfig.frozen); + emit ISpoke.UpdateReserveConfig(reserveId, expectedReserveConfig); + vm.prank(SPOKE_CONFIGURATOR); + spokeConfigurator.updateFrozen(spokeAddr, reserveId, expectedReserveConfig.frozen); - assertEq(spoke.getReserveConfig(_reserveId), expectedReserveConfig); + assertEq(spoke.getReserveConfig(reserveId), expectedReserveConfig); } } - function test_updateBorrowable_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateBorrowable_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - spokeConfigurator.updateBorrowable(spokeAddr, _reserveId, true); + spokeConfigurator.updateBorrowable(spokeAddr, reserveId, true); } function test_updateBorrowable() public { - ISpoke.ReserveConfig memory expectedReserveConfig = spoke.getReserveConfig(_reserveId); + ISpoke.ReserveConfig memory expectedReserveConfig = spoke.getReserveConfig(reserveId); for (uint256 i = 0; i < 2; i += 1) { expectedReserveConfig.borrowable = (i == 0) ? false : true; vm.expectCall( spokeAddr, - abi.encodeCall(ISpoke.updateReserveConfig, (_reserveId, expectedReserveConfig)) - ); - vm.expectEmit(address(spoke)); - emit ISpoke.UpdateReserveConfig(_reserveId, expectedReserveConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); - spokeConfigurator.updateBorrowable(spokeAddr, _reserveId, expectedReserveConfig.borrowable); - - assertEq(spoke.getReserveConfig(_reserveId), expectedReserveConfig); - } - } - - function test_updateLiquidatable_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); - vm.prank(alice); - spokeConfigurator.updateLiquidatable(spokeAddr, _reserveId, true); - } - - function test_updateLiquidatable() public { - ISpoke.ReserveConfig memory expectedReserveConfig = spoke.getReserveConfig(_reserveId); - - for (uint256 i = 0; i < 2; i += 1) { - expectedReserveConfig.liquidatable = (i == 0) ? false : true; - - vm.expectCall( - spokeAddr, - abi.encodeCall(ISpoke.updateReserveConfig, (_reserveId, expectedReserveConfig)) + abi.encodeCall(ISpoke.updateReserveConfig, (reserveId, expectedReserveConfig)) ); vm.expectEmit(address(spoke)); - emit ISpoke.UpdateReserveConfig(_reserveId, expectedReserveConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); - spokeConfigurator.updateLiquidatable( - spokeAddr, - _reserveId, - expectedReserveConfig.liquidatable - ); + emit ISpoke.UpdateReserveConfig(reserveId, expectedReserveConfig); + vm.prank(SPOKE_CONFIGURATOR); + spokeConfigurator.updateBorrowable(spokeAddr, reserveId, expectedReserveConfig.borrowable); - assertEq(spoke.getReserveConfig(_reserveId), expectedReserveConfig); + assertEq(spoke.getReserveConfig(reserveId), expectedReserveConfig); } } - function test_updateReceiveSharesEnabled_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateReceiveSharesEnabled_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - spokeConfigurator.updateReceiveSharesEnabled(spokeAddr, _reserveId, false); + spokeConfigurator.updateReceiveSharesEnabled(spokeAddr, reserveId, false); } function test_updateReceiveSharesEnabled() public { - ISpoke.ReserveConfig memory expectedReserveConfig = spoke.getReserveConfig(_reserveId); + ISpoke.ReserveConfig memory expectedReserveConfig = spoke.getReserveConfig(reserveId); for (uint256 i = 0; i < 2; i += 1) { expectedReserveConfig.receiveSharesEnabled = (i == 0) ? false : true; vm.expectCall( spokeAddr, - abi.encodeCall(ISpoke.updateReserveConfig, (_reserveId, expectedReserveConfig)) + abi.encodeCall(ISpoke.updateReserveConfig, (reserveId, expectedReserveConfig)) ); vm.expectEmit(address(spoke)); - emit ISpoke.UpdateReserveConfig(_reserveId, expectedReserveConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + emit ISpoke.UpdateReserveConfig(reserveId, expectedReserveConfig); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updateReceiveSharesEnabled( spokeAddr, - _reserveId, + reserveId, expectedReserveConfig.receiveSharesEnabled ); - assertEq(spoke.getReserveConfig(_reserveId), expectedReserveConfig); + assertEq(spoke.getReserveConfig(reserveId), expectedReserveConfig); } } - function test_updateCollateralRisk_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateCollateralRisk_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - spokeConfigurator.updateCollateralRisk(spokeAddr, _reserveId, 0); + spokeConfigurator.updateCollateralRisk(spokeAddr, reserveId, 0); } function test_updateCollateralRisk() public { uint24 newCollateralRisk = Constants.MAX_ALLOWED_COLLATERAL_RISK / 2; - ISpoke.ReserveConfig memory expectedReserveConfig = spoke.getReserveConfig(_reserveId); + ISpoke.ReserveConfig memory expectedReserveConfig = spoke.getReserveConfig(reserveId); expectedReserveConfig.collateralRisk = newCollateralRisk; vm.expectCall( spokeAddr, - abi.encodeCall(ISpoke.updateReserveConfig, (_reserveId, expectedReserveConfig)) + abi.encodeCall(ISpoke.updateReserveConfig, (reserveId, expectedReserveConfig)) ); vm.expectEmit(address(spoke)); - emit ISpoke.UpdateReserveConfig(_reserveId, expectedReserveConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); - spokeConfigurator.updateCollateralRisk(spokeAddr, _reserveId, newCollateralRisk); + emit ISpoke.UpdateReserveConfig(reserveId, expectedReserveConfig); + vm.prank(SPOKE_CONFIGURATOR); + spokeConfigurator.updateCollateralRisk(spokeAddr, reserveId, newCollateralRisk); - assertEq(spoke.getReserveConfig(_reserveId), expectedReserveConfig); + assertEq(spoke.getReserveConfig(reserveId), expectedReserveConfig); } - function test_addCollateralFactor_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_addCollateralFactor_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - spokeConfigurator.addCollateralFactor(spokeAddr, _reserveId, 0); + spokeConfigurator.addCollateralFactor(spokeAddr, reserveId, 0); } function test_addCollateralFactor() public { uint16 newCollateralFactor = PercentageMath.PERCENTAGE_FACTOR.toUint16() / 2; ISpoke.DynamicReserveConfig - memory expectedDynamicReserveConfig = _getLatestDynamicReserveConfig(spoke, _reserveId); + memory expectedDynamicReserveConfig = _getLatestDynamicReserveConfig(spoke, reserveId); expectedDynamicReserveConfig.collateralFactor = newCollateralFactor; - uint24 expectedConfigKey = _nextDynamicConfigKey(spoke, _reserveId); + uint32 expectedConfigKey = _nextDynamicConfigKey(spoke, reserveId); vm.expectCall( spokeAddr, - abi.encodeCall(ISpoke.addDynamicReserveConfig, (_reserveId, expectedDynamicReserveConfig)) + abi.encodeCall(ISpoke.addDynamicReserveConfig, (reserveId, expectedDynamicReserveConfig)) ); vm.expectEmit(address(spoke)); - emit ISpoke.AddDynamicReserveConfig( - _reserveId, - expectedConfigKey, - expectedDynamicReserveConfig - ); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); - uint24 dynamicConfigKey = spokeConfigurator.addCollateralFactor( + emit ISpoke.AddDynamicReserveConfig(reserveId, expectedConfigKey, expectedDynamicReserveConfig); + vm.prank(SPOKE_CONFIGURATOR); + uint32 dynamicConfigKey = spokeConfigurator.addCollateralFactor( spokeAddr, - _reserveId, + reserveId, newCollateralFactor ); assertEq(dynamicConfigKey, expectedConfigKey); - assertEq(spoke.getReserve(_reserveId).dynamicConfigKey, expectedConfigKey); - assertEq(_getLatestDynamicReserveConfig(spoke, _reserveId), expectedDynamicReserveConfig); + assertEq(spoke.getReserve(reserveId).dynamicConfigKey, expectedConfigKey); + assertEq(_getLatestDynamicReserveConfig(spoke, reserveId), expectedDynamicReserveConfig); } - function test_updateCollateralFactor_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateCollateralFactor_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - spokeConfigurator.updateCollateralFactor(spokeAddr, _reserveId, 0, 0); + spokeConfigurator.updateCollateralFactor(spokeAddr, reserveId, 0, 0); } function test_updateCollateralFactor() public { uint16 newCollateralFactor = PercentageMath.PERCENTAGE_FACTOR.toUint16() / 4; - uint24 dynamicConfigKey = 0; + uint32 dynamicConfigKey = 0; ISpoke.DynamicReserveConfig memory expectedDynamicReserveConfig = spoke.getDynamicReserveConfig( - _reserveId, + reserveId, dynamicConfigKey ); expectedDynamicReserveConfig.collateralFactor = newCollateralFactor; @@ -474,79 +459,79 @@ contract SpokeConfiguratorTest is SpokeBase { spokeAddr, abi.encodeCall( ISpoke.updateDynamicReserveConfig, - (_reserveId, dynamicConfigKey, expectedDynamicReserveConfig) + (reserveId, dynamicConfigKey, expectedDynamicReserveConfig) ) ); vm.expectEmit(address(spoke)); emit ISpoke.UpdateDynamicReserveConfig( - _reserveId, + reserveId, dynamicConfigKey, expectedDynamicReserveConfig ); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updateCollateralFactor( spokeAddr, - _reserveId, + reserveId, dynamicConfigKey, newCollateralFactor ); assertEq( - spoke.getDynamicReserveConfig(_reserveId, dynamicConfigKey), + spoke.getDynamicReserveConfig(reserveId, dynamicConfigKey), expectedDynamicReserveConfig ); } - function test_addLiquidationBonus_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_addLiquidationBonus_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - spokeConfigurator.addMaxLiquidationBonus(spokeAddr, _reserveId, 0); + spokeConfigurator.addMaxLiquidationBonus(spokeAddr, reserveId, 0); } function test_addMaxLiquidationBonus() public { uint32 newLiquidationBonus = PercentageMath.PERCENTAGE_FACTOR.toUint32() + 1; ISpoke.DynamicReserveConfig - memory expectedDynamicReserveConfig = _getLatestDynamicReserveConfig(spoke, _reserveId); + memory expectedDynamicReserveConfig = _getLatestDynamicReserveConfig(spoke, reserveId); expectedDynamicReserveConfig.maxLiquidationBonus = newLiquidationBonus; - uint24 expectedConfigKey = _nextDynamicConfigKey(spoke, _reserveId); + uint32 expectedConfigKey = _nextDynamicConfigKey(spoke, reserveId); vm.expectCall( spokeAddr, - abi.encodeCall(ISpoke.addDynamicReserveConfig, (_reserveId, expectedDynamicReserveConfig)) + abi.encodeCall(ISpoke.addDynamicReserveConfig, (reserveId, expectedDynamicReserveConfig)) ); vm.expectEmit(address(spoke)); - emit ISpoke.AddDynamicReserveConfig( - _reserveId, - expectedConfigKey, - expectedDynamicReserveConfig - ); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); - uint24 dynamicConfigKey = spokeConfigurator.addMaxLiquidationBonus( + emit ISpoke.AddDynamicReserveConfig(reserveId, expectedConfigKey, expectedDynamicReserveConfig); + vm.prank(SPOKE_CONFIGURATOR); + uint32 dynamicConfigKey = spokeConfigurator.addMaxLiquidationBonus( spokeAddr, - _reserveId, + reserveId, newLiquidationBonus ); assertEq(dynamicConfigKey, expectedConfigKey); - assertEq(spoke.getReserve(_reserveId).dynamicConfigKey, expectedConfigKey); - assertEq(_getLatestDynamicReserveConfig(spoke, _reserveId), expectedDynamicReserveConfig); + assertEq(spoke.getReserve(reserveId).dynamicConfigKey, expectedConfigKey); + assertEq(_getLatestDynamicReserveConfig(spoke, reserveId), expectedDynamicReserveConfig); } - function test_updateMaxLiquidationBonus_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateMaxLiquidationBonus_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - spokeConfigurator.updateMaxLiquidationBonus(spokeAddr, _reserveId, 0, 0); + spokeConfigurator.updateMaxLiquidationBonus(spokeAddr, reserveId, 0, 0); } function test_updateMaxLiquidationBonus() public { uint32 newLiquidationBonus = PercentageMath.PERCENTAGE_FACTOR.toUint32() + 123; - uint24 dynamicConfigKey = 0; + uint32 dynamicConfigKey = 0; ISpoke.DynamicReserveConfig memory expectedDynamicReserveConfig = spoke.getDynamicReserveConfig( - _reserveId, + reserveId, dynamicConfigKey ); expectedDynamicReserveConfig.maxLiquidationBonus = newLiquidationBonus; @@ -555,79 +540,79 @@ contract SpokeConfiguratorTest is SpokeBase { spokeAddr, abi.encodeCall( ISpoke.updateDynamicReserveConfig, - (_reserveId, dynamicConfigKey, expectedDynamicReserveConfig) + (reserveId, dynamicConfigKey, expectedDynamicReserveConfig) ) ); vm.expectEmit(address(spoke)); emit ISpoke.UpdateDynamicReserveConfig( - _reserveId, + reserveId, dynamicConfigKey, expectedDynamicReserveConfig ); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updateMaxLiquidationBonus( spokeAddr, - _reserveId, + reserveId, dynamicConfigKey, newLiquidationBonus ); assertEq( - spoke.getDynamicReserveConfig(_reserveId, dynamicConfigKey), + spoke.getDynamicReserveConfig(reserveId, dynamicConfigKey), expectedDynamicReserveConfig ); } - function test_addLiquidationFee_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_addLiquidationFee_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - spokeConfigurator.addLiquidationFee(spokeAddr, _reserveId, 0); + spokeConfigurator.addLiquidationFee(spokeAddr, reserveId, 0); } function test_addLiquidationFee() public { uint16 newLiquidationFee = PercentageMath.PERCENTAGE_FACTOR.toUint16() / 2; ISpoke.DynamicReserveConfig - memory expectedDynamicReserveConfig = _getLatestDynamicReserveConfig(spoke, _reserveId); + memory expectedDynamicReserveConfig = _getLatestDynamicReserveConfig(spoke, reserveId); expectedDynamicReserveConfig.liquidationFee = newLiquidationFee; - uint24 expectedConfigKey = _nextDynamicConfigKey(spoke, _reserveId); + uint32 expectedConfigKey = _nextDynamicConfigKey(spoke, reserveId); vm.expectCall( spokeAddr, - abi.encodeCall(ISpoke.addDynamicReserveConfig, (_reserveId, expectedDynamicReserveConfig)) + abi.encodeCall(ISpoke.addDynamicReserveConfig, (reserveId, expectedDynamicReserveConfig)) ); vm.expectEmit(address(spoke)); - emit ISpoke.AddDynamicReserveConfig( - _reserveId, - expectedConfigKey, - expectedDynamicReserveConfig - ); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); - uint24 dynamicConfigKey = spokeConfigurator.addLiquidationFee( + emit ISpoke.AddDynamicReserveConfig(reserveId, expectedConfigKey, expectedDynamicReserveConfig); + vm.prank(SPOKE_CONFIGURATOR); + uint32 dynamicConfigKey = spokeConfigurator.addLiquidationFee( spokeAddr, - _reserveId, + reserveId, newLiquidationFee ); assertEq(dynamicConfigKey, expectedConfigKey); - assertEq(spoke.getReserve(_reserveId).dynamicConfigKey, expectedConfigKey); - assertEq(_getLatestDynamicReserveConfig(spoke, _reserveId), expectedDynamicReserveConfig); + assertEq(spoke.getReserve(reserveId).dynamicConfigKey, expectedConfigKey); + assertEq(_getLatestDynamicReserveConfig(spoke, reserveId), expectedDynamicReserveConfig); } - function test_updateLiquidationFee_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateLiquidationFee_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); - spokeConfigurator.updateLiquidationFee(spokeAddr, _reserveId, 0, 0); + spokeConfigurator.updateLiquidationFee(spokeAddr, reserveId, 0, 0); } function test_updateLiquidationFee() public { uint16 newLiquidationFee = PercentageMath.PERCENTAGE_FACTOR.toUint16() / 4; - uint24 dynamicConfigKey = 0; + uint32 dynamicConfigKey = 0; ISpoke.DynamicReserveConfig memory expectedDynamicReserveConfig = spoke.getDynamicReserveConfig( - _reserveId, + reserveId, dynamicConfigKey ); expectedDynamicReserveConfig.liquidationFee = newLiquidationFee; @@ -636,35 +621,37 @@ contract SpokeConfiguratorTest is SpokeBase { spokeAddr, abi.encodeCall( ISpoke.updateDynamicReserveConfig, - (_reserveId, dynamicConfigKey, expectedDynamicReserveConfig) + (reserveId, dynamicConfigKey, expectedDynamicReserveConfig) ) ); vm.expectEmit(address(spoke)); emit ISpoke.UpdateDynamicReserveConfig( - _reserveId, + reserveId, dynamicConfigKey, expectedDynamicReserveConfig ); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updateLiquidationFee( spokeAddr, - _reserveId, + reserveId, dynamicConfigKey, newLiquidationFee ); assertEq( - spoke.getDynamicReserveConfig(_reserveId, dynamicConfigKey), + spoke.getDynamicReserveConfig(reserveId, dynamicConfigKey), expectedDynamicReserveConfig ); } - function test_addDynamicReserveConfig_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_addDynamicReserveConfig_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); spokeConfigurator.addDynamicReserveConfig( spokeAddr, - _reserveId, + reserveId, ISpoke.DynamicReserveConfig({ collateralFactor: 20_00, maxLiquidationBonus: 130_00, @@ -680,31 +667,33 @@ contract SpokeConfiguratorTest is SpokeBase { liquidationFee: 15_00 }); - uint24 expectedConfigKey = _nextDynamicConfigKey(spoke, _reserveId); + uint32 expectedConfigKey = _nextDynamicConfigKey(spoke, reserveId); vm.expectCall( spokeAddr, - abi.encodeCall(ISpoke.addDynamicReserveConfig, (_reserveId, newDynamicReserveConfig)) + abi.encodeCall(ISpoke.addDynamicReserveConfig, (reserveId, newDynamicReserveConfig)) ); vm.expectEmit(address(spoke)); - emit ISpoke.AddDynamicReserveConfig(_reserveId, expectedConfigKey, newDynamicReserveConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); - uint24 actualConfigKey = spokeConfigurator.addDynamicReserveConfig( + emit ISpoke.AddDynamicReserveConfig(reserveId, expectedConfigKey, newDynamicReserveConfig); + vm.prank(SPOKE_CONFIGURATOR); + uint32 actualConfigKey = spokeConfigurator.addDynamicReserveConfig( spokeAddr, - _reserveId, + reserveId, newDynamicReserveConfig ); - assertEq(_getLatestDynamicReserveConfig(spoke, _reserveId), newDynamicReserveConfig); + assertEq(_getLatestDynamicReserveConfig(spoke, reserveId), newDynamicReserveConfig); assertEq(actualConfigKey, expectedConfigKey); } - function test_updateDynamicReserveConfig_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_updateDynamicReserveConfig_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); spokeConfigurator.updateDynamicReserveConfig( spokeAddr, - _reserveId, + reserveId, 0, ISpoke.DynamicReserveConfig({ collateralFactor: 10_00, @@ -717,7 +706,7 @@ contract SpokeConfiguratorTest is SpokeBase { function test_updateDynamicReserveConfig() public { uint256 count = vm.randomUint(1, 50); for (uint256 i; i < count; ++i) test_addDynamicReserveConfig(); - assertEq(spoke.getReserve(_reserveId).dynamicConfigKey, count); + assertEq(spoke.getReserve(reserveId).dynamicConfigKey, count); ISpoke.DynamicReserveConfig memory newDynamicReserveConfig = ISpoke.DynamicReserveConfig({ collateralFactor: 10_00, @@ -730,51 +719,78 @@ contract SpokeConfiguratorTest is SpokeBase { spokeAddr, abi.encodeCall( ISpoke.updateDynamicReserveConfig, - (_reserveId, configKeyToUpdate, newDynamicReserveConfig) + (reserveId, configKeyToUpdate, newDynamicReserveConfig) ) ); vm.expectEmit(address(spoke)); - emit ISpoke.UpdateDynamicReserveConfig(_reserveId, configKeyToUpdate, newDynamicReserveConfig); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + emit ISpoke.UpdateDynamicReserveConfig(reserveId, configKeyToUpdate, newDynamicReserveConfig); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updateDynamicReserveConfig( spokeAddr, - _reserveId, + reserveId, configKeyToUpdate, newDynamicReserveConfig ); - assertEq(spoke.getDynamicReserveConfig(_reserveId, configKeyToUpdate), newDynamicReserveConfig); + assertEq(spoke.getDynamicReserveConfig(reserveId, configKeyToUpdate), newDynamicReserveConfig); } - function test_pauseAllReserves_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_pauseAllReserves_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); spokeConfigurator.pauseAllReserves(spokeAddr); } function test_pauseAllReserves() public { - for (uint256 reserveId = 0; reserveId < spoke.getReserveCount(); ++reserveId) { - ISpoke.ReserveConfig memory reserveConfig = spoke.getReserveConfig(reserveId); + for (uint256 reserveIdx = 0; reserveIdx < spoke.getReserveCount(); ++reserveIdx) { + ISpoke.ReserveConfig memory reserveConfig = spoke.getReserveConfig(reserveIdx); reserveConfig.paused = true; vm.expectCall( spokeAddr, - abi.encodeCall(ISpoke.updateReserveConfig, (reserveId, reserveConfig)) + abi.encodeCall(ISpoke.updateReserveConfig, (reserveIdx, reserveConfig)) ); vm.expectEmit(address(spoke)); - emit ISpoke.UpdateReserveConfig(reserveId, reserveConfig); + emit ISpoke.UpdateReserveConfig(reserveIdx, reserveConfig); } - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.pauseAllReserves(spokeAddr); - for (uint256 reserveId; reserveId < spoke.getReserveCount(); ++reserveId) { - assertEq(spoke.getReserveConfig(reserveId).paused, true); + for (uint256 reserveIdx; reserveIdx < spoke.getReserveCount(); ++reserveIdx) { + assertEq(spoke.getReserveConfig(reserveIdx).paused, true); } } - function test_freezeAllReserves_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_pauseReserve_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); + vm.prank(alice); + spokeConfigurator.pauseReserve(spokeAddr, reserveId); + } + + function test_pauseReserve() public { + ISpoke.ReserveConfig memory reserveConfig = spoke.getReserveConfig(reserveId); + reserveConfig.paused = true; + vm.expectCall( + spokeAddr, + abi.encodeCall(ISpoke.updateReserveConfig, (reserveId, reserveConfig)) + ); + vm.expectEmit(address(spoke)); + emit ISpoke.UpdateReserveConfig(reserveId, reserveConfig); + vm.prank(SPOKE_CONFIGURATOR); + spokeConfigurator.pauseReserve(spokeAddr, reserveId); + + assertTrue(spoke.getReserveConfig(reserveId).paused); + } + + function test_freezeAllReserves_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); spokeConfigurator.freezeAllReserves(spokeAddr); } @@ -788,7 +804,7 @@ contract SpokeConfiguratorTest is SpokeBase { emit ISpoke.UpdateReserveConfig(id, reserveConfig); } - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.freezeAllReserves(spokeAddr); for (uint256 id; id < spoke.getReserveCount(); ++id) { @@ -796,8 +812,33 @@ contract SpokeConfiguratorTest is SpokeBase { } } - function test_updatePositionManager_revertsWith_OwnableUnauthorizedAccount() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + function test_freezeReserve_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); + vm.prank(alice); + spokeConfigurator.freezeReserve(spokeAddr, reserveId); + } + + function test_freezeReserve() public { + ISpoke.ReserveConfig memory reserveConfig = spoke.getReserveConfig(reserveId); + reserveConfig.frozen = true; + vm.expectCall( + spokeAddr, + abi.encodeCall(ISpoke.updateReserveConfig, (reserveId, reserveConfig)) + ); + vm.expectEmit(address(spoke)); + emit ISpoke.UpdateReserveConfig(reserveId, reserveConfig); + vm.prank(SPOKE_CONFIGURATOR); + spokeConfigurator.freezeReserve(spokeAddr, reserveId); + + assertTrue(spoke.getReserveConfig(reserveId).frozen); + } + + function test_updatePositionManager_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); vm.prank(alice); spokeConfigurator.updatePositionManager(spokeAddr, address(0), true); } @@ -812,7 +853,7 @@ contract SpokeConfiguratorTest is SpokeBase { ); vm.expectEmit(address(spoke)); emit ISpoke.UpdatePositionManager(newPositionManager, active); - vm.prank(SPOKE_CONFIGURATOR_ADMIN); + vm.prank(SPOKE_CONFIGURATOR); spokeConfigurator.updatePositionManager(spokeAddr, newPositionManager, active); assertEq(spoke.isPositionManagerActive(newPositionManager), active); } diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol new file mode 100644 index 000000000..6abffba8a --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/Base.t.sol'; + +contract TokenizationSpokeBaseTest is Base { + ITokenizationSpoke public daiVault; + string public constant SHARE_NAME = 'Core Hub DAI'; + string public constant SHARE_SYMBOL = 'chDAI'; + + function setUp() public virtual override { + deployFixtures(); + initEnvironment(); + daiVault = _deployTokenizationSpoke(hub1, daiAssetId, SHARE_NAME, SHARE_SYMBOL, ADMIN); + _registerTokenizationSpoke(hub1, daiAssetId, daiVault); + } + + function _depositData( + ITokenizationSpoke vault, + address who, + uint256 deadline + ) internal returns (ITokenizationSpoke.TokenizedDeposit memory) { + return + ITokenizationSpoke.TokenizedDeposit({ + depositor: who, + assets: vm.randomUint(1, MAX_SUPPLY_AMOUNT), + receiver: vm.randomAddress(), + nonce: vault.nonces(who, _randomNonceKey()), + deadline: deadline + }); + } + + function _mintData( + ITokenizationSpoke vault, + address who, + uint256 deadline + ) internal returns (ITokenizationSpoke.TokenizedMint memory) { + return + ITokenizationSpoke.TokenizedMint({ + depositor: who, + shares: vm.randomUint(1, MAX_SUPPLY_AMOUNT), + receiver: vm.randomAddress(), + nonce: vault.nonces(who, _randomNonceKey()), + deadline: deadline + }); + } + + function _withdrawData( + ITokenizationSpoke vault, + address who, + uint256 deadline + ) internal returns (ITokenizationSpoke.TokenizedWithdraw memory) { + return + ITokenizationSpoke.TokenizedWithdraw({ + owner: who, + assets: vm.randomUint(1, MAX_SUPPLY_AMOUNT), + receiver: vm.randomAddress(), + nonce: vault.nonces(who, _randomNonceKey()), + deadline: deadline + }); + } + + function _redeemData( + ITokenizationSpoke vault, + address who, + uint256 deadline + ) internal returns (ITokenizationSpoke.TokenizedRedeem memory) { + return + ITokenizationSpoke.TokenizedRedeem({ + owner: who, + shares: vm.randomUint(1, MAX_SUPPLY_AMOUNT), + receiver: vm.randomAddress(), + nonce: vault.nonces(who, _randomNonceKey()), + deadline: deadline + }); + } + + function _permitData( + ITokenizationSpoke vault, + address who, + uint256 deadline + ) internal returns (EIP712Types.Permit memory) { + return + EIP712Types.Permit({ + owner: who, + spender: address(vault), + value: vm.randomUint(1, MAX_SUPPLY_AMOUNT), + deadline: deadline, + nonce: vault.nonces(who, vault.PERMIT_NONCE_NAMESPACE()) // can only use permit nonce key namespace + }); + } + + function _getTypedDataHash( + ITokenizationSpoke vault, + ITokenizationSpoke.TokenizedDeposit memory params + ) internal view returns (bytes32) { + return _typedDataHash(vault, vm.eip712HashStruct('TokenizedDeposit', abi.encode(params))); + } + + function _getTypedDataHash( + ITokenizationSpoke vault, + ITokenizationSpoke.TokenizedMint memory params + ) internal view returns (bytes32) { + return _typedDataHash(vault, vm.eip712HashStruct('TokenizedMint', abi.encode(params))); + } + + function _getTypedDataHash( + ITokenizationSpoke vault, + ITokenizationSpoke.TokenizedWithdraw memory params + ) internal view returns (bytes32) { + return _typedDataHash(vault, vm.eip712HashStruct('TokenizedWithdraw', abi.encode(params))); + } + + function _getTypedDataHash( + ITokenizationSpoke vault, + ITokenizationSpoke.TokenizedRedeem memory params + ) internal view returns (bytes32) { + return _typedDataHash(vault, vm.eip712HashStruct('TokenizedRedeem', abi.encode(params))); + } + + function _getTypedDataHash( + ITokenizationSpoke vault, + EIP712Types.Permit memory params + ) internal view returns (bytes32) { + return _typedDataHash(vault, vm.eip712HashStruct('Permit', abi.encode(params))); + } + + function _typedDataHash( + ITokenizationSpoke vault, + bytes32 typeHash + ) internal view returns (bytes32) { + return keccak256(abi.encodePacked('\x19\x01', vault.DOMAIN_SEPARATOR(), typeHash)); + } + + function _assertVaultHasNoBalanceOrAllowance(ITokenizationSpoke vault, address who) internal { + _assertEntityHasNoBalanceOrAllowance({ + underlying: IERC20(vault.asset()), + entity: address(vault), + user: who + }); + } + + function _simulateYield(ITokenizationSpoke vault, uint256 amount) internal { + IHub hub = IHub(vault.hub()); + TestnetERC20 asset = TestnetERC20(vault.asset()); + uint256 assetId = vault.assetId(); + + asset.mint(address(hub), amount); + vm.startPrank(address(spoke2)); + hub.add(assetId, amount); + _mockInterestRateBps(100_00); + hub.draw(assetId, amount, address(spoke2)); + skip(365 days); + asset.mint(address(hub), amount); + hub.restore(assetId, amount, IHubBase.PremiumDelta(0, 0, 0)); + vm.stopPrank(); + } +} diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.Config.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.Config.t.sol new file mode 100644 index 000000000..821c380ee --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.Config.t.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; + +contract TokenizationSpokeConfigTest is TokenizationSpokeBaseTest { + function test_constructor_reverts_when_invalid_setup() public { + uint256 invalidAssetId = vm.randomUint(hub1.getAssetCount(), UINT256_MAX); + vm.expectRevert(); + new TokenizationSpokeInstance(address(hub1), invalidAssetId); + + vm.expectRevert(); + new TokenizationSpokeInstance(address(0), vm.randomUint()); + } + + function test_constructor_asset_correctly_set() public { + uint256 assetId = vm.randomUint(0, hub1.getAssetCount() - 1); + TokenizationSpokeInstance instance = new TokenizationSpokeInstance(address(hub1), assetId); + assertEq(instance.asset(), hub1.getAsset(assetId).underlying); + assertEq(instance.decimals(), hub1.getAsset(assetId).decimals); + } + + function test_setUp() public { + assertEq(daiVault.name(), SHARE_NAME); + assertEq(daiVault.symbol(), SHARE_SYMBOL); + assertEq(daiVault.decimals(), tokenList.dai.decimals()); + + assertEq(daiVault.asset(), address(tokenList.dai)); + assertEq(daiVault.assetId(), daiAssetId); + assertEq(daiVault.hub(), address(hub1)); + + assertEq(daiVault.PERMIT_NONCE_NAMESPACE(), 0); + + assertEq(daiVault.totalAssets(), 0); + assertEq(daiVault.totalSupply(), 0); + assertEq(daiVault.balanceOf(vm.randomAddress()), 0); + } + + function test_configuration() public view { + ProxyAdmin proxyAdmin = ProxyAdmin(_getProxyAdminAddress(address(daiVault))); + assertEq(proxyAdmin.owner(), ADMIN); + assertEq(proxyAdmin.UPGRADE_INTERFACE_VERSION(), '5.0.0'); + assertEq( + _getProxyInitializedVersion(address(daiVault)), + TokenizationSpokeInstance(address(daiVault)).SPOKE_REVISION() + ); + address implementation = _getImplementationAddress(address(daiVault)); + assertEq(_getProxyInitializedVersion(implementation), type(uint64).max); + } +} diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.Constants.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.Constants.t.sol new file mode 100644 index 000000000..a725e8871 --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.Constants.t.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; + +contract TokenizationSpokeConstantsTest is TokenizationSpokeBaseTest { + function test_eip712Domain() public { + ITokenizationSpoke instance = _deployTokenizationSpoke( + hub1, + daiAssetId, + 'Core Hub DAI', + 'chDAI', + ADMIN + ); + ( + bytes1 fields, + string memory name, + string memory version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + uint256[] memory extensions + ) = IERC5267(address(instance)).eip712Domain(); + + assertEq(fields, bytes1(0x0f)); + assertEq(name, 'Tokenization Spoke'); + assertEq(version, '1'); + assertEq(chainId, block.chainid); + assertEq(verifyingContract, address(instance)); + assertEq(salt, bytes32(0)); + assertEq(extensions.length, 0); + } + + function test_DOMAIN_SEPARATOR() public { + ITokenizationSpoke instance = _deployTokenizationSpoke( + hub1, + daiAssetId, + 'Core Hub DAI', + 'chDAI', + ADMIN + ); + bytes32 expectedDomainSeparator = keccak256( + abi.encode( + keccak256( + 'EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)' + ), + keccak256('Tokenization Spoke'), + keccak256('1'), + block.chainid, + address(instance) + ) + ); + assertEq(instance.DOMAIN_SEPARATOR(), expectedDomainSeparator); + } + + function test_deposit_typeHash() public view { + assertEq(daiVault.DEPOSIT_TYPEHASH(), vm.eip712HashType('TokenizedDeposit')); + assertEq( + daiVault.DEPOSIT_TYPEHASH(), + keccak256( + 'TokenizedDeposit(address depositor,uint256 assets,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + } + + function test_mint_typeHash() public view { + assertEq(daiVault.MINT_TYPEHASH(), vm.eip712HashType('TokenizedMint')); + assertEq( + daiVault.MINT_TYPEHASH(), + keccak256( + 'TokenizedMint(address depositor,uint256 shares,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + } + + function test_withdraw_typeHash() public view { + assertEq(daiVault.WITHDRAW_TYPEHASH(), vm.eip712HashType('TokenizedWithdraw')); + assertEq( + daiVault.WITHDRAW_TYPEHASH(), + keccak256( + 'TokenizedWithdraw(address owner,uint256 assets,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + } + + function test_redeem_typeHash() public view { + assertEq(daiVault.REDEEM_TYPEHASH(), vm.eip712HashType('TokenizedRedeem')); + assertEq( + daiVault.REDEEM_TYPEHASH(), + keccak256( + 'TokenizedRedeem(address owner,uint256 shares,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + } + + function test_permit_typeHash() public view { + assertEq(daiVault.PERMIT_TYPEHASH(), vm.eip712HashType('Permit')); + assertEq( + daiVault.PERMIT_TYPEHASH(), + keccak256( + 'Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)' + ) + ); + } +} diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.DepositWithPermit.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.DepositWithPermit.t.sol new file mode 100644 index 000000000..a7263ca34 --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.DepositWithPermit.t.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; + +contract TokenizationSpokeDepositWithPermitTest is TokenizationSpokeBaseTest { + ITokenizationSpoke public vault; + TestnetERC20 public asset; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + asset = TestnetERC20(vault.asset()); + } + + function test_depositWithPermit_forwards_correct_call() public { + address owner = vm.randomAddress(); + address receiver = vm.randomAddress(); + address spender = address(vault); + uint256 maxAssets = vault.maxDeposit(receiver); + uint256 value = maxAssets == type(uint256).max + ? vm.randomUint(1, MAX_SUPPLY_AMOUNT) + : vm.randomUint(1, maxAssets); + uint256 deadline = vm.randomUint(); + uint8 v = uint8(vm.randomUint()); + bytes32 r = bytes32(vm.randomUint()); + bytes32 s = bytes32(vm.randomUint()); + + asset.mint(owner, value); + vm.prank(owner); + asset.approve(address(vault), value); + + vm.expectCall( + address(asset), + abi.encodeCall(TestnetERC20.permit, (owner, spender, value, deadline, v, r, s)), + 1 + ); + vm.prank(owner); + vault.depositWithPermit(value, receiver, deadline, v, r, s); + } + + function test_depositWithPermit_ignores_permit_reverts() public { + vm.mockCallRevert(address(asset), TestnetERC20.permit.selector, vm.randomBytes(64)); + + address owner = vm.randomAddress(); + address receiver = vm.randomAddress(); + uint256 maxAssets = vault.maxDeposit(receiver); + uint256 assets = maxAssets == type(uint256).max + ? vm.randomUint(1, MAX_SUPPLY_AMOUNT) + : vm.randomUint(1, maxAssets); + + asset.mint(owner, assets); + vm.prank(owner); + asset.approve(address(vault), assets); + + vm.prank(owner); + vault.depositWithPermit( + assets, + receiver, + vm.randomUint(), + uint8(vm.randomUint()), + bytes32(vm.randomUint()), + bytes32(vm.randomUint()) + ); + } + + function test_depositWithPermit() public { + (address user, uint256 userPk) = makeAddrAndKey('user'); + address receiver = vm.randomAddress(); + uint256 maxAssets = vault.maxDeposit(receiver); + uint256 assets = maxAssets == type(uint256).max + ? vm.randomUint(1, MAX_SUPPLY_AMOUNT) + : vm.randomUint(1, maxAssets); + + asset.mint(user, assets); + assertEq(asset.allowance(user, address(vault)), 0); + + EIP712Types.Permit memory params = EIP712Types.Permit({ + owner: user, + spender: address(vault), + value: assets, + deadline: _warpBeforeRandomDeadline(), + nonce: asset.nonces(user) + }); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPk, _getTypedDataHash(asset, params)); + + uint256 expectedShares = IHub(vault.hub()).previewAddByAssets(vault.assetId(), assets); + + vm.expectEmit(address(asset)); + emit IERC20.Approval(user, address(vault), params.value); + + vm.expectEmit(address(vault)); + emit IERC4626.Deposit(user, receiver, assets, expectedShares); + + vm.prank(user); + uint256 shares = vault.depositWithPermit(assets, receiver, params.deadline, v, r, s); + + assertEq(shares, expectedShares); + assertEq(asset.allowance(user, address(vault)), 0); + assertEq(vault.balanceOf(receiver), expectedShares); + } + + function test_depositWithPermit_works_with_existing_allowance() public { + address user = vm.randomAddress(); + address receiver = vm.randomAddress(); + uint256 maxAssets = vault.maxDeposit(receiver); + uint256 assets = maxAssets == type(uint256).max + ? vm.randomUint(1, MAX_SUPPLY_AMOUNT) + : vm.randomUint(1, maxAssets); + + asset.mint(user, assets); + + vm.prank(user); + asset.approve(address(vault), assets); + + vm.prank(user); + uint256 shares = vault.depositWithPermit( + assets, + receiver, + vm.randomUint(), + uint8(vm.randomUint()), + bytes32(vm.randomUint()), + bytes32(vm.randomUint()) + ); + + uint256 expectedShares = IHub(vault.hub()).previewAddByAssets(vault.assetId(), assets); + assertEq(shares, expectedShares); + assertEq(vault.balanceOf(receiver), expectedShares); + assertEq(asset.allowance(user, address(vault)), 0); + } +} diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.ERC4626Compliance.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.ERC4626Compliance.t.sol new file mode 100644 index 000000000..a777e6976 --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.ERC4626Compliance.t.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; +import {ERC4626Test} from 'lib/erc4626-tests/ERC4626.test.sol'; + +contract TokenizationSpokeERC4626ComplianceTest is TokenizationSpokeBaseTest, ERC4626Test { + function setUp() public override(TokenizationSpokeBaseTest, ERC4626Test) { + TokenizationSpokeBaseTest.setUp(); + updateLiquidityFee(IHub(daiVault.hub()), daiVault.assetId(), 0); + + _underlying_ = daiVault.asset(); + _vault_ = address(daiVault); + + _delta_ = 0; // maximum approximation error size to be passed to assertApproxEqAbs, 0 implies the vault follows the preferred rounding directions as per spec security considerations + _vaultMayBeEmpty = false; // fuzz inputs that empties the vault are considered; inflation protection is through virtual shares on hub + _unlimitedAmount = false; // fuzz inputs are restricted to the currently available amount from the caller + } + + function setUpYield(Init memory init) public override { + if (init.yield > 0) { + init.yield = bound(init.yield, 1, int256(MAX_SUPPLY_AMOUNT)); + _simulateYield(ITokenizationSpoke(_vault_), uint256(init.yield)); + } + } + + // @dev The following tests are relaxed to consider only smaller values, + // since they fail with large values (due to overflow). + function test_asset(Init memory init) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + super.test_asset(init); + } + + function test_totalAssets(Init memory init) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + super.test_totalAssets(init); + } + + function test_convertToShares(Init memory init, uint assets) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + assets = assets % MAX_SUPPLY_AMOUNT; + super.test_convertToShares(init, assets); + } + + function test_convertToAssets(Init memory init, uint shares) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + shares = shares % MAX_SUPPLY_AMOUNT; + super.test_convertToAssets(init, shares); + } + + function test_maxDeposit(Init memory init) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + super.test_maxDeposit(init); + } + + function test_previewDeposit(Init memory init, uint assets) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + assets = assets % MAX_SUPPLY_AMOUNT; + super.test_previewDeposit(init, assets); + } + + function test_deposit(Init memory init, uint assets, uint allowance) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + assets = assets % MAX_SUPPLY_AMOUNT; + allowance = allowance % MAX_SUPPLY_AMOUNT; + super.test_deposit(init, assets, allowance); + } + + function test_maxMint(Init memory init) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + super.test_maxMint(init); + } + + function test_previewMint(Init memory init, uint shares) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + shares = shares % MAX_SUPPLY_AMOUNT; + super.test_previewMint(init, shares); + } + + function test_mint(Init memory init, uint shares, uint allowance) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + shares = shares % MAX_SUPPLY_AMOUNT; + allowance = allowance % MAX_SUPPLY_AMOUNT; + super.test_mint(init, shares, allowance); + } + + function test_maxWithdraw(Init memory init) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + super.test_maxWithdraw(init); + } + + function test_previewWithdraw(Init memory init, uint assets) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + assets = assets % MAX_SUPPLY_AMOUNT; + super.test_previewWithdraw(init, assets); + } + + function test_maxRedeem(Init memory init) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + super.test_maxRedeem(init); + } + + function test_previewRedeem(Init memory init, uint shares) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + shares = shares % MAX_SUPPLY_AMOUNT; + super.test_previewRedeem(init, shares); + } + + function test_RT_redeem_deposit(Init memory init, uint shares) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + shares = shares % MAX_SUPPLY_AMOUNT; + super.test_RT_redeem_deposit(init, shares); + } + + function test_RT_redeem_mint(Init memory init, uint shares) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + shares = shares % MAX_SUPPLY_AMOUNT; + super.test_RT_redeem_mint(init, shares); + } + + function test_RT_mint_withdraw(Init memory init, uint shares) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + shares = shares % MAX_SUPPLY_AMOUNT; + super.test_RT_mint_withdraw(init, shares); + } + + function test_RT_mint_redeem(Init memory init, uint shares) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + shares = shares % MAX_SUPPLY_AMOUNT; + super.test_RT_mint_redeem(init, shares); + } + + function test_RT_withdraw_mint(Init memory init, uint assets) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + assets = assets % MAX_SUPPLY_AMOUNT; + super.test_RT_withdraw_mint(init, assets); + } + + function test_RT_withdraw_deposit(Init memory init, uint assets) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + assets = assets % MAX_SUPPLY_AMOUNT; + super.test_RT_withdraw_deposit(init, assets); + } + + function test_RT_deposit_redeem(Init memory init, uint assets) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + assets = assets % MAX_SUPPLY_AMOUNT; + super.test_RT_deposit_redeem(init, assets); + } + + function test_RT_deposit_withdraw(Init memory init, uint assets) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + assets = assets % MAX_SUPPLY_AMOUNT; + super.test_RT_deposit_withdraw(init, assets); + } + + function test_withdraw(Init memory init, uint assets, uint allowance) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + assets = assets % MAX_SUPPLY_AMOUNT; + allowance = allowance % MAX_SUPPLY_AMOUNT; + super.test_withdraw(init, assets, allowance); + } + + function test_withdraw_zero_allowance(Init memory init, uint assets) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + assets = assets % MAX_SUPPLY_AMOUNT; + super.test_withdraw_zero_allowance(init, assets); + } + + function test_redeem(Init memory init, uint shares, uint allowance) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + shares = shares % MAX_SUPPLY_AMOUNT; + allowance = allowance % MAX_SUPPLY_AMOUNT; + super.test_redeem(init, shares, allowance); + } + + function test_redeem_zero_allowance(Init memory init, uint shares) public override { + init = clamp(init, MAX_SUPPLY_AMOUNT); + shares = shares % MAX_SUPPLY_AMOUNT; + super.test_redeem_zero_allowance(init, shares); + } + + function clamp(Init memory init, uint256 max) internal pure returns (Init memory) { + for (uint256 i = 0; i < N; i++) { + init.share[i] = init.share[i] % max; + init.asset[i] = init.asset[i] % max; + } + init.yield = init.yield % int256(max); + return init; + } +} diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.Edge.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.Edge.t.sol new file mode 100644 index 000000000..2b894c895 --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.Edge.t.sol @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; + +contract TokenizationSpokeEdgeTest is TokenizationSpokeBaseTest { + ITokenizationSpoke public vault; + TestnetERC20 public asset; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + asset = TestnetERC20(vault.asset()); + } + + function test_vaultInteractionsForSomeoneElse() public { + // init 2 users with a 1e18 balance + uint256 amount = 1e18; + asset.mint(alice, amount); + asset.mint(bob, amount); + + uint256 aliceInitialBalance = asset.balanceOf(alice); + uint256 bobInitialBalance = asset.balanceOf(bob); + + vm.prank(alice); + asset.approve(address(vault), amount); + + vm.prank(bob); + asset.approve(address(vault), amount); + + // alice deposits 1e18 for bob + vm.prank(alice); + uint256 bobShares = vault.deposit(amount, bob); + + assertEq(vault.balanceOf(alice), 0); + assertEq(vault.balanceOf(bob), bobShares); + assertEq(asset.balanceOf(alice), aliceInitialBalance - amount); + + // bob mint bobShares for alice + uint256 assetsForMint = vault.previewMint(bobShares); + vm.prank(bob); + vault.mint(bobShares, alice); + assertEq(vault.balanceOf(alice), bobShares); + assertEq(vault.balanceOf(bob), bobShares); + assertEq(asset.balanceOf(bob), bobInitialBalance - assetsForMint); + + // alice redeem bobShares for bob + vm.prank(alice); + uint256 redeemedAssets = vault.redeem(bobShares, bob, alice); + + assertEq(vault.balanceOf(alice), 0); + assertEq(vault.balanceOf(bob), bobShares); + assertEq(asset.balanceOf(bob), bobInitialBalance - assetsForMint + redeemedAssets); + + // bob withdraw redeemedAssets for alice + vm.prank(bob); + vault.withdraw(redeemedAssets, alice, bob); + + assertEq(vault.balanceOf(alice), 0); + assertEq(asset.balanceOf(alice), aliceInitialBalance - amount + redeemedAssets); + + _assertVaultHasNoBalanceOrAllowance(vault, alice); + _assertVaultHasNoBalanceOrAllowance(vault, bob); + } + + function test_singleDepositWithdraw() public { + address depositor = alice; + uint256 assets = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + asset.mint(depositor, assets); + Utils.approve(vault, depositor, assets); + + uint256 alicePreDepositBal = asset.balanceOf(depositor); + + vm.prank(depositor); + uint256 shares = vault.deposit(assets, depositor); + + assertEq(vault.previewWithdraw(shares), assets); + assertEq(vault.previewDeposit(assets), shares); + assertEq(vault.totalSupply(), shares); + assertEq(vault.totalAssets(), assets); + assertEq(vault.balanceOf(depositor), shares); + assertEq(vault.convertToAssets(vault.balanceOf(depositor)), assets); + assertEq(asset.balanceOf(depositor), alicePreDepositBal - assets); + + vm.prank(depositor); + vault.withdraw(assets, depositor, depositor); + + assertEq(vault.totalAssets(), 0); + assertEq(vault.balanceOf(depositor), 0); + assertEq(vault.convertToAssets(vault.balanceOf(depositor)), 0); + assertEq(asset.balanceOf(depositor), alicePreDepositBal); + _assertVaultHasNoBalanceOrAllowance(vault, depositor); + } + + function test_singleMintRedeem() public { + address depositor = alice; + uint256 shares = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + uint256 expectedAssets = IHub(vault.hub()).previewAddByShares(vault.assetId(), shares); + asset.mint(depositor, expectedAssets); + Utils.approve(vault, depositor, expectedAssets); + + uint256 alicePreDepositBal = asset.balanceOf(depositor); + + vm.prank(depositor); + uint256 assets = vault.mint(shares, depositor); + + assertEq(vault.previewWithdraw(shares), assets); + assertEq(vault.previewDeposit(assets), shares); + assertEq(vault.totalSupply(), shares); + assertEq(vault.totalAssets(), assets); + assertEq(vault.balanceOf(depositor), shares); + assertEq(vault.convertToAssets(vault.balanceOf(depositor)), assets); + assertEq(asset.balanceOf(depositor), alicePreDepositBal - assets); + + vm.prank(depositor); + vault.redeem(shares, depositor, depositor); + + assertEq(vault.totalAssets(), 0); + assertEq(vault.balanceOf(depositor), 0); + assertEq(vault.convertToAssets(vault.balanceOf(depositor)), 0); + assertEq(asset.balanceOf(depositor), alicePreDepositBal); + _assertVaultHasNoBalanceOrAllowance(vault, depositor); + } + + function test_multipleMintDepositRedeemWithdraw() public { + uint256 mutationAmount = 3000; + + asset.mint(alice, 4000); + asset.mint(bob, 7001); + + vm.prank(alice); + asset.approve(address(vault), 4000); + vm.prank(bob); + asset.approve(address(vault), 7001); + + // 1. Alice mints 2000 shares + vm.prank(alice); + uint256 aliceAssets = vault.mint(2000, alice); + uint256 aliceShares = vault.previewDeposit(aliceAssets); + assertEq(aliceShares, 2000); + assertEq(vault.balanceOf(alice), aliceShares); + + // 2. Bob deposits 4000 tokens + vm.prank(bob); + uint256 bobShares = vault.deposit(4000, bob); + assertEq(vault.totalSupply(), aliceShares + bobShares); + + // 3. Yield accrues via Hub drawnIndex mechanism + _simulateYield(vault, mutationAmount); + + // Verify share values increased + assertGt(vault.convertToAssets(vault.balanceOf(alice)), aliceAssets); + assertGt(vault.convertToAssets(vault.balanceOf(bob)), 4000); + + // 4. Alice deposits 2000 more tokens + vm.prank(alice); + vault.deposit(2000, alice); + + // 5. Bob mints 2000 more shares + vm.prank(bob); + vault.mint(2000, bob); + + // 6. More yield accrues + _simulateYield(vault, mutationAmount); + + // 7-10. Gradual redemption/withdrawal + uint256 aliceSharesBefore = vault.balanceOf(alice); + vm.prank(alice); + vault.redeem(aliceSharesBefore / 2, alice, alice); + + uint256 bobAssetsBefore = vault.convertToAssets(vault.balanceOf(bob)); + vm.prank(bob); + vault.withdraw(bobAssetsBefore / 3, bob, bob); + + // Alice withdraws remaining + uint256 aliceRemainingAssets = vault.convertToAssets(vault.balanceOf(alice)); + vm.prank(alice); + vault.withdraw(aliceRemainingAssets, alice, alice); + assertEq(vault.balanceOf(alice), 0); + + // Bob redeems remaining + uint256 bobRemainingShares = vault.balanceOf(bob); + vm.prank(bob); + vault.redeem(bobRemainingShares, bob, bob); + assertEq(vault.balanceOf(bob), 0); + + // Vault should be empty (or near-empty due to rounding) + assertLe(vault.totalSupply(), 1); + assertEq(asset.balanceOf(address(vault)), 0); + } + + function test_depositZero_revertsWith_InvalidAmount() public { + vm.expectRevert(IHub.InvalidAmount.selector); + vm.prank(alice); + vault.deposit(0, alice); + } + + function test_mintZero_revertsWith_InvalidAmount() public { + vm.expectRevert(IHub.InvalidAmount.selector); + vm.prank(alice); + vault.mint(0, alice); + } + + function test_withdrawZero_revertsWith_InvalidAmount() public { + uint256 assets = 1e18; + asset.mint(alice, assets); + Utils.approve(vault, alice, assets); + vm.prank(alice); + vault.deposit(assets, alice); + + vm.expectRevert(IHub.InvalidAmount.selector); + vm.prank(alice); + vault.withdraw(0, alice, alice); + } + + function test_redeemZero_revertsWith_InvalidAmount() public { + uint256 assets = 1e18; + asset.mint(alice, assets); + Utils.approve(vault, alice, assets); + vm.prank(alice); + vault.deposit(assets, alice); + + vm.expectRevert(IHub.InvalidAmount.selector); + vm.prank(alice); + vault.redeem(0, alice, alice); + } + + function test_deposit_revertsWith_ERC20InsufficientAllowance_noApproval() public { + uint256 assets = 1e18; + asset.mint(alice, assets); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(vault), + 0, + assets + ) + ); + vm.prank(alice); + vault.deposit(assets, alice); + } + + function test_mint_revertsWith_ERC20InsufficientAllowance_noApproval() public { + uint256 shares = 1e18; + uint256 neededAssets = IHub(vault.hub()).previewAddByShares(vault.assetId(), shares); + asset.mint(alice, neededAssets); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(vault), + 0, + neededAssets + ) + ); + vm.prank(alice); + vault.mint(shares, alice); + } + + function test_withdraw_revertsWith_ERC20InsufficientBalance_noBalance() public { + uint256 assets = 1e18; + uint256 shares = vault.previewWithdraw(assets); + + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, shares) + ); + vm.prank(alice); + vault.withdraw(assets, alice, alice); + } + + function test_redeem_revertsWith_ERC20InsufficientBalance_noShares() public { + uint256 shares = 1e18; + + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, shares) + ); + vm.prank(alice); + vault.redeem(shares, alice, alice); + } + + function test_withdraw_revertsWith_ERC20InsufficientBalance() public { + uint256 depositAssets = 1e18; + asset.mint(alice, depositAssets); + Utils.approve(vault, alice, depositAssets); + vm.prank(alice); + vault.deposit(depositAssets, alice); + + uint256 withdrawAssets = depositAssets + 1; + uint256 aliceShares = vault.balanceOf(alice); + uint256 neededShares = vault.previewWithdraw(withdrawAssets); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientBalance.selector, + alice, + aliceShares, + neededShares + ) + ); + vm.prank(alice); + vault.withdraw(withdrawAssets, alice, alice); + } + + function test_redeem_revertsWith_ERC20InsufficientBalance_on_InsufficientShares() public { + uint256 depositAssets = 1e18; + asset.mint(alice, depositAssets); + Utils.approve(vault, alice, depositAssets); + vm.prank(alice); + uint256 shares = vault.deposit(depositAssets, alice); + + uint256 redeemShares = shares + 1; + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientBalance.selector, + alice, + shares, + redeemShares + ) + ); + vm.prank(alice); + vault.redeem(redeemShares, alice, alice); + } + + function test_withdraw_revertsWith_ERC20InsufficientAllowance_callerNotOwner() public { + uint256 depositAssets = 1e18; + asset.mint(alice, depositAssets); + Utils.approve(vault, alice, depositAssets); + vm.prank(alice); + vault.deposit(depositAssets, alice); + + uint256 shares = vault.previewWithdraw(depositAssets); + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, bob, 0, shares) + ); + vm.prank(bob); + vault.withdraw(depositAssets, bob, alice); + } + + function test_redeem_revertsWith_ERC20InsufficientAllowance_callerNotOwner() public { + uint256 depositAssets = 1e18; + asset.mint(alice, depositAssets); + Utils.approve(vault, alice, depositAssets); + vm.prank(alice); + uint256 shares = vault.deposit(depositAssets, alice); + + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, bob, 0, shares) + ); + vm.prank(bob); + vault.redeem(shares, bob, alice); + } +} diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.MaxGetters.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.MaxGetters.t.sol new file mode 100644 index 000000000..290aca2ee --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.MaxGetters.t.sol @@ -0,0 +1,453 @@ +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; + +// Coverage Matrix for maxDeposit/maxMint/maxWithdraw/maxRedeem: +// +---------------------------+----------------+----------------+----------------+----------------+ +// | Scenario | maxDeposit | maxMint | maxWithdraw | maxRedeem | +// +---------------------------+----------------+----------------+----------------+----------------+ +// | active=false | 0 | 0 | 0 | 0 | +// | halted=true | 0 | 0 | 0 | 0 | +// | active=false & halted=true| 0 | 0 | 0 | 0 | +// | addCap=0 | 0 | 0 | n/a | n/a | +// | addCap=MAX | type(uint).max | type(uint).max | n/a | n/a | +// | addCap=variable (empty) | cap * units | shares(cap) | n/a | n/a | +// | addCap=variable (partial) | remaining | shares(rem) | n/a | n/a | +// | addCap exactly reached | 0 | 0 | n/a | n/a | +// | addCap exceeded by yield | 0 | 0 | n/a | n/a | +// | liquidity=0 | n/a | n/a | 0 | 0 | +// | liquidity < balance | n/a | n/a | liquidity | shares(liq) | +// | liquidity >= balance | n/a | n/a | balance | shares(bal) | +// | owner has 0 shares | n/a | n/a | 0 | 0 | +// +---------------------------+----------------+----------------+----------------+----------------+ +// n/a = scenario does not affect this getter + +abstract contract TokenizationSpokeMaxGettersBaseTest is TokenizationSpokeBaseTest { + ITokenizationSpoke public vault; + TestnetERC20 public asset; + IHub public hub; + uint256 public assetId; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + asset = TestnetERC20(vault.asset()); + hub = IHub(vault.hub()); + assetId = vault.assetId(); + } +} + +abstract contract TokenizationSpokeMaxGettersAllZeroTest is TokenizationSpokeMaxGettersBaseTest { + function test_maxDeposit_returnsZero() public view { + assertEq(vault.maxDeposit(alice), 0); + } + + function test_maxMint_returnsZero() public view { + assertEq(vault.maxMint(alice), 0); + } + + function test_maxWithdraw_returnsZero() public view { + assertEq(vault.maxWithdraw(alice), 0); + } + + function test_maxRedeem_returnsZero() public view { + assertEq(vault.maxRedeem(alice), 0); + } +} + +contract TokenizationSpokeMaxGettersNotActiveTest is TokenizationSpokeMaxGettersAllZeroTest { + function setUp() public override { + super.setUp(); + _updateSpokeActive(hub, assetId, address(vault), false); + } +} + +contract TokenizationSpokeMaxGettersHaltedTest is TokenizationSpokeMaxGettersAllZeroTest { + function setUp() public override { + super.setUp(); + _updateSpokeHalted(hub, assetId, address(vault), true); + } +} + +contract TokenizationSpokeMaxGettersNotActiveAndHaltedTest is + TokenizationSpokeMaxGettersAllZeroTest +{ + function setUp() public override { + super.setUp(); + _updateSpokeActive(hub, assetId, address(vault), false); + _updateSpokeHalted(hub, assetId, address(vault), true); + } +} + +contract TokenizationSpokeMaxGettersAddCapZeroTest is TokenizationSpokeMaxGettersBaseTest { + function setUp() public override { + super.setUp(); + _updateAddCap(hub, assetId, address(vault), 0); + } + + function test_maxDeposit_returnsZero() public view { + assertEq(vault.maxDeposit(alice), 0); + } + + function test_maxMint_returnsZero() public view { + assertEq(vault.maxMint(alice), 0); + } +} + +contract TokenizationSpokeMaxGettersAddCapMaxTest is TokenizationSpokeMaxGettersBaseTest { + function setUp() public override { + super.setUp(); + uint256 depositAmount = 10e18; + asset.mint(alice, depositAmount); + Utils.approve(vault, alice, depositAmount); + vm.prank(alice); + vault.deposit(depositAmount, alice); + } + + function test_maxDeposit_returnsMaxUint() public view { + assertEq(vault.maxDeposit(alice), type(uint256).max); + } + + function test_maxMint_returnsMaxUint() public view { + assertEq(vault.maxMint(alice), type(uint256).max); + } +} + +contract TokenizationSpokeMaxGettersAddCapVariableEmptyTest is TokenizationSpokeMaxGettersBaseTest { + using SafeCast for uint256; + + uint40 public addCap; + + function setUp() public override { + super.setUp(); + addCap = vm.randomUint(1, 1000).toUint40(); + _updateAddCap(hub, assetId, address(vault), addCap); + } + + function test_maxDeposit_returnsCapTimesUnits() public view { + uint256 expected = uint256(addCap) * MathUtils.uncheckedExp(10, vault.decimals()); + assertEq(vault.maxDeposit(alice), expected); + } + + function test_maxMint_returnsSharesOfCap() public view { + uint256 capAssets = uint256(addCap) * MathUtils.uncheckedExp(10, vault.decimals()); + uint256 expected = hub.previewAddByAssets(assetId, capAssets); + assertEq(vault.maxMint(alice), expected); + } +} + +contract TokenizationSpokeMaxGettersAddCapVariablePartialTest is + TokenizationSpokeMaxGettersBaseTest +{ + using SafeCast for uint256; + + uint40 public addCap; + uint256 public capWithDecimals; + uint256 public depositAmount; + + function setUp() public override { + super.setUp(); + addCap = vm.randomUint(100, 1000).toUint40(); + _updateAddCap(hub, assetId, address(vault), addCap); + + capWithDecimals = uint256(addCap) * MathUtils.uncheckedExp(10, vault.decimals()); + depositAmount = capWithDecimals / 2; + asset.mint(alice, depositAmount); + Utils.approve(vault, alice, depositAmount); + vm.prank(alice); + vault.deposit(depositAmount, alice); + } + + function test_maxDeposit_returnsRemaining() public view { + uint256 expected = capWithDecimals - vault.previewMint(vault.totalSupply()); + assertEq(vault.maxDeposit(alice), expected); + } + + function test_maxMint_returnsSharesOfRemaining() public view { + uint256 remaining = capWithDecimals - vault.previewMint(vault.totalSupply()); + uint256 expected = hub.previewAddByAssets(assetId, remaining); + assertEq(vault.maxMint(alice), expected); + } +} + +contract TokenizationSpokeMaxGettersAddCapExactlyReachedTest is + TokenizationSpokeMaxGettersBaseTest +{ + using SafeCast for uint256; + + uint40 public addCap; + uint256 public capWithDecimals; + + function setUp() public override { + super.setUp(); + addCap = vm.randomUint(1, 1000).toUint40(); + _updateAddCap(hub, assetId, address(vault), addCap); + + capWithDecimals = uint256(addCap) * MathUtils.uncheckedExp(10, vault.decimals()); + asset.mint(alice, capWithDecimals); + Utils.approve(vault, alice, capWithDecimals); + vm.prank(alice); + vault.deposit(capWithDecimals, alice); + } + + function test_maxDeposit_returnsZero() public view { + assertEq(vault.maxDeposit(alice), 0); + } + + function test_maxMint_returnsZero() public view { + assertEq(vault.maxMint(alice), 0); + } +} + +contract TokenizationSpokeMaxGettersCapExceededByYieldTest is TokenizationSpokeMaxGettersBaseTest { + using SafeCast for uint256; + + uint40 public addCap; + uint256 public capWithDecimals; + + function setUp() public override { + super.setUp(); + addCap = 10; + _updateAddCap(hub, assetId, address(vault), addCap); + + capWithDecimals = uint256(addCap) * MathUtils.uncheckedExp(10, vault.decimals()); + asset.mint(alice, capWithDecimals); + Utils.approve(vault, alice, capWithDecimals); + vm.prank(alice); + vault.deposit(capWithDecimals, alice); + + _simulateYield(vault, capWithDecimals); + + assertGt(vault.totalAssets(), capWithDecimals); + } + + function test_maxDeposit_returnsZero() public view { + assertEq(vault.maxDeposit(alice), 0); + } + + function test_maxMint_returnsZero() public view { + assertEq(vault.maxMint(alice), 0); + } +} + +contract TokenizationSpokeMaxGettersZeroLiquidityTest is TokenizationSpokeMaxGettersBaseTest { + uint256 public depositAmount; + + function setUp() public override { + super.setUp(); + depositAmount = 10e18; + asset.mint(alice, depositAmount); + Utils.approve(vault, alice, depositAmount); + vm.prank(alice); + vault.deposit(depositAmount, alice); + + // spoke2 needs to first add, then can draw + asset.mint(address(hub), depositAmount); + vm.startPrank(address(spoke2)); + hub.add(assetId, depositAmount); + hub.draw(assetId, depositAmount * 2, address(spoke2)); + vm.stopPrank(); + + assertEq(hub.getAssetLiquidity(assetId), 0); + } + + function test_maxWithdraw_returnsZero() public view { + assertEq(vault.maxWithdraw(alice), 0); + } + + function test_maxRedeem_returnsZero() public view { + assertEq(vault.maxRedeem(alice), 0); + } +} + +contract TokenizationSpokeMaxGettersLiquidityLessThanBalanceTest is + TokenizationSpokeMaxGettersBaseTest +{ + using MathUtils for uint256; + + uint256 public depositAmount; + + function setUp() public override { + super.setUp(); + depositAmount = 10e18; + asset.mint(alice, depositAmount); + Utils.approve(vault, alice, depositAmount); + vm.prank(alice); + vault.deposit(depositAmount, alice); + + _simulateYield(vault, depositAmount); + + uint256 drawnAmount = depositAmount / 2; + asset.mint(address(hub), drawnAmount); + vm.startPrank(address(spoke2)); + hub.add(assetId, drawnAmount); + hub.draw(assetId, drawnAmount + depositAmount, address(spoke2)); + vm.stopPrank(); + } + + function test_maxWithdraw_returnsLiquidity() public view { + uint256 liquidity = hub.getAssetLiquidity(assetId); + uint256 aliceBalance = vault.convertToAssets(vault.balanceOf(alice)); + assertLt(liquidity, aliceBalance); + + assertEq(vault.maxWithdraw(alice), liquidity); + } + + function test_maxRedeem_returnsSharesOfLiquidity() public view { + uint256 liquidity = hub.getAssetLiquidity(assetId); + uint256 liquidityShares = vault.convertToShares(liquidity); + uint256 aliceShares = vault.balanceOf(alice); + assertLt(liquidityShares, aliceShares); + + assertEq(vault.maxRedeem(alice), liquidityShares); + } +} + +contract TokenizationSpokeMaxGettersLiquidityGreaterThanBalanceTest is + TokenizationSpokeMaxGettersBaseTest +{ + uint256 public depositAmount; + + function setUp() public override { + super.setUp(); + depositAmount = 10e18; + asset.mint(alice, depositAmount); + Utils.approve(vault, alice, depositAmount); + vm.prank(alice); + vault.deposit(depositAmount, alice); + + uint256 extraLiquidity = 5e18; + asset.mint(bob, extraLiquidity); + Utils.approve(vault, bob, extraLiquidity); + vm.prank(bob); + vault.deposit(extraLiquidity, bob); + } + + function test_maxWithdraw_returnsBalance() public view { + uint256 liquidity = hub.getAssetLiquidity(assetId); + uint256 aliceBalance = vault.convertToAssets(vault.balanceOf(alice)); + assertGt(liquidity, aliceBalance); + + assertEq(vault.maxWithdraw(alice), aliceBalance); + } + + function test_maxRedeem_returnsSharesOfBalance() public view { + uint256 liquidity = hub.getAssetLiquidity(assetId); + uint256 aliceShares = vault.balanceOf(alice); + uint256 liquidityShares = vault.convertToShares(liquidity); + assertGt(liquidityShares, aliceShares); + + assertEq(vault.maxRedeem(alice), aliceShares); + } +} + +contract TokenizationSpokeMaxGettersOwnerZeroSharesTest is TokenizationSpokeMaxGettersBaseTest { + function setUp() public override { + super.setUp(); + uint256 depositAmount = 10e18; + asset.mint(bob, depositAmount); + Utils.approve(vault, bob, depositAmount); + vm.prank(bob); + vault.deposit(depositAmount, bob); + + assertEq(vault.balanceOf(alice), 0); + assertGt(hub.getAssetLiquidity(assetId), 0); + } + + function test_maxWithdraw_returnsZero() public view { + assertEq(vault.maxWithdraw(alice), 0); + } + + function test_maxRedeem_returnsZero() public view { + assertEq(vault.maxRedeem(alice), 0); + } +} + +contract TokenizationSpokeMaxGettersExactBoundaryAfterYieldTest is + TokenizationSpokeMaxGettersBaseTest +{ + uint40 public addCap; + uint256 public capWithDecimals; + + function setUp() public override { + super.setUp(); + addCap = 100; + _updateAddCap(hub, assetId, address(vault), addCap); + + capWithDecimals = uint256(addCap) * MathUtils.uncheckedExp(10, vault.decimals()); + uint256 depositAmount = capWithDecimals / 2; + asset.mint(alice, depositAmount); + Utils.approve(vault, alice, depositAmount); + vm.prank(alice); + vault.deposit(depositAmount, alice); + + _simulateYield(vault, depositAmount); + assertGt(vault.totalAssets(), depositAmount); + } + + function test_maxDeposit_exactBoundary_succeeds() public { + uint256 max = vault.maxDeposit(bob); + assertGt(max, 0); + + asset.mint(bob, max); + Utils.approve(vault, bob, max); + vm.prank(bob); + vault.deposit(max, bob); + } + + function test_maxMint_exactBoundary_succeeds() public { + uint256 max = vault.maxMint(bob); + assertGt(max, 0); + + uint256 assets = vault.previewMint(max); + asset.mint(bob, assets); + Utils.approve(vault, bob, assets); + vm.prank(bob); + vault.mint(max, bob); + } +} + +contract TokenizationSpokeMaxGettersExactBoundaryLimitedLiquidityTest is + TokenizationSpokeMaxGettersBaseTest +{ + uint256 public depositAmount; + + function setUp() public override { + super.setUp(); + depositAmount = 10e18; + asset.mint(alice, depositAmount); + Utils.approve(vault, alice, depositAmount); + vm.prank(alice); + vault.deposit(depositAmount, alice); + + _simulateYield(vault, depositAmount); + + uint256 drawnAmount = depositAmount / 2; + asset.mint(address(hub), drawnAmount); + vm.startPrank(address(spoke2)); + hub.add(assetId, drawnAmount); + hub.draw(assetId, drawnAmount + depositAmount, address(spoke2)); + vm.stopPrank(); + + uint256 liquidity = hub.getAssetLiquidity(assetId); + uint256 aliceBalance = vault.convertToAssets(vault.balanceOf(alice)); + assertLt(liquidity, aliceBalance); + } + + function test_maxWithdraw_exactBoundary_limitedLiquidity_succeeds() public { + uint256 max = vault.maxWithdraw(alice); + assertGt(max, 0); + + vm.prank(alice); + vault.withdraw(max, alice, alice); + } + + function test_maxRedeem_exactBoundary_limitedLiquidity_succeeds() public { + uint256 max = vault.maxRedeem(alice); + assertGt(max, 0); + + vm.prank(alice); + vault.redeem(max, alice, alice); + } +} diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.Permit.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.Permit.t.sol new file mode 100644 index 000000000..37f2103d3 --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.Permit.t.sol @@ -0,0 +1,152 @@ +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; + +contract TokenizationSpokePermitTest is TokenizationSpokeBaseTest { + using MathUtils for uint256; + + ITokenizationSpoke public vault; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + } + + function test_nonces_uses_permit_nonce_key_namespace(bytes32) public { + vm.setArbitraryStorage(address(vault)); + uint192 key = vault.PERMIT_NONCE_NAMESPACE(); + + address user = vm.randomAddress(); + assertEq(vault.nonces(user), vault.nonces(user, key)); + + uint256 keyNonce = vault.nonces(user); + (uint192 unpackedKey, ) = _unpackNonce(keyNonce); + assertEq(unpackedKey, key); + } + + function test_usePermitNonce(bytes32) public { + vm.setArbitraryStorage(address(vault)); + uint192 key = vault.PERMIT_NONCE_NAMESPACE(); + + address owner = vm.randomAddress(); + uint256 initialNonce = vault.nonces(owner, key); + + vm.prank(owner); + uint256 usedNonce = vault.usePermitNonce(); + assertEq(usedNonce, initialNonce); + + uint256 newNonce = vault.nonces(owner, key); + assertEq(newNonce, initialNonce.uncheckedAdd(1)); + } + + function test_permit() public { + EIP712Types.Permit memory p = _permitData(vault, alice, _warpBeforeRandomDeadline()); + p.nonce = _burnRandomNoncesAtKey(vault, p.owner, vault.PERMIT_NONCE_NAMESPACE()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectEmit(address(vault)); + emit IERC20.Approval(p.owner, p.spender, p.value); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + + assertEq(vault.allowance(p.owner, p.spender), p.value); + } + + function test_permit_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { + EIP712Types.Permit memory p = _permitData(vault, alice, _warpAfterRandomDeadline()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + } + + function test_permit_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { + (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); + address owner = _randomAddressOmit(randomUser); + + EIP712Types.Permit memory p = _permitData(vault, owner, _warpBeforeRandomDeadline()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(randomUserPk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + } + + function test_permit_revertsWith_InvalidAddress_dueTo_ZeroAddressOwner() public { + EIP712Types.Permit memory p = _permitData(vault, address(0), _warpBeforeRandomDeadline()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + } + + // @dev Any nonce used at arbitrary namespace will revert with InvalidSignature. + function test_permit_revertsWith_InvalidSignature_dueTo_invalid_nonce_at_arbitrary_namespace( + bytes32 + ) public { + EIP712Types.Permit memory p = _permitData(vault, alice, _warpBeforeRandomDeadline()); + uint192 nonceKey = _randomNonceKey(); + while (nonceKey == vault.PERMIT_NONCE_NAMESPACE()) nonceKey = _randomNonceKey(); + + p.nonce = _getRandomNonceAtKey(nonceKey); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + } + + function test_permit_revertsWith_InvalidSignature_dueTo_invalid_nonce_at_permit_key_namespace( + bytes32 + ) public { + EIP712Types.Permit memory p = _permitData(vault, alice, _warpBeforeRandomDeadline()); + uint192 nonceKey = vault.PERMIT_NONCE_NAMESPACE(); + + p.nonce = _getRandomInvalidNonceAtKey(vault, p.owner, nonceKey); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + } + + function test_renounceAllowance() public { + address owner = vm.randomAddress(); + address spender = vm.randomAddress(); + uint256 amount = vm.randomUint(); + + vm.prank(owner); + vault.approve(spender, amount); + + assertEq(vault.allowance(owner, spender), amount); + + vm.expectEmit(address(vault)); + emit IERC20.Approval(owner, spender, 0); + vm.prank(spender); + vault.renounceAllowance(owner); + + assertEq(vault.allowance(owner, spender), 0); + } + + function test_renounceAllowance_noop() public { + address owner = vm.randomAddress(); + address spender = vm.randomAddress(); + + vm.prank(owner); + vault.approve(spender, 0); + + vm.record(); + vm.recordLogs(); + vm.prank(spender); + vault.renounceAllowance(owner); + + assertEq(vm.getRecordedLogs().length, 0); + (, bytes32[] memory writeSlots) = vm.accesses(address(vault)); + assertEq(writeSlots.length, 0); + } +} diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.Reverts.InsufficientAllowance.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.Reverts.InsufficientAllowance.t.sol new file mode 100644 index 000000000..32533e648 --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.Reverts.InsufficientAllowance.t.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; + +contract TokenizationSpokeInsufficientAllowanceTest is TokenizationSpokeBaseTest { + ITokenizationSpoke public vault; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + } + + function test_deposit_revertsWith_ERC20InsufficientAllowance() public { + (uint256 amount, uint256 allowance) = _setArbitraryAllowance(); + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(vault), + allowance, + amount + ) + ); + vm.prank(alice); + vault.deposit(amount, alice); + } + + function test_mint_revertsWith_ERC20InsufficientAllowance() public { + (uint256 amount, uint256 allowance) = _setArbitraryAllowance(); + uint256 shares = vault.previewMint(amount); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(vault), + allowance, + amount + ) + ); + vm.prank(alice); + vault.mint(shares, alice); + } + + function test_depositWithSig_revertsWith_ERC20InsufficientAllowance() public { + (uint256 amount, uint256 allowance) = _setArbitraryAllowance(); + uint256 deadline = _warpBeforeRandomDeadline(); + + ITokenizationSpoke.TokenizedDeposit memory p = _depositData(vault, alice, deadline); + p.assets = amount; + p.nonce = _burnRandomNoncesAtKey(vault, p.depositor); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(vault), + allowance, + p.assets + ) + ); + vm.prank(vm.randomAddress()); + vault.depositWithSig(p, signature); + } + + function test_mintWithSig_revertsWith_ERC20InsufficientAllowance() public { + (uint256 amount, uint256 allowance) = _setArbitraryAllowance(); + uint256 deadline = _warpBeforeRandomDeadline(); + + ITokenizationSpoke.TokenizedMint memory p = _mintData(vault, alice, deadline); + p.shares = vault.previewMint(amount); + p.nonce = _burnRandomNoncesAtKey(vault, p.depositor); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + uint256 neededAssets = IHub(vault.hub()).previewAddByShares(vault.assetId(), p.shares); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(vault), + allowance, + neededAssets + ) + ); + vm.prank(vm.randomAddress()); + vault.mintWithSig(p, signature); + } + + function _setArbitraryAllowance() internal returns (uint256, uint256) { + uint256 amount = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + uint256 allowance = vm.randomUint(0, amount - 1); + Utils.approve(vault, alice, allowance); + + return (amount, allowance); + } +} diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.Upgradeable.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.Upgradeable.t.sol new file mode 100644 index 000000000..9080a7e88 --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.Upgradeable.t.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; +import {MockTokenizationSpokeInstance} from 'tests/mocks/MockTokenizationSpokeInstance.sol'; + +contract TokenizationSpokeUpgradeableTest is TokenizationSpokeBaseTest { + address internal proxyAdminOwner = makeAddr('proxyAdminOwner'); + + function test_implementation_constructor_fuzz(uint64 revision) public { + address vaultImplAddress = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); + vm.expectEmit(vaultImplAddress); + emit Initializable.Initialized(type(uint64).max); + + TokenizationSpokeInstance vaultImpl = _deployMockTokenizationSpokeInstance(revision); + + assertEq(address(vaultImpl), vaultImplAddress); + assertEq(vaultImpl.SPOKE_REVISION(), revision); + assertEq(_getProxyInitializedVersion(vaultImplAddress), type(uint64).max); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + vaultImpl.initialize(SHARE_NAME, SHARE_SYMBOL); + } + + function test_proxy_constructor_fuzz(uint64 revision) public { + revision = uint64(bound(revision, 1, type(uint64).max)); + + TokenizationSpokeInstance vaultImpl = _deployMockTokenizationSpokeInstance(revision); + address vaultProxyAddress = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); + address proxyAdminAddress = vm.computeCreateAddress(vaultProxyAddress, 1); + + vm.expectEmit(vaultProxyAddress); + emit IERC1967.Upgraded(address(vaultImpl)); + vm.expectEmit(vaultProxyAddress); + emit Initializable.Initialized(revision); + vm.expectEmit(proxyAdminAddress); + emit Ownable.OwnershipTransferred(address(0), proxyAdminOwner); + vm.expectEmit(vaultProxyAddress); + emit IERC1967.AdminChanged(address(0), proxyAdminAddress); + ITokenizationSpoke vaultProxy = ITokenizationSpoke( + address( + new TransparentUpgradeableProxy( + address(vaultImpl), + proxyAdminOwner, + abi.encodeCall(TokenizationSpokeInstance.initialize, (SHARE_NAME, SHARE_SYMBOL)) + ) + ) + ); + + assertEq(address(vaultProxy), vaultProxyAddress); + assertEq(_getProxyAdminAddress(address(vaultProxy)), proxyAdminAddress); + assertEq(_getImplementationAddress(address(vaultProxy)), address(vaultImpl)); + + assertEq(_getProxyInitializedVersion(address(vaultProxy)), revision); + assertEq(vaultProxy.name(), SHARE_NAME); + assertEq(vaultProxy.symbol(), SHARE_SYMBOL); + } + + function test_proxy_reinitialization_fuzz(uint64 initialRevision) public { + initialRevision = uint64(bound(initialRevision, 1, type(uint64).max - 1)); + TokenizationSpokeInstance vaultImpl = _deployMockTokenizationSpokeInstance(initialRevision); + ITransparentUpgradeableProxy vaultProxy = ITransparentUpgradeableProxy( + address( + new TransparentUpgradeableProxy( + address(vaultImpl), + proxyAdminOwner, + abi.encodeCall(TokenizationSpokeInstance.initialize, (SHARE_NAME, SHARE_SYMBOL)) + ) + ) + ); + + string memory originalName = ITokenizationSpoke(address(vaultProxy)).name(); + + uint64 secondRevision = uint64(vm.randomUint(initialRevision + 1, type(uint64).max)); + TokenizationSpokeInstance vaultImpl2 = _deployMockTokenizationSpokeInstance(secondRevision); + + string memory newShareName = 'New Share Name'; + string memory newShareSymbol = 'New Share Symbol'; + vm.expectEmit(address(vaultProxy)); + emit Initializable.Initialized(secondRevision); + vm.recordLogs(); + vm.prank(_getProxyAdminAddress(address(vaultProxy))); + vaultProxy.upgradeToAndCall( + address(vaultImpl2), + _getInitializeCalldata(newShareName, newShareSymbol) + ); + + assertEq(ITokenizationSpoke(address(vaultProxy)).name(), newShareName); + assertEq(ITokenizationSpoke(address(vaultProxy)).symbol(), newShareSymbol); + assertNotEq(ITokenizationSpoke(address(vaultProxy)).name(), originalName); + } + + function test_proxy_constructor_revertsWith_InvalidInitialization_ZeroRevision() public { + TokenizationSpokeInstance vaultImpl = _deployMockTokenizationSpokeInstance(0); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + new TransparentUpgradeableProxy( + address(vaultImpl), + proxyAdminOwner, + abi.encodeCall(TokenizationSpokeInstance.initialize, (SHARE_NAME, SHARE_SYMBOL)) + ); + } + + function test_proxy_constructor_fuzz_revertsWith_InvalidInitialization( + uint64 initialRevision + ) public { + initialRevision = uint64(bound(initialRevision, 1, type(uint64).max)); + + TokenizationSpokeInstance vaultImpl = _deployMockTokenizationSpokeInstance(initialRevision); + ITransparentUpgradeableProxy vaultProxy = ITransparentUpgradeableProxy( + address( + new TransparentUpgradeableProxy( + address(vaultImpl), + proxyAdminOwner, + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) + ) + ) + ); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + vm.prank(_getProxyAdminAddress(address(vaultProxy))); + vaultProxy.upgradeToAndCall( + address(vaultImpl), + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) + ); + + uint64 secondRevision = uint64(vm.randomUint(0, initialRevision - 1)); + TokenizationSpokeInstance vaultImpl2 = _deployMockTokenizationSpokeInstance(secondRevision); + vm.expectRevert(Initializable.InvalidInitialization.selector); + vm.prank(_getProxyAdminAddress(address(vaultProxy))); + vaultProxy.upgradeToAndCall( + address(vaultImpl2), + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) + ); + } + + function test_proxy_reinitialization_revertsWith_CallerNotProxyAdmin() public { + TokenizationSpokeInstance vaultImpl = _deployMockTokenizationSpokeInstance(1); + ITransparentUpgradeableProxy vaultProxy = ITransparentUpgradeableProxy( + address( + new TransparentUpgradeableProxy( + address(vaultImpl), + proxyAdminOwner, + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) + ) + ) + ); + + TokenizationSpokeInstance vaultImpl2 = _deployMockTokenizationSpokeInstance(2); + vm.expectRevert(); + vm.prank(makeUser()); + vaultProxy.upgradeToAndCall( + address(vaultImpl2), + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) + ); + } + + function _getInitializeCalldata( + string memory shareName, + string memory shareSymbol + ) internal pure returns (bytes memory) { + return abi.encodeCall(TokenizationSpokeInstance.initialize, (shareName, shareSymbol)); + } + + function _deployMockTokenizationSpokeInstance( + uint64 revision + ) internal returns (TokenizationSpokeInstance) { + return + TokenizationSpokeInstance( + address(new MockTokenizationSpokeInstance(revision, address(hub1), daiAssetId)) + ); + } +} diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.WithSig.Reverts.InvalidSignature.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.WithSig.Reverts.InvalidSignature.t.sol new file mode 100644 index 000000000..6a77e5a3f --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.WithSig.Reverts.InvalidSignature.t.sol @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; + +contract TokenizationSpokeWithSigInvalidSignatureTest is TokenizationSpokeBaseTest { + ITokenizationSpoke public vault; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + } + + function test_depositWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { + ITokenizationSpoke.TokenizedDeposit memory p = _depositData( + vault, + alice, + _warpAfterRandomDeadline() + ); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.depositWithSig(p, signature); + } + + function test_mintWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { + ITokenizationSpoke.TokenizedMint memory p = _mintData(vault, alice, _warpAfterRandomDeadline()); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.mintWithSig(p, signature); + } + + function test_withdrawWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { + ITokenizationSpoke.TokenizedWithdraw memory p = _withdrawData( + vault, + alice, + _warpAfterRandomDeadline() + ); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.withdrawWithSig(p, signature); + } + + function test_redeemWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { + ITokenizationSpoke.TokenizedRedeem memory p = _redeemData( + vault, + alice, + _warpAfterRandomDeadline() + ); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.redeemWithSig(p, signature); + } + + function test_depositWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { + (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); + address depositor = _randomAddressOmit(randomUser); + + ITokenizationSpoke.TokenizedDeposit memory p = _depositData( + vault, + depositor, + _warpAfterRandomDeadline() + ); + bytes memory signature = _sign(randomUserPk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.depositWithSig(p, signature); + } + + function test_mintWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { + (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); + address depositor = _randomAddressOmit(randomUser); + + ITokenizationSpoke.TokenizedMint memory p = _mintData( + vault, + depositor, + _warpAfterRandomDeadline() + ); + bytes memory signature = _sign(randomUserPk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.mintWithSig(p, signature); + } + + function test_withdrawWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { + (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); + address owner = _randomAddressOmit(randomUser); + + ITokenizationSpoke.TokenizedWithdraw memory p = _withdrawData( + vault, + owner, + _warpAfterRandomDeadline() + ); + bytes memory signature = _sign(randomUserPk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.withdrawWithSig(p, signature); + } + + function test_redeemWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { + (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); + address owner = _randomAddressOmit(randomUser); + + ITokenizationSpoke.TokenizedRedeem memory p = _redeemData( + vault, + owner, + _warpAfterRandomDeadline() + ); + bytes memory signature = _sign(randomUserPk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.redeemWithSig(p, signature); + } + + function test_depositWithSig_revertsWith_InvalidAccountNonce(bytes32) public { + ITokenizationSpoke.TokenizedDeposit memory p = _depositData( + vault, + alice, + _warpBeforeRandomDeadline() + ); + uint192 nonceKey = _randomNonceKey(); + uint256 currentNonce = _burnRandomNoncesAtKey(vault, p.depositor, nonceKey); + p.nonce = _getRandomInvalidNonceAtKey(vault, p.depositor, nonceKey); + + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert( + abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, p.depositor, currentNonce) + ); + vm.prank(vm.randomAddress()); + vault.depositWithSig(p, signature); + } + + function test_mintWithSig_revertsWith_InvalidAccountNonce(bytes32) public { + ITokenizationSpoke.TokenizedMint memory p = _mintData( + vault, + alice, + _warpBeforeRandomDeadline() + ); + uint192 nonceKey = _randomNonceKey(); + uint256 currentNonce = _burnRandomNoncesAtKey(vault, p.depositor, nonceKey); + p.nonce = _getRandomInvalidNonceAtKey(vault, p.depositor, nonceKey); + + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert( + abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, p.depositor, currentNonce) + ); + vm.prank(vm.randomAddress()); + vault.mintWithSig(p, signature); + } + + function test_withdrawWithSig_revertsWith_InvalidAccountNonce(bytes32) public { + ITokenizationSpoke.TokenizedWithdraw memory p = _withdrawData( + vault, + alice, + _warpBeforeRandomDeadline() + ); + uint192 nonceKey = _randomNonceKey(); + uint256 currentNonce = _burnRandomNoncesAtKey(vault, p.owner, nonceKey); + p.nonce = _getRandomInvalidNonceAtKey(vault, p.owner, nonceKey); + + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert( + abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, p.owner, currentNonce) + ); + vm.prank(vm.randomAddress()); + vault.withdrawWithSig(p, signature); + } + + function test_redeemWithSig_revertsWith_InvalidAccountNonce(bytes32) public { + ITokenizationSpoke.TokenizedRedeem memory p = _redeemData( + vault, + alice, + _warpBeforeRandomDeadline() + ); + uint192 nonceKey = _randomNonceKey(); + uint256 currentNonce = _burnRandomNoncesAtKey(vault, p.owner, nonceKey); + p.nonce = _getRandomInvalidNonceAtKey(vault, p.owner, nonceKey); + + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert( + abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, p.owner, currentNonce) + ); + vm.prank(vm.randomAddress()); + vault.redeemWithSig(p, signature); + } +} diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.WithSig.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.WithSig.t.sol new file mode 100644 index 000000000..f3f909442 --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.WithSig.t.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; + +contract TokenizationSpokeWithSigTest is TokenizationSpokeBaseTest { + using SafeCast for *; + + ITokenizationSpoke public vault; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + } + + function test_useNonce_monotonic(bytes32) public { + vm.setArbitraryStorage(address(vault)); + address user = vm.randomAddress(); + uint192 nonceKey = vm.randomUint(0, type(uint192).max).toUint192(); + + (, uint64 nonce) = _unpackNonce(vault.nonces(user, nonceKey)); + + vm.prank(user); + vault.useNonce(nonceKey); + + // prettier-ignore + unchecked { ++nonce; } + assertEq(vault.nonces(user, nonceKey), _packNonce(nonceKey, nonce)); + } + + function test_depositWithSig(bytes32) public { + ITokenizationSpoke.TokenizedDeposit memory p = _depositData( + vault, + alice, + _warpBeforeRandomDeadline() + ); + p.nonce = _burnRandomNoncesAtKey(vault, p.depositor); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.assets); + + uint256 shares = IHub(vault.hub()).previewAddByAssets(vault.assetId(), p.assets); + + vm.expectEmit(address(vault)); + emit IERC4626.Deposit(p.depositor, p.receiver, p.assets, shares); + + vm.prank(vm.randomAddress()); + uint256 returnShares = vault.depositWithSig(p, signature); + + assertEq(returnShares, shares); + _assertNonceIncrement(vault, alice, p.nonce); + _assertVaultHasNoBalanceOrAllowance(vault, alice); + } + + function test_mintWithSig(bytes32) public { + ITokenizationSpoke.TokenizedMint memory p = _mintData( + vault, + alice, + _warpBeforeRandomDeadline() + ); + p.nonce = _burnRandomNoncesAtKey(vault, p.depositor); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.shares); + + uint256 assets = IHub(vault.hub()).previewAddByShares(vault.assetId(), p.shares); + + vm.expectEmit(address(vault)); + emit IERC4626.Deposit(p.depositor, p.receiver, p.shares, assets); + + vm.prank(vm.randomAddress()); + uint256 returnAssets = vault.mintWithSig(p, signature); + + assertEq(returnAssets, assets); + _assertNonceIncrement(vault, alice, p.nonce); + _assertVaultHasNoBalanceOrAllowance(vault, alice); + } + + function test_withdrawWithSig(bytes32) public { + ITokenizationSpoke.TokenizedWithdraw memory p = _withdrawData( + vault, + alice, + _warpBeforeRandomDeadline() + ); + p.nonce = _burnRandomNoncesAtKey(vault, p.owner); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.assets); + vm.prank(alice); + vault.deposit(p.assets, alice); + + uint256 shares = IHub(vault.hub()).previewAddByAssets(vault.assetId(), p.assets); + + vm.expectEmit(address(vault)); + emit IERC4626.Withdraw(p.owner, p.receiver, p.owner, p.assets, shares); + + vm.prank(vm.randomAddress()); + uint256 returnShares = vault.withdrawWithSig(p, signature); + + assertEq(returnShares, shares); + _assertNonceIncrement(vault, alice, p.nonce); + _assertVaultHasNoBalanceOrAllowance(vault, alice); + } + + function test_redeemWithSig(bytes32) public { + ITokenizationSpoke.TokenizedRedeem memory p = _redeemData( + vault, + alice, + _warpBeforeRandomDeadline() + ); + p.nonce = _burnRandomNoncesAtKey(vault, p.owner); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.shares); + vm.prank(alice); + vault.mint(p.shares, alice); + + uint256 assets = IHub(vault.hub()).previewAddByShares(vault.assetId(), p.shares); + + vm.expectEmit(address(vault)); + emit IERC4626.Withdraw(p.owner, p.receiver, p.owner, p.shares, assets); + + vm.prank(vm.randomAddress()); + uint256 returnAssets = vault.redeemWithSig(p, signature); + + assertEq(returnAssets, assets); + _assertNonceIncrement(vault, alice, p.nonce); + _assertVaultHasNoBalanceOrAllowance(vault, alice); + } +} diff --git a/tests/unit/TokenizationSpoke/TokenizationSpoke.t.sol b/tests/unit/TokenizationSpoke/TokenizationSpoke.t.sol new file mode 100644 index 000000000..42e18b1a8 --- /dev/null +++ b/tests/unit/TokenizationSpoke/TokenizationSpoke.t.sol @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/TokenizationSpoke/TokenizationSpoke.Base.t.sol'; + +contract TokenizationSpokeTest is TokenizationSpokeBaseTest { + ITokenizationSpoke public vault; + TestnetERC20 public asset; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + asset = TestnetERC20(vault.asset()); + } + + function test_deposit() public { + address depositor = alice; + uint256 assets = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + asset.mint(depositor, assets); + Utils.approve(vault, depositor, assets); + + uint256 alicePreDepositBal = asset.balanceOf(depositor); + uint256 expectedShares = IHub(vault.hub()).previewAddByAssets(vault.assetId(), assets); + + vm.expectCall(vault.hub(), abi.encodeCall(IHubBase.add, (vault.assetId(), assets))); + vm.expectEmit(address(vault)); + emit IERC20.Transfer(address(0), depositor, expectedShares); + vm.expectEmit(address(vault)); + emit IERC4626.Deposit(depositor, depositor, assets, expectedShares); + + vm.prank(depositor); + uint256 shares = vault.deposit(assets, depositor); + + assertEq(shares, expectedShares); + assertEq(vault.previewWithdraw(shares), assets); + assertEq(vault.previewDeposit(assets), shares); + assertEq(vault.totalSupply(), shares); + assertEq(vault.totalAssets(), assets); + assertEq(vault.balanceOf(depositor), expectedShares); + assertEq(vault.convertToAssets(vault.balanceOf(depositor)), assets); + assertEq(asset.balanceOf(depositor), alicePreDepositBal - assets); + _assertVaultHasNoBalanceOrAllowance(vault, depositor); + } + + function test_deposit_receiverDifferentFromCaller() public { + address depositor = alice; + address receiver = bob; + uint256 assets = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + asset.mint(depositor, assets); + Utils.approve(vault, depositor, assets); + + uint256 expectedShares = IHub(vault.hub()).previewAddByAssets(vault.assetId(), assets); + + vm.expectCall(vault.hub(), abi.encodeCall(IHubBase.add, (vault.assetId(), assets))); + vm.expectEmit(address(vault)); + emit IERC20.Transfer(address(0), receiver, expectedShares); + vm.expectEmit(address(vault)); + emit IERC4626.Deposit(depositor, receiver, assets, expectedShares); + + vm.prank(depositor); + uint256 shares = vault.deposit(assets, receiver); + + assertEq(shares, expectedShares); + assertEq(vault.balanceOf(receiver), expectedShares); + assertEq(vault.balanceOf(depositor), 0); + _assertVaultHasNoBalanceOrAllowance(vault, depositor); + _assertVaultHasNoBalanceOrAllowance(vault, receiver); + } + + function test_mint() public { + address depositor = alice; + uint256 shares = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + uint256 expectedAssets = IHub(vault.hub()).previewAddByShares(vault.assetId(), shares); + asset.mint(depositor, expectedAssets); + Utils.approve(vault, depositor, expectedAssets); + + uint256 alicePreDepositBal = asset.balanceOf(depositor); + + vm.expectCall(vault.hub(), abi.encodeCall(IHubBase.add, (vault.assetId(), expectedAssets))); + vm.expectEmit(address(vault)); + emit IERC20.Transfer(address(0), depositor, shares); + vm.expectEmit(address(vault)); + emit IERC4626.Deposit(depositor, depositor, expectedAssets, shares); + + vm.prank(depositor); + uint256 assets = vault.mint(shares, depositor); + + assertEq(assets, expectedAssets); + assertEq(vault.previewWithdraw(shares), assets); + assertEq(vault.previewDeposit(assets), shares); + assertEq(vault.totalSupply(), shares); + assertEq(vault.totalAssets(), assets); + assertEq(vault.balanceOf(depositor), shares); + assertEq(vault.convertToAssets(vault.balanceOf(depositor)), assets); + assertEq(asset.balanceOf(depositor), alicePreDepositBal - assets); + _assertVaultHasNoBalanceOrAllowance(vault, depositor); + } + + function test_mint_receiverDifferentFromCaller() public { + address depositor = alice; + address receiver = bob; + uint256 shares = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + uint256 expectedAssets = IHub(vault.hub()).previewAddByShares(vault.assetId(), shares); + asset.mint(depositor, expectedAssets); + Utils.approve(vault, depositor, expectedAssets); + + vm.expectCall(vault.hub(), abi.encodeCall(IHubBase.add, (vault.assetId(), expectedAssets))); + vm.expectEmit(address(vault)); + emit IERC20.Transfer(address(0), receiver, shares); + vm.expectEmit(address(vault)); + emit IERC4626.Deposit(depositor, receiver, expectedAssets, shares); + + vm.prank(depositor); + uint256 assets = vault.mint(shares, receiver); + + assertEq(assets, expectedAssets); + assertEq(vault.balanceOf(receiver), shares); + assertEq(vault.balanceOf(depositor), 0); + _assertVaultHasNoBalanceOrAllowance(vault, depositor); + _assertVaultHasNoBalanceOrAllowance(vault, receiver); + } + + function test_withdraw() public { + address owner = alice; + uint256 depositAssets = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + asset.mint(owner, depositAssets); + Utils.approve(vault, owner, depositAssets); + vm.prank(owner); + vault.deposit(depositAssets, owner); + + uint256 alicePreWithdrawBal = asset.balanceOf(owner); + uint256 withdrawAssets = depositAssets; + uint256 expectedShares = IHub(vault.hub()).previewRemoveByAssets( + vault.assetId(), + withdrawAssets + ); + + vm.expectCall( + vault.hub(), + abi.encodeCall(IHubBase.remove, (vault.assetId(), withdrawAssets, owner)) + ); + vm.expectEmit(address(vault)); + emit IERC20.Transfer(owner, address(0), expectedShares); + vm.expectEmit(address(vault)); + emit IERC4626.Withdraw(owner, owner, owner, withdrawAssets, expectedShares); + + vm.prank(owner); + uint256 shares = vault.withdraw(withdrawAssets, owner, owner); + + assertEq(shares, expectedShares); + assertEq(vault.totalAssets(), 0); + assertEq(vault.balanceOf(owner), 0); + assertEq(vault.convertToAssets(vault.balanceOf(owner)), 0); + assertEq(asset.balanceOf(owner), alicePreWithdrawBal + withdrawAssets); + _assertVaultHasNoBalanceOrAllowance(vault, owner); + } + + function test_withdraw_ownerDifferentFromCaller() public { + address owner = alice; + address caller = bob; + address receiver = carol; + uint256 depositAssets = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + asset.mint(owner, depositAssets); + Utils.approve(vault, owner, depositAssets); + vm.prank(owner); + uint256 depositedShares = vault.deposit(depositAssets, owner); + + vm.prank(owner); + vault.approve(caller, depositedShares); + + uint256 withdrawAssets = depositAssets; + uint256 expectedShares = IHub(vault.hub()).previewRemoveByAssets( + vault.assetId(), + withdrawAssets + ); + + vm.expectCall( + vault.hub(), + abi.encodeCall(IHubBase.remove, (vault.assetId(), withdrawAssets, receiver)) + ); + vm.expectEmit(address(vault)); + emit IERC20.Transfer(owner, address(0), expectedShares); + vm.expectEmit(address(vault)); + emit IERC4626.Withdraw(caller, receiver, owner, withdrawAssets, expectedShares); + + vm.prank(caller); + uint256 shares = vault.withdraw(withdrawAssets, receiver, owner); + + assertEq(shares, expectedShares); + assertEq(vault.balanceOf(owner), 0); + assertEq(vault.allowance(owner, caller), 0); + _assertVaultHasNoBalanceOrAllowance(vault, owner); + _assertVaultHasNoBalanceOrAllowance(vault, caller); + _assertVaultHasNoBalanceOrAllowance(vault, receiver); + } + + function test_redeem() public { + address owner = alice; + uint256 mintShares = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + uint256 mintAssets = IHub(vault.hub()).previewAddByShares(vault.assetId(), mintShares); + asset.mint(owner, mintAssets); + Utils.approve(vault, owner, mintAssets); + vm.prank(owner); + vault.mint(mintShares, owner); + + uint256 alicePreRedeemBal = asset.balanceOf(owner); + uint256 redeemShares = mintShares; + uint256 expectedAssets = IHub(vault.hub()).previewRemoveByShares(vault.assetId(), redeemShares); + + vm.expectCall( + vault.hub(), + abi.encodeCall(IHubBase.remove, (vault.assetId(), expectedAssets, owner)) + ); + vm.expectEmit(address(vault)); + emit IERC20.Transfer(owner, address(0), redeemShares); + vm.expectEmit(address(vault)); + emit IERC4626.Withdraw(owner, owner, owner, expectedAssets, redeemShares); + + vm.prank(owner); + uint256 assets = vault.redeem(redeemShares, owner, owner); + + assertEq(assets, expectedAssets); + assertEq(vault.totalAssets(), 0); + assertEq(vault.balanceOf(owner), 0); + assertEq(vault.convertToAssets(vault.balanceOf(owner)), 0); + assertEq(asset.balanceOf(owner), alicePreRedeemBal + assets); + _assertVaultHasNoBalanceOrAllowance(vault, owner); + } + + function test_redeem_ownerDifferentFromCaller() public { + address owner = alice; + address caller = bob; + address receiver = carol; + uint256 mintShares = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + uint256 mintAssets = IHub(vault.hub()).previewAddByShares(vault.assetId(), mintShares); + asset.mint(owner, mintAssets); + Utils.approve(vault, owner, mintAssets); + vm.prank(owner); + vault.mint(mintShares, owner); + + vm.prank(owner); + vault.approve(caller, mintShares); + + uint256 redeemShares = mintShares; + uint256 expectedAssets = IHub(vault.hub()).previewRemoveByShares(vault.assetId(), redeemShares); + + vm.expectCall( + vault.hub(), + abi.encodeCall(IHubBase.remove, (vault.assetId(), expectedAssets, receiver)) + ); + vm.expectEmit(address(vault)); + emit IERC20.Transfer(owner, address(0), redeemShares); + vm.expectEmit(address(vault)); + emit IERC4626.Withdraw(caller, receiver, owner, expectedAssets, redeemShares); + + vm.prank(caller); + uint256 assets = vault.redeem(redeemShares, receiver, owner); + + assertEq(assets, expectedAssets); + assertEq(vault.balanceOf(owner), 0); + assertEq(vault.allowance(owner, caller), 0); + _assertVaultHasNoBalanceOrAllowance(vault, owner); + _assertVaultHasNoBalanceOrAllowance(vault, caller); + _assertVaultHasNoBalanceOrAllowance(vault, receiver); + } +} diff --git a/tests/unit/UnitPriceFeed.t.sol b/tests/unit/UnitPriceFeed.t.sol index db45f97ce..bf0c57805 100644 --- a/tests/unit/UnitPriceFeed.t.sol +++ b/tests/unit/UnitPriceFeed.t.sol @@ -17,6 +17,14 @@ contract UnitPriceFeedTest is Base { unitPriceFeed = new UnitPriceFeed(DECIMALS, _description); } + function test_constructor_revertsWith_Uint8Overflow() public { + uint8 invalidDecimals = 77; + vm.expectRevert( + abi.encodeWithSelector(SafeCast.SafeCastOverflowedUintToInt.selector, 10 ** invalidDecimals) + ); + new UnitPriceFeed(invalidDecimals, _description); + } + function testDECIMALS() public view { assertEq(unitPriceFeed.decimals(), DECIMALS); } diff --git a/tests/unit/WadRayMath.t.sol b/tests/unit/WadRayMath.t.sol index 74ac566fd..6b4b25f3c 100644 --- a/tests/unit/WadRayMath.t.sol +++ b/tests/unit/WadRayMath.t.sol @@ -13,6 +13,7 @@ contract WadRayMathDifferentialTests is Test { } function test_constants() public view { + assertEq(w.WAD_DECIMALS(), 18, 'wad decimals'); assertEq(w.WAD(), 1e18, 'wad'); assertEq(w.RAY(), 1e27, 'ray'); assertEq(w.PERCENTAGE_FACTOR(), 1e4, 'percentage factor'); @@ -225,15 +226,14 @@ contract WadRayMathDifferentialTests is Test { uint256 b; bool safetyCheck; unchecked { - b = a * w.WAD(); - safetyCheck = b / w.WAD() == a; + b = a * (w.WAD() / w.PERCENTAGE_FACTOR()); + safetyCheck = (a == 0 || type(uint256).max / a >= w.WAD() / w.PERCENTAGE_FACTOR()); } if (!safetyCheck) { vm.expectRevert(); w.bpsToWad(a); } else { - assertEq(w.bpsToWad(a), (a * w.WAD()) / 100_00); - assertEq(w.bpsToWad(a), b / 100_00); + assertEq(w.bpsToWad(a), b); } } @@ -241,15 +241,31 @@ contract WadRayMathDifferentialTests is Test { uint256 b; bool safetyCheck; unchecked { - b = a * w.RAY(); - safetyCheck = b / w.RAY() == a; + b = a * (w.RAY() / w.PERCENTAGE_FACTOR()); + safetyCheck = (a == 0 || type(uint256).max / a >= w.RAY() / w.PERCENTAGE_FACTOR()); } if (!safetyCheck) { vm.expectRevert(); w.bpsToRay(a); } else { - assertEq(w.bpsToRay(a), (a * w.RAY()) / 100_00); - assertEq(w.bpsToRay(a), b / 100_00); + assertEq(w.bpsToRay(a), b); } } + + function test_roundRayUp_fuzz(uint256 a) public { + if (a % w.RAY() == 0) { + assertEq(w.roundRayUp(a), a); + } else if (a <= (type(uint256).max / w.RAY()) * w.RAY()) { + assertEq(w.roundRayUp(a), ((a - 1) / w.RAY() + 1) * w.RAY()); // a == 0 enters the first if block + } else { + vm.expectRevert(); + w.roundRayUp(a); + } + } + + function test_roundRayUp_overflow() public { + uint256 maxA = (type(uint256).max / w.RAY()) * w.RAY(); + test_roundRayUp_fuzz(maxA); + test_roundRayUp_fuzz(maxA + 1); + } } diff --git a/tests/unit/libraries/KeyValueList.t.sol b/tests/unit/libraries/KeyValueList.t.sol index 54ea41215..5950460f2 100644 --- a/tests/unit/libraries/KeyValueList.t.sol +++ b/tests/unit/libraries/KeyValueList.t.sol @@ -89,14 +89,14 @@ contract KeyValueListTest is Test { KeyValueListWrapper wrapper = new KeyValueListWrapper(); KeyValueList.List memory list = KeyValueList.init(5); - if (key >= KeyValueList._MAX_KEY || value >= KeyValueList._MAX_VALUE) { + if (key >= KeyValueList.MAX_KEY || value >= KeyValueList.MAX_VALUE) { vm.expectRevert(KeyValueList.MaxDataSizeExceeded.selector); wrapper.add(list, 0, key, value); } else { list.add(0, key, value); } - if (key < KeyValueList._MAX_KEY && value < KeyValueList._MAX_VALUE) { + if (key < KeyValueList.MAX_KEY && value < KeyValueList.MAX_VALUE) { (uint256 storedKey, uint256 storedValue) = list.get(0); assertEq(storedKey, key); assertEq(storedValue, value); @@ -211,6 +211,31 @@ contract KeyValueListTest is Test { } } + function test_fuzz_uncheckedAt(uint256[] memory seed) public pure { + vm.assume(seed.length > 0 && seed.length < 1e2); + KeyValueList.List memory list = KeyValueList.init(seed.length); + for (uint256 i; i < seed.length; ++i) { + list.add(i, _truncateKey(seed[i]), _truncateValue(seed[i])); + } + for (uint256 i; i < seed.length; ++i) { + (uint256 keyGet, uint256 valueGet) = list.get(i); + (uint256 keyUnsafe, uint256 valueUnsafe) = list.uncheckedAt(i); + assertEq(keyGet, keyUnsafe); + assertEq(valueGet, valueUnsafe); + } + } + + function test_fuzz_pack_unpack_roundtrip(uint256 key, uint256 value) public pure { + key = bound(key, 0, KeyValueList.MAX_KEY - 1); + value = bound(value, 0, KeyValueList.MAX_VALUE - 1); + + uint256 packed = KeyValueList.pack(key, value); + (uint256 unpackedKey, uint256 unpackedValue) = KeyValueList.unpack(packed); + + assertEq(key, unpackedKey); + assertEq(value, unpackedValue); + } + function _assertSortedOrder(KeyValueList.List memory list) internal pure { // validate sorted order (uint256 prevKey, uint256 prevValue) = list.get(0); @@ -226,11 +251,11 @@ contract KeyValueListTest is Test { } function _truncateKey(uint256 key) internal pure returns (uint256) { - return key % KeyValueList._MAX_KEY; + return key % KeyValueList.MAX_KEY; } function _truncateValue(uint256 value) internal pure returns (uint256) { - return value % KeyValueList._MAX_VALUE; + return value % KeyValueList.MAX_VALUE; } function _generateRandomUint256Array( @@ -244,8 +269,7 @@ contract KeyValueListTest is Test { uint256[] memory result = new uint256[](size); for (uint256 i; i < size; ++i) { result[i] = - (uint256((keccak256(abi.encode(seed + i)))) % (upperBound - lowerBound + 1)) + - lowerBound; + (uint256((keccak256(abi.encode(seed + i)))) % (upperBound - lowerBound + 1)) + lowerBound; } return result; } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol index 06f8c2f43..9eb915545 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol @@ -41,7 +41,11 @@ contract LiquidationLogicBaseTest is SpokeBase { function _bound( LiquidationLogic.CalculateDebtToTargetHealthFactorParams memory params ) internal virtual returns (LiquidationLogic.CalculateDebtToTargetHealthFactorParams memory) { - uint256 totalDebtValue = bound(params.totalDebtValue, 1, MAX_SUPPLY_IN_BASE_CURRENCY); + uint256 totalDebtValueRay = bound( + params.totalDebtValueRay, + 1, + MAX_SUPPLY_IN_BASE_CURRENCY * WadRayMath.RAY + ); uint256 liquidationBonus = bound( params.liquidationBonus, @@ -68,7 +72,7 @@ contract LiquidationLogicBaseTest is SpokeBase { return LiquidationLogic.CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: totalDebtValue, + totalDebtValueRay: totalDebtValueRay, debtAssetUnit: debtAssetUnit, debtAssetPrice: debtAssetPrice, collateralFactor: collateralFactor, @@ -86,20 +90,33 @@ contract LiquidationLogicBaseTest is SpokeBase { ); uint256 debtToCover = bound(params.debtToCover, 0, MAX_SUPPLY_AMOUNT); - uint256 debtReserveBalance = bound( - params.debtReserveBalance, + uint256 drawnIndex = bound(params.drawnIndex, MIN_DRAWN_INDEX, MAX_DRAWN_INDEX); + uint256 drawnShares = bound( + params.drawnShares, + 1, + _convertValueToAmount( + MAX_SUPPLY_AMOUNT, + debtToTargetParams.debtAssetPrice, + debtToTargetParams.debtAssetUnit + ) + ); + uint256 premiumDebtRay = bound( + params.premiumDebtRay, 0, _convertValueToAmount( - debtToTargetParams.totalDebtValue, + MAX_SUPPLY_AMOUNT, debtToTargetParams.debtAssetPrice, debtToTargetParams.debtAssetUnit - ).min(MAX_SUPPLY_AMOUNT) + ) ); return LiquidationLogic.CalculateDebtToLiquidateParams({ - debtReserveBalance: debtReserveBalance, - totalDebtValue: debtToTargetParams.totalDebtValue, + drawnShares: drawnShares, + premiumDebtRay: premiumDebtRay, + drawnIndex: drawnIndex, + totalDebtValueRay: debtToTargetParams.totalDebtValueRay, + debtAssetDecimals: Math.log10(debtToTargetParams.debtAssetUnit), debtAssetUnit: debtToTargetParams.debtAssetUnit, debtAssetPrice: debtToTargetParams.debtAssetPrice, debtToCover: debtToCover, @@ -114,19 +131,99 @@ contract LiquidationLogicBaseTest is SpokeBase { LiquidationLogic.CalculateDebtToLiquidateParams memory params ) internal virtual returns (LiquidationLogic.CalculateDebtToLiquidateParams memory) { params = _bound(params); - uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( + // bound price such that 1 drawn share is worth less than DUST_LIQUIDATION_THRESHOLD + params.debtAssetPrice = bound( + params.debtAssetPrice, + 1, + _convertDecimals( + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + 18, + params.debtAssetDecimals, + false + ).rayDivDown(params.drawnIndex) + ); + + uint256 debtRayToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( _getDebtToTargetHealthFactorParams(params) ); - params.debtReserveBalance = bound( - params.debtReserveBalance, - debtToTarget.min(params.debtToCover) + 1, - debtToTarget.min(params.debtToCover) + + + uint256 debtRayToLiquidate = debtRayToTarget.min( + _max( + _min(type(uint256).max / WadRayMath.RAY, params.debtToCover.toRay()), + params.drawnIndex + ) - params.drawnIndex // debtToCover acts as an upperbound + ); + uint256 debtRay = vm.randomUint( + debtRayToLiquidate + 1, + debtRayToLiquidate + _convertValueToAmount( LiquidationLogic.DUST_LIQUIDATION_THRESHOLD - 1, params.debtAssetPrice, params.debtAssetUnit - ) + ).toRay() ); + + params.drawnShares = bound(params.drawnShares, 0, debtRay / params.drawnIndex); + vm.assume(params.drawnShares > 0); + params.premiumDebtRay = debtRay - params.drawnShares * params.drawnIndex; + + return params; + } + + function _bound( + LiquidationLogic.CalculateCollateralToLiquidateParams memory params + ) internal virtual returns (LiquidationLogic.CalculateCollateralToLiquidateParams memory) { + params.collateralReserveHub = hub1; + params.collateralReserveAssetId = bound( + params.collateralReserveAssetId, + 0, + IHub(address(params.collateralReserveHub)).getAssetCount() - 1 + ); + params.collateralAssetUnit = + 10 ** + bound(params.collateralAssetUnit, MIN_TOKEN_DECIMALS_SUPPORTED, MAX_TOKEN_DECIMALS_SUPPORTED); + params.collateralAssetPrice = bound(params.collateralAssetPrice, 1, MAX_ASSET_PRICE); + params.drawnIndex = bound(params.drawnIndex, MIN_DRAWN_INDEX, MAX_DRAWN_INDEX); + params.drawnSharesToLiquidate = bound( + params.drawnSharesToLiquidate, + 0, + MAX_SUPPLY_AMOUNT / params.drawnIndex + ); + params.premiumDebtRayToLiquidate = bound( + params.premiumDebtRayToLiquidate, + 0, + MAX_SUPPLY_AMOUNT - params.drawnSharesToLiquidate * params.drawnIndex + ); + params.debtAssetUnit = + 10 ** bound(params.debtAssetUnit, MIN_TOKEN_DECIMALS_SUPPORTED, MAX_TOKEN_DECIMALS_SUPPORTED); + uint256 debtRayToLiquidate = params.drawnSharesToLiquidate * params.drawnIndex + + params.premiumDebtRayToLiquidate; + params.debtAssetPrice = bound( + params.debtAssetPrice, + 1, + MAX_SUPPLY_AMOUNT / + _max(1, _convertAmountToValue(debtRayToLiquidate.fromRayUp(), 1, params.debtAssetUnit)) + ); + params.liquidationBonus = bound( + params.liquidationBonus, + MIN_LIQUIDATION_BONUS, + MAX_LIQUIDATION_BONUS + ); + + uint256 hubAddedShares = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + uint256 hubAddedAssets = vm.randomUint( + hubAddedShares, + MAX_SUPPLY_AMOUNT.min( + MAX_SUPPLY_PRICE * (hubAddedShares + SharesMath.VIRTUAL_SHARES) - SharesMath.VIRTUAL_ASSETS + ) + ); + _mockSupplySharePrice( + IHub(address(params.collateralReserveHub)), + params.collateralReserveAssetId, + hubAddedAssets, + hubAddedShares + ); + return params; } @@ -145,18 +242,20 @@ contract LiquidationLogicBaseTest is SpokeBase { params.maxLiquidationBonus ); - params.debtAssetUnit = bound( - params.debtAssetUnit, - 10 ** MIN_TOKEN_DECIMALS_SUPPORTED, - 10 ** MAX_TOKEN_DECIMALS_SUPPORTED + params.debtAssetDecimals = bound( + params.debtAssetDecimals, + MIN_TOKEN_DECIMALS_SUPPORTED, + MAX_TOKEN_DECIMALS_SUPPORTED ); LiquidationLogic.CalculateDebtToLiquidateParams memory debtToLiquidateParams = _getCalculateDebtToLiquidateParams(params); debtToLiquidateParams = _bound(debtToLiquidateParams); - params.debtReserveBalance = debtToLiquidateParams.debtReserveBalance; - params.totalDebtValue = debtToLiquidateParams.totalDebtValue; + params.drawnShares = debtToLiquidateParams.drawnShares; + params.premiumDebtRay = debtToLiquidateParams.premiumDebtRay; + params.drawnIndex = debtToLiquidateParams.drawnIndex; + params.totalDebtValueRay = debtToLiquidateParams.totalDebtValueRay; params.debtAssetPrice = debtToLiquidateParams.debtAssetPrice; params.debtToCover = debtToLiquidateParams.debtToCover; params.healthFactor = debtToLiquidateParams.healthFactor; @@ -164,13 +263,33 @@ contract LiquidationLogicBaseTest is SpokeBase { params.collateralFactor = debtToLiquidateParams.collateralFactor; params.collateralAssetPrice = bound(params.collateralAssetPrice, 1, MAX_ASSET_PRICE); - params.collateralAssetUnit = bound( - params.collateralAssetUnit, - 10 ** MIN_TOKEN_DECIMALS_SUPPORTED, - 10 ** MAX_TOKEN_DECIMALS_SUPPORTED + params.collateralAssetDecimals = bound( + params.collateralAssetDecimals, + MIN_TOKEN_DECIMALS_SUPPORTED, + MAX_TOKEN_DECIMALS_SUPPORTED ); params.liquidationFee = bound(params.liquidationFee, 0, PercentageMath.PERCENTAGE_FACTOR); - params.collateralReserveBalance = bound(params.collateralReserveBalance, 0, MAX_SUPPLY_AMOUNT); + + params.suppliedShares = bound(params.suppliedShares, 0, MAX_SUPPLY_AMOUNT); + uint256 hubAddedShares = vm.randomUint(params.suppliedShares, MAX_SUPPLY_AMOUNT); + uint256 hubAddedAssets = vm.randomUint( + hubAddedShares, + MAX_SUPPLY_AMOUNT.min( + MAX_SUPPLY_PRICE * (hubAddedShares + SharesMath.VIRTUAL_SHARES) - SharesMath.VIRTUAL_ASSETS + ) + ); + params.collateralReserveHub = hub1; + params.collateralReserveAssetId = bound( + params.collateralReserveAssetId, + 0, + IHub(address(params.collateralReserveHub)).getAssetCount() - 1 + ); + _mockSupplySharePrice( + IHub(address(params.collateralReserveHub)), + params.collateralReserveAssetId, + hubAddedAssets, + hubAddedShares + ); return params; } @@ -183,9 +302,11 @@ contract LiquidationLogicBaseTest is SpokeBase { memory debtToLiquidateParams = _getCalculateDebtToLiquidateParams(params); debtToLiquidateParams = _boundWithDustAdjustment(debtToLiquidateParams); - params.debtReserveBalance = debtToLiquidateParams.debtReserveBalance; - params.totalDebtValue = debtToLiquidateParams.totalDebtValue; - params.debtAssetUnit = debtToLiquidateParams.debtAssetUnit; + params.drawnShares = debtToLiquidateParams.drawnShares; + params.premiumDebtRay = debtToLiquidateParams.premiumDebtRay; + params.drawnIndex = debtToLiquidateParams.drawnIndex; + params.totalDebtValueRay = debtToLiquidateParams.totalDebtValueRay; + params.debtAssetDecimals = debtToLiquidateParams.debtAssetDecimals; params.debtAssetPrice = debtToLiquidateParams.debtAssetPrice; params.debtToCover = debtToLiquidateParams.debtToCover; params.collateralFactor = debtToLiquidateParams.collateralFactor; @@ -200,7 +321,7 @@ contract LiquidationLogicBaseTest is SpokeBase { ) internal pure returns (LiquidationLogic.CalculateDebtToTargetHealthFactorParams memory) { return LiquidationLogic.CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: params.totalDebtValue, + totalDebtValueRay: params.totalDebtValueRay, debtAssetUnit: params.debtAssetUnit, debtAssetPrice: params.debtAssetPrice, collateralFactor: params.collateralFactor, @@ -221,9 +342,12 @@ contract LiquidationLogicBaseTest is SpokeBase { }); return LiquidationLogic.CalculateDebtToLiquidateParams({ - debtReserveBalance: params.debtReserveBalance, - totalDebtValue: params.totalDebtValue, - debtAssetUnit: params.debtAssetUnit, + drawnShares: params.drawnShares, + premiumDebtRay: params.premiumDebtRay, + drawnIndex: params.drawnIndex, + totalDebtValueRay: params.totalDebtValueRay, + debtAssetDecimals: params.debtAssetDecimals, + debtAssetUnit: 10 ** params.debtAssetDecimals, debtAssetPrice: params.debtAssetPrice, debtToCover: params.debtToCover, collateralFactor: params.collateralFactor, diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.CollateralToLiquidate.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.CollateralToLiquidate.t.sol new file mode 100644 index 000000000..28a650c4a --- /dev/null +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.CollateralToLiquidate.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol'; + +contract LiquidationLogicCollateralToLiquidateTest is LiquidationLogicBaseTest { + using WadRayMath for uint256; + + function test_calculateCollateralToLiquidate_fuzz( + LiquidationLogic.CalculateCollateralToLiquidateParams memory params + ) public { + params = _bound(params); + + uint256 collateralAmountToLiquidate = _calculateCollateralAmountToLiquidate(params); + uint256 expectedCollateralSharesToLiquidate = _calculateCollateralSharesToLiquidate( + params, + collateralAmountToLiquidate + ); + + vm.expectCall( + address(params.collateralReserveHub), + abi.encodeWithSelector( + IHubBase.previewAddByAssets.selector, + params.collateralReserveAssetId, + collateralAmountToLiquidate + ), + 1 + ); + + uint256 collateralSharesToLiquidate = liquidationLogicWrapper.calculateCollateralToLiquidate( + params + ); + + assertEq(collateralSharesToLiquidate, expectedCollateralSharesToLiquidate); + } + + function test_calculateCollateralAmountToLiquidate() public { + // drawnIndex = 1.5, supply share price = 1.25 + // debt asset: weth, 18 decimals, price = 1000 + // collateral asset: usdx, 6 decimals, price = 0.98 + // liquidation bonus = 105% + // drawn shares to liquidate = 3 + // premium debt ray to liquidate = 0.4 + // total debt to liquidate = 3 * 1.5 + 0.4 = 4.9 + // debt to collateral = 4.9 * 1000 / 0.98 = 5000 + // collateral with bonus = 5000 * 105% = 5250 + // collateral shares to liquidate = 5250 / 1.25 = 4200 + _mockSupplySharePrice(hub1, usdxAssetId, 12_500.25e6, 10_000e6); + vm.expectCall( + address(hub1), + abi.encodeWithSelector(IHubBase.previewAddByAssets.selector, usdxAssetId, 5250e6), + 1 + ); + uint256 collateralSharesToLiquidate = liquidationLogicWrapper.calculateCollateralToLiquidate( + LiquidationLogic.CalculateCollateralToLiquidateParams({ + collateralReserveHub: hub1, + collateralReserveAssetId: usdxAssetId, + collateralAssetUnit: 10 ** tokenList.usdx.decimals(), + collateralAssetPrice: 0.98e8, + drawnSharesToLiquidate: 3e18, + premiumDebtRayToLiquidate: 0.4e18 * 1e27, + drawnIndex: 1.5e27, + debtAssetUnit: 10 ** tokenList.weth.decimals(), + debtAssetPrice: 1000e8, + liquidationBonus: 105_00 + }) + ); + assertEq(collateralSharesToLiquidate, 4200e6); + } + + function _calculateCollateralAmountToLiquidate( + LiquidationLogic.CalculateCollateralToLiquidateParams memory params + ) internal pure returns (uint256) { + uint256 debtRayToLiquidate = params.drawnSharesToLiquidate * params.drawnIndex + + params.premiumDebtRayToLiquidate; + + uint256 collateralToLiquidate = Math.mulDiv( + debtRayToLiquidate, + params.debtAssetPrice * params.collateralAssetUnit * params.liquidationBonus, + params.debtAssetUnit * + params.collateralAssetPrice * + PercentageMath.PERCENTAGE_FACTOR * + WadRayMath.RAY, + Math.Rounding.Floor + ); + + return collateralToLiquidate; + } + + function _calculateCollateralSharesToLiquidate( + LiquidationLogic.CalculateCollateralToLiquidateParams memory params, + uint256 collateralAmountToLiquidate + ) internal view returns (uint256) { + return + params.collateralReserveHub.previewAddByAssets( + params.collateralReserveAssetId, + collateralAmountToLiquidate + ); + } +} diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToLiquidate.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToLiquidate.t.sol index ab5266f74..9e1a5c6da 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToLiquidate.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToLiquidate.t.sol @@ -15,23 +15,56 @@ contract LiquidationLogicDebtToLiquidateTest is LiquidationLogicBaseTest { ) public { params = _bound(params); - uint256 debtToLiquidate = liquidationLogicWrapper.calculateDebtToLiquidate(params); - uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( + uint256 debtRayToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( _getDebtToTargetHealthFactorParams(params) ); - uint256 rawDebtToLiquidate = params.debtReserveBalance.min(params.debtToCover).min( - debtToTarget + uint256 rawPremiumDebtRayToLiquidate = debtRayToTarget.fromRayUp().toRay().min( + params.premiumDebtRay ); + if (params.debtToCover <= rawPremiumDebtRayToLiquidate / WadRayMath.RAY) { + rawPremiumDebtRayToLiquidate = params.debtToCover.toRay(); + } + + uint256 drawnSharesToTarget = (rawPremiumDebtRayToLiquidate == params.premiumDebtRay && + rawPremiumDebtRayToLiquidate < debtRayToTarget) + ? (debtRayToTarget - rawPremiumDebtRayToLiquidate).divUp(params.drawnIndex) + : 0; + uint256 drawnSharesToCover = Math.mulDiv( + params.debtToCover - rawPremiumDebtRayToLiquidate.fromRayUp(), + WadRayMath.RAY, + params.drawnIndex, + Math.Rounding.Floor + ); + uint256 rawDrawnSharesToLiquidate = drawnSharesToTarget.min(drawnSharesToCover).min( + params.drawnShares + ); + + uint256 assetsRequired = _calculateDebtAssetsToRestore({ + drawnSharesToLiquidate: rawDrawnSharesToLiquidate, + premiumDebtRayToLiquidate: rawPremiumDebtRayToLiquidate, + drawnIndex: params.drawnIndex + }); + assertLe(assetsRequired, params.debtToCover, 'assets required'); + + uint256 debtRayRemaining = (params.drawnShares - rawDrawnSharesToLiquidate) * + params.drawnIndex + + params.premiumDebtRay - + rawPremiumDebtRayToLiquidate; bool leavesDebtDust = _convertAmountToValue( - params.debtReserveBalance - rawDebtToLiquidate, + debtRayRemaining, params.debtAssetPrice, params.debtAssetUnit - ) < LiquidationLogic.DUST_LIQUIDATION_THRESHOLD; + ) < LiquidationLogic.DUST_LIQUIDATION_THRESHOLD.toRay(); + + (uint256 drawnSharesToLiquidate, uint256 premiumDebtRayToLiquidate) = liquidationLogicWrapper + .calculateDebtToLiquidate(params); if (leavesDebtDust) { - assertEq(debtToLiquidate, params.debtReserveBalance); + assertEq(drawnSharesToLiquidate, params.drawnShares); + assertEq(premiumDebtRayToLiquidate, params.premiumDebtRay); } else { - assertEq(debtToLiquidate, rawDebtToLiquidate); + assertEq(drawnSharesToLiquidate, rawDrawnSharesToLiquidate); + assertEq(premiumDebtRayToLiquidate, rawPremiumDebtRayToLiquidate); } } @@ -40,23 +73,44 @@ contract LiquidationLogicDebtToLiquidateTest is LiquidationLogicBaseTest { LiquidationLogic.CalculateDebtToLiquidateParams memory params ) public { params = _bound(params); - params.debtAssetUnit = 10 ** bound(params.debtAssetUnit, 1, 5); + params.debtAssetDecimals = bound(params.debtAssetDecimals, 1, 5); + params.debtAssetUnit = 10 ** params.debtAssetDecimals; params.debtAssetPrice = bound( params.debtAssetPrice, LiquidationLogic.DUST_LIQUIDATION_THRESHOLD.fromWadDown() * params.debtAssetUnit, MAX_ASSET_PRICE ); - uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( + uint256 debtRayToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( _getDebtToTargetHealthFactorParams(params) ); - params.debtReserveBalance = bound( - params.debtReserveBalance, - debtToTarget.min(params.debtToCover), - MAX_SUPPLY_AMOUNT + + uint256 rawPremiumDebtRayToLiquidate = debtRayToTarget.fromRayUp().toRay().min( + params.premiumDebtRay + ); + if (params.debtToCover <= rawPremiumDebtRayToLiquidate / WadRayMath.RAY) { + rawPremiumDebtRayToLiquidate = params.debtToCover.toRay(); + } + + uint256 drawnSharesToTarget = (rawPremiumDebtRayToLiquidate == params.premiumDebtRay && + rawPremiumDebtRayToLiquidate < debtRayToTarget) + ? (debtRayToTarget - rawPremiumDebtRayToLiquidate).divUp(params.drawnIndex) + : 0; + uint256 drawnSharesToCover = Math.mulDiv( + params.debtToCover - rawPremiumDebtRayToLiquidate.fromRayUp(), + WadRayMath.RAY, + params.drawnIndex, + Math.Rounding.Floor + ); + params.drawnShares = bound( + params.drawnShares, + drawnSharesToTarget.min(drawnSharesToCover), + MAX_SUPPLY_ASSET_UNITS * params.debtAssetUnit ); - uint256 debtToLiquidate = liquidationLogicWrapper.calculateDebtToLiquidate(params); - assertEq(debtToLiquidate, debtToTarget.min(params.debtToCover)); + (uint256 drawnSharesToLiquidate, uint256 premiumDebtRayToLiquidate) = liquidationLogicWrapper + .calculateDebtToLiquidate(params); + assertEq(drawnSharesToLiquidate, drawnSharesToTarget.min(drawnSharesToCover)); + assertEq(premiumDebtRayToLiquidate, rawPremiumDebtRayToLiquidate); } /// function returns total reserve debt if dust is left @@ -64,7 +118,9 @@ contract LiquidationLogicDebtToLiquidateTest is LiquidationLogicBaseTest { LiquidationLogic.CalculateDebtToLiquidateParams memory params ) public { params = _boundWithDustAdjustment(params); - uint256 debtToLiquidate = liquidationLogicWrapper.calculateDebtToLiquidate(params); - assertEq(debtToLiquidate, params.debtReserveBalance); + (uint256 drawnSharesToLiquidate, uint256 premiumDebtRayToLiquidate) = liquidationLogicWrapper + .calculateDebtToLiquidate(params); + assertEq(drawnSharesToLiquidate, params.drawnShares); + assertEq(premiumDebtRayToLiquidate, params.premiumDebtRay); } } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToTargetHealthFactor.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToTargetHealthFactor.t.sol index e621ac44f..53d63d028 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToTargetHealthFactor.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToTargetHealthFactor.t.sol @@ -57,7 +57,7 @@ contract LiquidationLogicDebtToTargetHealthFactorTest is LiquidationLogicBaseTes uint256 assetUnit = assetUnitList[i]; uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( LiquidationLogic.CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: 10_000e26, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, debtAssetPrice: 1e8, debtAssetUnit: assetUnit, collateralFactor: 50_00, @@ -69,7 +69,7 @@ contract LiquidationLogicDebtToTargetHealthFactorTest is LiquidationLogicBaseTes // liquidationPenalty = 1.5 * 0.5 = 0.75 // debtToTarget = $10000 * (1.25 - 0.8) / (1.25 - 0.75) / $1 = 9000 - assertEq(debtToTarget, 9000 * assetUnit); + assertEq(debtToTarget, 9000 * assetUnit * WadRayMath.RAY); } } @@ -78,7 +78,7 @@ contract LiquidationLogicDebtToTargetHealthFactorTest is LiquidationLogicBaseTes uint256 assetUnit = assetUnitList[i]; uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( LiquidationLogic.CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: 10_000e26, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, debtAssetUnit: assetUnit, debtAssetPrice: 2000e8, collateralFactor: 50_00, @@ -90,14 +90,14 @@ contract LiquidationLogicDebtToTargetHealthFactorTest is LiquidationLogicBaseTes // liquidationPenalty = 1.5 * 0.5 = 0.75 // debtToTarget = $10000 * (1 - 0.8) / (1 - 0.75) / $2000 = 4 - assertEq(debtToTarget, 4 * assetUnit); + assertEq(debtToTarget, 4 * assetUnit * WadRayMath.RAY); } } function test_calculateDebtToTargetHealthFactor_PrecisionLoss() public view { LiquidationLogic.CalculateDebtToTargetHealthFactorParams memory params = LiquidationLogic .CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: 10_000e26, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, debtAssetUnit: 1, debtAssetPrice: 333e8, collateralFactor: 50_00, @@ -106,14 +106,14 @@ contract LiquidationLogicDebtToTargetHealthFactorTest is LiquidationLogicBaseTes targetHealthFactor: 1e18 }); uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor(params); - assertEq(debtToTarget, 25); + assertEq(debtToTarget, 24.024024024024024024024024025e27); params.debtAssetUnit = 1e6; debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor(params); - assertEq(debtToTarget, 24.024025e6); + assertEq(debtToTarget, 24.024024024024024024024024024024025e33); params.debtAssetUnit = 1e18; debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor(params); - assertEq(debtToTarget, 24.024024024024024025e18); + assertEq(debtToTarget, 24.024024024024024024024024024024024024024024025e45); } } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.EvaluateDeficit.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.EvaluateDeficit.t.sol index f90c580a2..c540b10c6 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.EvaluateDeficit.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.EvaluateDeficit.t.sol @@ -15,7 +15,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRE(), activeCollateralCount: SCCO(), isDebtPositionEmpty: DRE(), - borrowedCount: BRCO() + borrowCount: BRCO() }); assertEq(hasDeficit, false); } @@ -26,7 +26,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRE(), activeCollateralCount: SCCO(), isDebtPositionEmpty: DRE(), - borrowedCount: BRCM() + borrowCount: BRCM() }); assertEq(hasDeficit, true); } @@ -37,7 +37,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRE(), activeCollateralCount: SCCO(), isDebtPositionEmpty: DRN(), - borrowedCount: BRCO() + borrowCount: BRCO() }); assertEq(hasDeficit, true); } @@ -48,7 +48,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRE(), activeCollateralCount: SCCO(), isDebtPositionEmpty: DRN(), - borrowedCount: BRCM() + borrowCount: BRCM() }); assertEq(hasDeficit, true); } @@ -59,7 +59,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRE(), activeCollateralCount: SCCM(), isDebtPositionEmpty: DRE(), - borrowedCount: BRCO() + borrowCount: BRCO() }); assertEq(hasDeficit, false); } @@ -70,7 +70,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRE(), activeCollateralCount: SCCM(), isDebtPositionEmpty: DRE(), - borrowedCount: BRCM() + borrowCount: BRCM() }); assertEq(hasDeficit, false); } @@ -81,7 +81,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRE(), activeCollateralCount: SCCM(), isDebtPositionEmpty: DRN(), - borrowedCount: BRCO() + borrowCount: BRCO() }); assertEq(hasDeficit, false); } @@ -92,7 +92,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRE(), activeCollateralCount: SCCM(), isDebtPositionEmpty: DRN(), - borrowedCount: BRCM() + borrowCount: BRCM() }); assertEq(hasDeficit, false); } @@ -103,7 +103,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRN(), activeCollateralCount: SCCO(), isDebtPositionEmpty: DRE(), - borrowedCount: BRCO() + borrowCount: BRCO() }); assertEq(hasDeficit, false); } @@ -114,7 +114,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRN(), activeCollateralCount: SCCO(), isDebtPositionEmpty: DRE(), - borrowedCount: BRCM() + borrowCount: BRCM() }); assertEq(hasDeficit, false); } @@ -125,7 +125,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRN(), activeCollateralCount: SCCO(), isDebtPositionEmpty: DRN(), - borrowedCount: BRCO() + borrowCount: BRCO() }); assertEq(hasDeficit, false); } @@ -136,7 +136,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRN(), activeCollateralCount: SCCO(), isDebtPositionEmpty: DRN(), - borrowedCount: BRCM() + borrowCount: BRCM() }); assertEq(hasDeficit, false); } @@ -147,7 +147,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRN(), activeCollateralCount: SCCM(), isDebtPositionEmpty: DRE(), - borrowedCount: BRCO() + borrowCount: BRCO() }); assertEq(hasDeficit, false); } @@ -158,7 +158,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRN(), activeCollateralCount: SCCM(), isDebtPositionEmpty: DRE(), - borrowedCount: BRCM() + borrowCount: BRCM() }); assertEq(hasDeficit, false); } @@ -169,7 +169,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRN(), activeCollateralCount: SCCM(), isDebtPositionEmpty: DRN(), - borrowedCount: BRCO() + borrowCount: BRCO() }); assertEq(hasDeficit, false); } @@ -180,7 +180,7 @@ contract LiquidationLogicEvaluateDeficitTest is LiquidationLogicBaseTest { isCollateralPositionEmpty: CRN(), activeCollateralCount: SCCM(), isDebtPositionEmpty: DRN(), - borrowedCount: BRCM() + borrowCount: BRCM() }); assertEq(hasDeficit, false); } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ExecuteLiquidation.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ExecuteLiquidation.t.sol new file mode 100644 index 000000000..e4a7424fc --- /dev/null +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ExecuteLiquidation.t.sol @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol'; + +contract LiquidationLogicExecuteLiquidationTest is LiquidationLogicBaseTest { + using SafeCast for *; + using WadRayMath for uint256; + using ReserveFlagsMap for ReserveFlags; + + uint256 usdxReserveId; + uint256 wethReserveId; + + LiquidationLogic.ExecuteLiquidationParams params; + + // drawn index is 1.05, supply share price is 1.25 + // variable liquidation bonus is max: 120% + // liquidation penalty: 1.2 * 0.5 = 0.6 + // debtToTarget = $10000 * (1 - 0.8) / (1 - 0.6) / $2000 = 2.5 + // max debt to liquidate = min(2.5, 4.4 * 1.05 + 0.4, 3) = 2.5 + // premiumDebtRayToLiquidate = 0.4 + // drawnSharesToLiquidate = (2.5 - 0.4) / 1.05 = 2 + // collateral to liquidate = 2.5 * 120% * $2000 / $1 = 6000 + // collateral shares to liquidate = 6000 / 1.25 = 4800 + // bonus collateral shares = 4800 - 4800 / 120% = 800 + // collateral fee shares = 800 * 10% = 80 + // collateral shares to liquidator = 4800 - 80 = 4720 + function setUp() public override { + super.setUp(); + IHub collateralReserveHub = hub1; + _mockSupplySharePrice(collateralReserveHub, usdxAssetId, 12_500.25e6, 10_000e6); + (IHub debtReserveHub, ) = hub2Fixture(); + _mockInterestRateBps(debtReserveHub.getAsset(wethAssetId).irStrategy, 5_00); + + // Mock params + usdxReserveId = _usdxReserveId(spoke1); + wethReserveId = _wethReserveId(spoke1); + params = LiquidationLogic.ExecuteLiquidationParams({ + collateralHub: collateralReserveHub, + collateralAssetId: usdxAssetId, + collateralAssetDecimals: 6, + collateralReserveId: usdxReserveId, + collateralReserveFlags: ReserveFlagsMap.create(false, false, false, true), + collateralDynConfig: ISpoke.DynamicReserveConfig({ + maxLiquidationBonus: 120_00, + collateralFactor: 50_00, + liquidationFee: 10_00 + }), + debtHub: debtReserveHub, + debtAssetId: wethAssetId, + debtAssetDecimals: 18, + debtUnderlying: address(tokenList.weth), + debtReserveId: wethReserveId, + debtReserveFlags: ReserveFlagsMap.create(false, false, false, false), + liquidationConfig: ISpoke.LiquidationConfig({ + targetHealthFactor: 1e18, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00 + }), + oracle: address(oracle1), + user: makeAddr('user'), + debtToCover: 3e18, + healthFactor: 0.8e18, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + activeCollateralCount: 1, + borrowCount: 1, + liquidator: makeAddr('liquidator'), + receiveShares: false + }); + + // Mock storage + liquidationLogicWrapper.setBorrower(params.user); + liquidationLogicWrapper.setLiquidator(params.liquidator); + liquidationLogicWrapper.setCollateralPositionSuppliedShares(10_000e6); + liquidationLogicWrapper.setDebtPositionDrawnShares(4.4e18); + liquidationLogicWrapper.setDebtPositionPremiumShares(1e18); + liquidationLogicWrapper.setDebtPositionPremiumOffsetRay((0.65e18 * WadRayMath.RAY).toInt256()); + liquidationLogicWrapper.setBorrowerCollateralStatus(usdxReserveId, true); + liquidationLogicWrapper.setBorrowerBorrowingStatus(wethReserveId, true); + + // Set liquidationLogicWrapper as a spoke + IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ + active: true, + halted: false, + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK + }); + vm.startPrank(HUB_ADMIN); + collateralReserveHub.addSpoke(usdxAssetId, address(liquidationLogicWrapper), spokeConfig); + debtReserveHub.addSpoke(wethAssetId, address(liquidationLogicWrapper), spokeConfig); + vm.stopPrank(); + + // Collateral hub: Add liquidity + address tempUser = makeUser(); + deal(address(tokenList.usdx), tempUser, MAX_SUPPLY_AMOUNT); + Utils.add(hub1, usdxAssetId, address(liquidationLogicWrapper), MAX_SUPPLY_AMOUNT, tempUser); + + // Debt hub: Add liquidity, remove liquidity, refresh premium and skip time to accrue both drawn and premium debt + deal(address(tokenList.weth), tempUser, MAX_SUPPLY_AMOUNT); + Utils.add( + debtReserveHub, + wethAssetId, + address(liquidationLogicWrapper), + MAX_SUPPLY_AMOUNT, + tempUser + ); + Utils.draw( + debtReserveHub, + wethAssetId, + address(liquidationLogicWrapper), + tempUser, + MAX_SUPPLY_AMOUNT + ); + vm.startPrank(address(liquidationLogicWrapper)); + debtReserveHub.refreshPremium( + wethAssetId, + _getExpectedPremiumDelta({ + hub: debtReserveHub, + assetId: wethAssetId, + oldPremiumShares: 0, + oldPremiumOffsetRay: 0, + drawnShares: 1e6 * 1e18, // risk premium is 100% + riskPremium: 100_00, + restoredPremiumRay: 0 + }) + ); + vm.stopPrank(); + skip(365 days); + (uint256 spokeDrawnOwed, uint256 spokePremiumOwed) = debtReserveHub.getSpokeOwed( + wethAssetId, + address(liquidationLogicWrapper) + ); + assertGt(spokeDrawnOwed, 10000e18); + assertGt(spokePremiumOwed, 10000e18); + + // Mint tokens to liquidator and approve spoke + deal(address(tokenList.weth), params.liquidator, spokeDrawnOwed + spokePremiumOwed); + Utils.approve( + ISpoke(address(liquidationLogicWrapper)), + address(tokenList.weth), + params.liquidator, + spokeDrawnOwed + spokePremiumOwed + ); + } + + function test_executeLiquidation() public { + uint256 initialCollateralReserveBalance = tokenList.usdx.balanceOf( + address(params.collateralHub) + ); + uint256 initialDebtReserveBalance = tokenList.weth.balanceOf(address(params.debtHub)); + uint256 initialLiquidatorWethBalance = tokenList.weth.balanceOf(address(params.liquidator)); + + ISpoke.UserPosition memory debtPosition = liquidationLogicWrapper.getDebtPosition(params.user); + + vm.expectCall( + address(params.collateralHub), + abi.encodeCall(IHubBase.previewRemoveByShares, (usdxAssetId, 4800e6)), + 1 + ); + + vm.expectCall( + address(params.collateralHub), + abi.encodeCall(IHubBase.previewRemoveByShares, (usdxAssetId, 4720e6)), + 1 + ); + + vm.expectCall( + address(params.collateralHub), + abi.encodeCall(IHubBase.remove, (usdxAssetId, 5900e6, params.liquidator)), + 1 + ); + + vm.expectCall( + address(params.collateralHub), + abi.encodeCall(IHubBase.payFeeShares, (usdxAssetId, 80e6)), + 1 + ); + + vm.expectCall( + address(params.debtHub), + abi.encodeCall( + IHubBase.restore, + ( + wethAssetId, + 2.1e18, + _getExpectedPremiumDelta({ + hub: IHub(address(params.debtHub)), + assetId: wethAssetId, + oldPremiumShares: debtPosition.premiumShares, + oldPremiumOffsetRay: debtPosition.premiumOffsetRay, + drawnShares: 0, + riskPremium: 0, + restoredPremiumRay: 0.4e18 * WadRayMath.RAY + }) + ) + ), + 1 + ); + + bool hasDeficit = liquidationLogicWrapper.executeLiquidation(params); + assertEq(hasDeficit, false); + + assertEq( + tokenList.usdx.balanceOf(address(params.collateralHub)), + initialCollateralReserveBalance - 5900e6 + ); + assertEq(tokenList.usdx.balanceOf(address(params.liquidator)), 5900e6); + assertApproxEqAbs( + params.collateralHub.getSpokeAddedShares(usdxAssetId, address(treasurySpoke)), + 80e6, + 1 + ); + + assertEq(tokenList.weth.balanceOf(address(params.debtHub)), initialDebtReserveBalance + 2.5e18); + assertEq( + tokenList.weth.balanceOf(address(params.liquidator)), + initialLiquidatorWethBalance - 2.5e18 + ); + } + + function test_executeLiquidation_revertsWith_InvalidDebtToCover() public { + params.debtToCover = 0; + vm.expectRevert(ISpoke.InvalidDebtToCover.selector); + liquidationLogicWrapper.executeLiquidation(params); + } + + function test_executeLiquidation_revertsWith_MustNotLeaveDust_Debt() public { + // debtToTarget doubles (from 2.5 to 5) + // debtToCover is 4.9, so 5.02 - 4.9 = 0.12 debt is left + params.totalDebtValueRay *= 2; + params.debtToCover = 4.9e18; + liquidationLogicWrapper.setCollateralPositionSuppliedShares( + liquidationLogicWrapper.getCollateralPosition(params.user).suppliedShares * 2 + ); + vm.expectRevert(ISpoke.MustNotLeaveDust.selector); + liquidationLogicWrapper.executeLiquidation(params); + } + + function test_executeLiquidation_revertsWith_MustNotLeaveDust_Collateral() public { + // collateral shares remaining is 5200 - 4800 = 400 + // this would leave collateral dust, hence collateral are increased + // new debt that needs to be liquidated is > 2.7, which is more than debtToCover (2.6) + liquidationLogicWrapper.setCollateralPositionSuppliedShares(5200e6); + params.debtToCover = 2.6e18; + vm.expectRevert(ISpoke.MustNotLeaveDust.selector); + liquidationLogicWrapper.executeLiquidation(params); + } +} diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateCollateral.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateCollateral.t.sol index e943710ef..9ad955e24 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateCollateral.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateCollateral.t.sol @@ -3,11 +3,13 @@ pragma solidity ^0.8.0; import 'tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol'; +import {HubBase} from 'tests/unit/Hub/HubBase.t.sol'; -contract LiquidationLogicLiquidateCollateralTest is LiquidationLogicBaseTest { +contract LiquidationLogicLiquidateCollateralTest is LiquidationLogicBaseTest, HubBase { using SafeCast for uint256; - LiquidationLogic.LiquidateCollateralParams params; + address borrower; + address liquidator; IHub hub; ISpoke spoke; @@ -15,16 +17,13 @@ contract LiquidationLogicLiquidateCollateralTest is LiquidationLogicBaseTest { uint256 assetId; uint256 userSuppliedShares; uint256 reserveId; - address borrower; - address liquidator; - ISpoke.Reserve initialReserve; ISpoke.UserPosition initialUserPosition; ISpoke.UserPosition initialLiquidatorPosition; IHub.SpokeData initialTreasurySpokeData; - function setUp() public override { - super.setUp(); + function setUp() public override(HubBase, LiquidationLogicBaseTest) { + LiquidationLogicBaseTest.setUp(); hub = hub1; spoke = ISpoke(address(liquidationLogicWrapper)); @@ -35,21 +34,17 @@ contract LiquidationLogicLiquidateCollateralTest is LiquidationLogicBaseTest { borrower = makeAddr('borrower'); liquidator = makeAddr('liquidator'); - liquidationLogicWrapper.setCollateralReserveHub(hub); - liquidationLogicWrapper.setCollateralReserveAssetId(assetId); - liquidationLogicWrapper.setCollateralReserveId(reserveId); liquidationLogicWrapper.setBorrower(borrower); - liquidationLogicWrapper.setCollateralPositionSuppliedShares(userSuppliedShares); liquidationLogicWrapper.setLiquidator(liquidator); + liquidationLogicWrapper.setCollateralPositionSuppliedShares(userSuppliedShares); - initialReserve = liquidationLogicWrapper.getCollateralReserve(); initialUserPosition = liquidationLogicWrapper.getCollateralPosition(borrower); initialLiquidatorPosition = liquidationLogicWrapper.getCollateralPosition(liquidator); initialTreasurySpokeData = hub.getSpoke(assetId, address(treasurySpoke)); IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK @@ -58,164 +53,103 @@ contract LiquidationLogicLiquidateCollateralTest is LiquidationLogicBaseTest { vm.prank(HUB_ADMIN); hub.addSpoke(assetId, address(spoke), spokeConfig); - address tempUser = makeUser(); - deal(address(asset), tempUser, MAX_SUPPLY_AMOUNT); - Utils.add(hub, assetId, address(spoke), MAX_SUPPLY_AMOUNT, tempUser); + // add and drawn liquidity to increase supply share price of assetId + deal(address(asset), alice, MAX_SUPPLY_AMOUNT * 2); + _addAndDrawLiquidity({ + hub: hub, + assetId: assetId, + addUser: alice, + addSpoke: address(spoke), + addAmount: userSuppliedShares * 3, + drawUser: alice, + drawSpoke: address(spoke), + drawAmount: userSuppliedShares, + skipTime: 365 days + }); } function test_liquidateCollateral_fuzz( - uint256 collateralToLiquidate, - uint256 collateralToLiquidator + uint256 sharesToLiquidate, + uint256 sharesToLiquidator, + bool receiveShares ) public { - params = LiquidationLogic.LiquidateCollateralParams({ - collateralToLiquidate: bound( - collateralToLiquidate, - 1, - hub.previewRemoveByShares(assetId, userSuppliedShares) - ), - collateralToLiquidator: 0, // populated below - liquidator: liquidator, - receiveShares: false - }); - params.collateralToLiquidator = bound(collateralToLiquidator, 1, params.collateralToLiquidate); + LiquidationLogic.LiquidateCollateralParams memory params = LiquidationLogic + .LiquidateCollateralParams({ + hub: hub, + assetId: assetId, + sharesToLiquidate: bound(sharesToLiquidate, 0, userSuppliedShares), + sharesToLiquidator: 0, // populated below + liquidator: liquidator, + receiveShares: receiveShares + }); + params.sharesToLiquidator = bound(sharesToLiquidator, 0, params.sharesToLiquidate); uint256 initialHubBalance = asset.balanceOf(address(hub)); + uint256 expectedAmountToLiquidator; + if (!params.receiveShares) { + expectedAmountToLiquidator = hub.previewRemoveByShares(assetId, params.sharesToLiquidator); + } + uint256 expectedAmountRemoved = hub.previewRemoveByShares(assetId, params.sharesToLiquidate); - uint256 sharesToLiquidate = _expectEventsAndCalls(params); - (, , bool isPositionEmpty) = liquidationLogicWrapper.liquidateCollateral(params); - - assertEq(liquidationLogicWrapper.getCollateralReserve(), initialReserve); - assertPosition( - liquidationLogicWrapper.getCollateralPosition(borrower), - initialUserPosition, - userSuppliedShares - sharesToLiquidate - ); + _expectCalls(params); + LiquidationLogic.LiquidateCollateralResult memory result = liquidationLogicWrapper + .liquidateCollateral(params); - assertEq(isPositionEmpty, userSuppliedShares == sharesToLiquidate); - assertEq(asset.balanceOf(address(hub)), initialHubBalance - params.collateralToLiquidator); - assertEq(asset.balanceOf(params.liquidator), params.collateralToLiquidator); - assertApproxEqAbs( - hub.getSpokeAddedShares(assetId, address(treasurySpoke)), - params.collateralToLiquidate - params.collateralToLiquidator, - 1 + assertEq(result.amountRemoved, expectedAmountRemoved, 'amountRemoved'); + assertEq( + result.isCollateralPositionEmpty, + userSuppliedShares == params.sharesToLiquidate, + 'isCollateralPositionEmpty' ); - } - - /// on receiveShares, sharesToLiquidator should round down - function test_liquidateCollateral_receiveShares_sharesToLiquidatorIsZero() public { - // increase reserve index to ensure sharesToLiquidator rounds to 0 while feeShares rounds up to 1 - _increaseReserveIndex(spoke1, reserveId); - // supply ex rate is between 1 and 2 - assertGt(hub.previewAddByShares(assetId, WadRayMath.RAY), WadRayMath.RAY); - assertLt(hub.previewAddByShares(assetId, WadRayMath.RAY), 2 * WadRayMath.RAY); - - params = LiquidationLogic.LiquidateCollateralParams({ - collateralToLiquidate: 1, - collateralToLiquidator: 1, - liquidator: liquidator, - receiveShares: true - }); - - uint256 sharesToLiquidate = hub.previewRemoveByAssets(assetId, params.collateralToLiquidate); - uint256 sharesToLiquidator = hub.previewAddByAssets(assetId, params.collateralToLiquidator); - uint256 feeShares = sharesToLiquidate - sharesToLiquidator; - - assertEq(sharesToLiquidate, 1); - assertEq(sharesToLiquidator, 0); - assertEq(feeShares, 1); - - _expectEventsAndCalls(params); - liquidationLogicWrapper.liquidateCollateral(params); - - // sharesToLiquidator should round to 0 and remain unchanged - assertPosition( - liquidationLogicWrapper.getCollateralPosition(params.liquidator), - initialLiquidatorPosition, - sharesToLiquidator - ); assertPosition( liquidationLogicWrapper.getCollateralPosition(borrower), initialUserPosition, - userSuppliedShares - sharesToLiquidate + userSuppliedShares - params.sharesToLiquidate ); - assertSpokePosition( - hub.getSpoke(assetId, address(treasurySpoke)), - initialTreasurySpokeData, - initialTreasurySpokeData.addedShares + (sharesToLiquidate - sharesToLiquidator).toUint120() - ); - } - - // on receiveShares, sharesToLiquidator should round down - function test_liquidateCollateral_fuzz_receiveShares_sharesToLiquidator( - uint256 collateralToLiquidate, - uint256 collateralToLiquidator - ) public { - params = LiquidationLogic.LiquidateCollateralParams({ - collateralToLiquidate: bound( - collateralToLiquidate, - 1, - hub.previewRemoveByShares(assetId, 1e6) - ), - collateralToLiquidator: 0, // populated below - liquidator: liquidator, - receiveShares: true - }); - params.collateralToLiquidator = bound(collateralToLiquidator, 1, params.collateralToLiquidate); - - // increase reserve index to ensure sharesToLiquidator rounds to 0 while feeShares rounds up to 1 - _increaseReserveIndex(spoke1, reserveId); - uint256 sharesToLiquidate = hub.previewRemoveByAssets(assetId, params.collateralToLiquidate); - uint256 sharesToLiquidator = hub.previewAddByAssets(assetId, params.collateralToLiquidator); - - _expectEventsAndCalls(params); - liquidationLogicWrapper.liquidateCollateral(params); - - // sharesToLiquidator should round to 0 and remain unchanged + assertEq(asset.balanceOf(params.liquidator), expectedAmountToLiquidator); assertPosition( liquidationLogicWrapper.getCollateralPosition(params.liquidator), initialLiquidatorPosition, - sharesToLiquidator + initialLiquidatorPosition.suppliedShares + + (params.receiveShares ? params.sharesToLiquidator : 0) ); - assertPosition( - liquidationLogicWrapper.getCollateralPosition(borrower), - initialUserPosition, - userSuppliedShares - sharesToLiquidate - ); - assertSpokePosition( - hub.getSpoke(assetId, address(treasurySpoke)), - initialTreasurySpokeData, - initialTreasurySpokeData.addedShares + (sharesToLiquidate - sharesToLiquidator).toUint120() + + assertEq(asset.balanceOf(address(hub)), initialHubBalance - expectedAmountToLiquidator); + assertEq( + hub.getSpokeAddedShares(assetId, address(treasurySpoke)), + params.sharesToLiquidate - params.sharesToLiquidator ); } - // hub.remove is skipped when collateralToLiquidator is 0 (otherwise it would revert) - function test_liquidateCollateral_fuzz_CollateralToLiquidatorIsZero( - uint256 collateralToLiquidate - ) public { - params.collateralToLiquidate = bound( - collateralToLiquidate, - 0, - hub.previewRemoveByShares(assetId, userSuppliedShares) - ); - params.collateralToLiquidator = 0; + // reverts with arithmetic underflow when updating user's supplied shares + function test_liquidateCollateral_revertsWith_ArithmeticUnderflow() public { + LiquidationLogic.LiquidateCollateralParams memory params = LiquidationLogic + .LiquidateCollateralParams({ + hub: hub, + assetId: assetId, + sharesToLiquidate: userSuppliedShares + 1, + sharesToLiquidator: userSuppliedShares + 1, + liquidator: liquidator, + receiveShares: false + }); - vm.expectCall(address(hub), abi.encodeWithSelector(IHubBase.remove.selector), 0); + vm.expectRevert(stdError.arithmeticError); liquidationLogicWrapper.liquidateCollateral(params); } - // reverts with arithmetic underflow when updating user's supplied shares - function test_liquidateCollateral_fuzz_revertsWith_ArithmeticUnderflow( - uint256 collateralToLiquidate, - uint256 collateralToLiquidator - ) public { - params.collateralToLiquidate = bound( - collateralToLiquidate, - hub.previewRemoveByShares(assetId, userSuppliedShares) + 1, - MAX_SUPPLY_AMOUNT - ); - params.collateralToLiquidator = bound(collateralToLiquidator, 1, params.collateralToLiquidate); + // reverts with arithmetic underflow when computing fee shares + function test_liquidateCollateral_revertsWith_ArithmeticUnderflow_FeeShares() public { + LiquidationLogic.LiquidateCollateralParams memory params = LiquidationLogic + .LiquidateCollateralParams({ + hub: hub, + assetId: assetId, + sharesToLiquidate: userSuppliedShares, + sharesToLiquidator: userSuppliedShares + 1, + liquidator: liquidator, + receiveShares: false + }); vm.expectRevert(stdError.arithmeticError); liquidationLogicWrapper.liquidateCollateral(params); @@ -230,51 +164,35 @@ contract LiquidationLogicLiquidateCollateralTest is LiquidationLogicBaseTest { assertEq(newPosition, initPosition); } - function assertSpokePosition( - IHub.SpokeData memory newSpokeData, - IHub.SpokeData memory initSpokeData, - uint256 newAddedShares - ) internal pure { - initSpokeData.addedShares = newAddedShares.toUint120(); - assertEq(newSpokeData, initSpokeData); - } + function _expectCalls(LiquidationLogic.LiquidateCollateralParams memory p) internal { + uint256 collateralToLiquidator = hub.previewRemoveByShares(assetId, p.sharesToLiquidator); - function _expectEventsAndCalls( - LiquidationLogic.LiquidateCollateralParams memory p - ) internal returns (uint256) { - uint256 sharesToLiquidate = hub.previewRemoveByAssets(assetId, p.collateralToLiquidate); - uint256 sharesToLiquidator = p.receiveShares - ? hub.previewAddByAssets(assetId, p.collateralToLiquidator) - : hub.previewRemoveByAssets(assetId, p.collateralToLiquidator); - uint256 sharesToPayFee = sharesToLiquidate - sharesToLiquidator; - - if (p.collateralToLiquidator > 0 && p.receiveShares) { - vm.expectCall( - address(hub), - abi.encodeCall(IHubBase.previewAddByAssets, (assetId, p.collateralToLiquidator)), - 1 - ); - } - if (p.collateralToLiquidator > 0 && !p.receiveShares) { - vm.expectCall( - address(hub), - abi.encodeCall(IHubBase.remove, (assetId, p.collateralToLiquidator, p.liquidator)), - 1 - ); - } vm.expectCall( address(hub), - abi.encodeCall(IHubBase.previewRemoveByAssets, (assetId, p.collateralToLiquidate)), + abi.encodeCall(IHubBase.previewRemoveByShares, (assetId, p.sharesToLiquidate)), 1 ); - if (sharesToPayFee > 0) { + + if (p.sharesToLiquidator != p.sharesToLiquidate) { + // otherwise already checked above vm.expectCall( address(hub), - abi.encodeCall(IHubBase.payFeeShares, (assetId, sharesToPayFee)), - 1 + abi.encodeCall(IHubBase.previewRemoveByShares, (assetId, p.sharesToLiquidator)), + (p.sharesToLiquidator > 0 && !p.receiveShares) ? 1 : 0 ); } - return sharesToLiquidate; + vm.expectCall( + address(hub), + abi.encodeCall(IHubBase.remove, (assetId, collateralToLiquidator, p.liquidator)), + (p.sharesToLiquidator > 0 && !p.receiveShares) ? 1 : 0 + ); + + uint256 sharesToPayFee = p.sharesToLiquidate - p.sharesToLiquidator; + vm.expectCall( + address(hub), + abi.encodeCall(IHubBase.payFeeShares, (assetId, sharesToPayFee)), + sharesToPayFee > 0 ? 1 : 0 + ); } } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateDebt.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateDebt.t.sol index da8823437..15cb5549b 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateDebt.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateDebt.t.sol @@ -8,8 +8,6 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { using SafeCast for *; using WadRayMath for uint256; - LiquidationLogic.LiquidateDebtParams params; - IHub internal hub; ISpoke internal spoke; IERC20 internal asset; @@ -34,16 +32,12 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { // Set initial storage values liquidationLogicWrapper.setBorrower(user); liquidationLogicWrapper.setLiquidator(liquidator); - liquidationLogicWrapper.setDebtReserveId(reserveId); - liquidationLogicWrapper.setDebtReserveHub(hub); - liquidationLogicWrapper.setDebtReserveAssetId(assetId); - liquidationLogicWrapper.setDebtReserveUnderlying(address(asset)); liquidationLogicWrapper.setBorrowerBorrowingStatus(reserveId, true); // Add liquidation logic wrapper as a spoke IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ active: true, - paused: false, + halted: false, addCap: Constants.MAX_ALLOWED_SPOKE_CAP, drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK @@ -81,8 +75,9 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { } function test_liquidateDebt_fuzz(uint256) public { - (uint256 spokeDrawnOwed, ) = hub.getSpokeOwed(assetId, address(spoke)); IHub.SpokeData memory spokeData = hub.getSpoke(assetId, address(spoke)); + uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); + uint256 spokePremiumOwedRay = _calculatePremiumDebtRay( hub, assetId, @@ -90,67 +85,94 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { spokeData.premiumOffsetRay ); - uint256 drawnDebt = vm.randomUint(0, spokeDrawnOwed); + uint256 drawnShares = vm.randomUint(1, spokeData.drawnShares); uint256 premiumDebtRay = vm.randomUint(0, spokePremiumOwedRay); - vm.assume(drawnDebt * WadRayMath.RAY + premiumDebtRay > 0); - - uint256 debtToLiquidate = vm.randomUint(1, drawnDebt + premiumDebtRay.fromRayUp()); - (uint256 drawnToLiquidate, uint256 premiumToLiquidateRay) = _calculateLiquidationAmounts( - premiumDebtRay, - debtToLiquidate - ); - - ISpoke.UserPosition memory initialPosition = _updateStorage(drawnDebt, premiumDebtRay); + ISpoke.UserPosition memory initialPosition = _updateStorage(drawnShares, premiumDebtRay); + + uint256 drawnSharesToLiquidate; + uint256 premiumDebtRayToLiquidate; + bool liquidatePremiumOnly = vm.randomBool(); + if (liquidatePremiumOnly) { + premiumDebtRayToLiquidate = vm.randomUint(1, premiumDebtRay); + } else { + premiumDebtRayToLiquidate = premiumDebtRay; + drawnSharesToLiquidate = vm.randomUint(1, drawnShares); + } uint256 initialHubBalance = asset.balanceOf(address(hub)); uint256 initialLiquidatorBalance = asset.balanceOf(liquidator); - expectCall( - initialPosition.premiumShares, - initialPosition.premiumOffsetRay, - drawnToLiquidate, - premiumToLiquidateRay - ); + expectCall({ + drawnIndex: drawnIndex, + premiumShares: initialPosition.premiumShares, + premiumOffsetRay: initialPosition.premiumOffsetRay, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate + }); - (uint256 drawnSharesLiquidated, , bool isPositionEmpty) = liquidationLogicWrapper.liquidateDebt( - LiquidationLogic.LiquidateDebtParams({ - debtReserveId: reserveId, - debtToLiquidate: debtToLiquidate, - premiumDebtRay: premiumDebtRay, - drawnIndex: hub.getAssetDrawnIndex(assetId), - liquidator: liquidator - }) + LiquidationLogic.LiquidateDebtResult memory liquidateDebtResult = liquidationLogicWrapper + .liquidateDebt( + LiquidationLogic.LiquidateDebtParams({ + hub: hub, + assetId: assetId, + underlying: address(asset), + reserveId: reserveId, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate, + drawnIndex: drawnIndex, + liquidator: liquidator + }) + ); + + uint256 amountRestored = drawnSharesToLiquidate.rayMulUp(drawnIndex) + + premiumDebtRayToLiquidate.fromRayUp(); + assertEq(liquidateDebtResult.amountRestored, amountRestored); + assertEq(liquidateDebtResult.isDebtPositionEmpty, drawnShares == drawnSharesToLiquidate); + assertEq( + liquidationLogicWrapper.getBorrowerBorrowingStatus(reserveId), + !liquidateDebtResult.isDebtPositionEmpty ); - - assertEq(drawnSharesLiquidated, hub.previewRestoreByAssets(assetId, drawnToLiquidate)); - assertEq(isPositionEmpty, debtToLiquidate == drawnDebt + premiumDebtRay.fromRayUp()); - assertEq(liquidationLogicWrapper.getBorrowerBorrowingStatus(reserveId), !isPositionEmpty); assertPosition( liquidationLogicWrapper.getDebtPosition(user), initialPosition, - drawnSharesLiquidated, - premiumToLiquidateRay + drawnSharesToLiquidate, + premiumDebtRayToLiquidate ); - assertEq(asset.balanceOf(address(hub)), initialHubBalance + debtToLiquidate); - assertEq(asset.balanceOf(liquidator), initialLiquidatorBalance - debtToLiquidate); + assertEq(asset.balanceOf(address(hub)), initialHubBalance + amountRestored); + assertEq(asset.balanceOf(liquidator), initialLiquidatorBalance - amountRestored); } // reverts with arithmetic underflow if more debt is liquidated than the position has function test_liquidateDebt_revertsWith_ArithmeticUnderflow() public { - uint256 drawnDebt = 100e18; + uint256 drawnShares = 100e18; uint256 premiumDebtRay = 10e18 * WadRayMath.RAY; - _updateStorage(drawnDebt, premiumDebtRay); - - uint256 debtToLiquidate = drawnDebt + premiumDebtRay.fromRayUp() + 1; + _updateStorage(drawnShares, premiumDebtRay); uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); vm.expectRevert(stdError.arithmeticError); liquidationLogicWrapper.liquidateDebt( LiquidationLogic.LiquidateDebtParams({ - debtReserveId: reserveId, - debtToLiquidate: debtToLiquidate, - premiumDebtRay: premiumDebtRay, + hub: hub, + assetId: assetId, + underlying: address(asset), + reserveId: reserveId, + drawnSharesToLiquidate: 0, + premiumDebtRayToLiquidate: premiumDebtRay + 1, + drawnIndex: drawnIndex, + liquidator: liquidator + }) + ); + + vm.expectRevert(stdError.arithmeticError); + liquidationLogicWrapper.liquidateDebt( + LiquidationLogic.LiquidateDebtParams({ + hub: hub, + assetId: assetId, + underlying: address(asset), + reserveId: reserveId, + drawnSharesToLiquidate: drawnShares + 1, + premiumDebtRayToLiquidate: premiumDebtRay, drawnIndex: drawnIndex, liquidator: liquidator }) @@ -159,22 +181,24 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { // reverts when spoke does not have enough allowance from liquidator function test_liquidateDebt_revertsWith_InsufficientAllowance() public { - uint256 drawnDebt = 100e18; + uint256 drawnShares = 100e18; uint256 premiumDebtRay = 10e18 * WadRayMath.RAY; - _updateStorage(drawnDebt, premiumDebtRay); - - uint256 debtToLiquidateRay = drawnDebt * WadRayMath.RAY + premiumDebtRay; - uint256 debtToLiquidate = debtToLiquidateRay.fromRayUp(); - Utils.approve(spoke, address(asset), liquidator, debtToLiquidate - 1); + _updateStorage(drawnShares, premiumDebtRay); uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); + uint256 amountToRestore = drawnShares.rayMulUp(drawnIndex) + premiumDebtRay.fromRayUp(); + Utils.approve(spoke, address(asset), liquidator, amountToRestore - 1); + vm.expectRevert(); liquidationLogicWrapper.liquidateDebt( LiquidationLogic.LiquidateDebtParams({ - debtReserveId: reserveId, - debtToLiquidate: debtToLiquidate, - premiumDebtRay: premiumDebtRay, + hub: hub, + assetId: assetId, + underlying: address(asset), + reserveId: reserveId, + drawnSharesToLiquidate: drawnShares, + premiumDebtRayToLiquidate: premiumDebtRay, drawnIndex: drawnIndex, liquidator: liquidator }) @@ -183,22 +207,24 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { // reverts when liquidator does not have enough balance function test_liquidateDebt_revertsWith_InsufficientBalance() public { - uint256 drawnDebt = 100e18; + uint256 drawnShares = 100e18; uint256 premiumDebtRay = 10e18 * WadRayMath.RAY; - _updateStorage(drawnDebt, premiumDebtRay); - - uint256 debtToLiquidateRay = drawnDebt * WadRayMath.RAY + premiumDebtRay; - uint256 debtToLiquidate = debtToLiquidateRay.fromRayUp(); - deal(address(asset), liquidator, debtToLiquidate - 1); + _updateStorage(drawnShares, premiumDebtRay); uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); + uint256 amountToRestore = drawnShares.rayMulUp(drawnIndex) + premiumDebtRay.fromRayUp(); + deal(address(asset), liquidator, amountToRestore - 1); + vm.expectRevert(); liquidationLogicWrapper.liquidateDebt( LiquidationLogic.LiquidateDebtParams({ - debtReserveId: reserveId, - debtToLiquidate: debtToLiquidate, - premiumDebtRay: premiumDebtRay, + hub: hub, + assetId: assetId, + underlying: address(asset), + reserveId: reserveId, + drawnSharesToLiquidate: drawnShares, + premiumDebtRayToLiquidate: premiumDebtRay, drawnIndex: drawnIndex, liquidator: liquidator }) @@ -206,10 +232,11 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { } function expectCall( + uint256 drawnIndex, uint256 premiumShares, int256 premiumOffsetRay, - uint256 drawnToLiquidate, - uint256 premiumToLiquidateRay + uint256 drawnSharesToLiquidate, + uint256 premiumDebtRayToLiquidate ) internal { IHubBase.PremiumDelta memory premiumDelta = _getExpectedPremiumDelta({ hub: hub, @@ -218,26 +245,27 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { oldPremiumOffsetRay: premiumOffsetRay, drawnShares: 0, riskPremium: 0, - restoredPremiumRay: premiumToLiquidateRay + restoredPremiumRay: premiumDebtRayToLiquidate }); + vm.expectCall( address(hub), - abi.encodeCall(IHubBase.restore, (assetId, drawnToLiquidate, premiumDelta)) + abi.encodeCall( + IHubBase.restore, + (assetId, drawnSharesToLiquidate.rayMulUp(drawnIndex), premiumDelta) + ) ); } function _updateStorage( - uint256 drawnDebt, + uint256 drawnShares, uint256 premiumDebtRay ) internal returns (ISpoke.UserPosition memory) { - liquidationLogicWrapper.setDebtPositionDrawnShares( - hub.previewRestoreByAssets(assetId, drawnDebt) - ); - uint256 premiumDebtShares = hub.previewDrawByAssets(assetId, premiumDebtRay.fromRayUp()); - liquidationLogicWrapper.setDebtPositionPremiumShares(premiumDebtShares); + liquidationLogicWrapper.setDebtPositionDrawnShares(drawnShares); + uint256 premiumShares = hub.previewDrawByAssets(assetId, premiumDebtRay.fromRayUp()); + liquidationLogicWrapper.setDebtPositionPremiumShares(premiumShares); liquidationLogicWrapper.setDebtPositionPremiumOffsetRay( - _calculatePremiumAssetsRay(hub, assetId, premiumDebtShares).toInt256() - - premiumDebtRay.toInt256() + _calculatePremiumAssetsRay(hub, assetId, premiumShares).toInt256() - premiumDebtRay.toInt256() ); return liquidationLogicWrapper.getDebtPosition(user); @@ -247,7 +275,7 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { ISpoke.UserPosition memory newPosition, ISpoke.UserPosition memory initialPosition, uint256 drawnSharesLiquidated, - uint256 premiumToLiquidateRay + uint256 premiumDebtRayLiquidated ) internal view { uint256 premiumDebtRay = _calculatePremiumDebtRay( hub, @@ -257,19 +285,9 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { ); initialPosition.drawnShares -= drawnSharesLiquidated.toUint120(); initialPosition.premiumShares = 0; - initialPosition.premiumOffsetRay = -(premiumDebtRay - premiumToLiquidateRay) + initialPosition.premiumOffsetRay = -(premiumDebtRay - premiumDebtRayLiquidated) .toInt256() .toInt200(); assertEq(newPosition, initialPosition); } - - function _calculateLiquidationAmounts( - uint256 premiumDebtRay, - uint256 debtToLiquidate - ) internal pure returns (uint256, uint256) { - uint256 debtToLiquidateRay = debtToLiquidate.toRay(); - uint256 premiumToLiquidateRay = _min(premiumDebtRay, debtToLiquidateRay); - uint256 drawnToLiquidate = debtToLiquidate - premiumToLiquidateRay.fromRayUp(); - return (drawnToLiquidate, premiumToLiquidateRay); - } } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateUser.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateUser.t.sol index ac2ee59a3..5f2aa9b17 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateUser.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateUser.t.sol @@ -7,30 +7,32 @@ import 'tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol'; contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { using SafeCast for *; using WadRayMath for uint256; - - IHub hub2; + using ReserveFlagsMap for ReserveFlags; uint256 usdxReserveId; uint256 wethReserveId; - - ISpoke.LiquidationConfig liquidationConfig; - ISpoke.DynamicReserveConfig dynamicCollateralConfig; + IHub collateralReserveHub; + IHub debtReserveHub; LiquidationLogic.LiquidateUserParams params; - // drawn index is 1.05 + // drawn index is 1.05, supply share price is 1.25 // variable liquidation bonus is max: 120% // liquidation penalty: 1.2 * 0.5 = 0.6 // debtToTarget = $10000 * (1 - 0.8) / (1 - 0.6) / $2000 = 2.5 - // max debt to liquidate = min(2.5, 5, 3) = 2.5 + // max debt to liquidate = min(2.5, 4.4 * 1.05 + 0.4, 3) = 2.5 + // premiumDebtRayToLiquidate = 0.4 + // drawnSharesToLiquidate = (2.5 - 0.4) / 1.05 = 2 // collateral to liquidate = 2.5 * 120% * $2000 / $1 = 6000 - // bonus collateral = 6000 - 6000 / 120% = 1000 - // collateral fee = 1000 * 10% = 100 - // collateral to liquidator = 6000 - 100 = 5900 + // collateral shares to liquidate = 6000 / 1.25 = 4800 + // bonus collateral shares = 4800 - 4800 / 120% = 800 + // collateral fee shares = 800 * 10% = 80 + // collateral shares to liquidator = 4800 - 80 = 4720 function setUp() public override { super.setUp(); - (hub2, ) = hub2Fixture(); - - _mockInterestRateBps(hub2.getAsset(wethAssetId).irStrategy, 5_00); + collateralReserveHub = hub1; + _mockSupplySharePrice(collateralReserveHub, usdxAssetId, 12_500.25e6, 10_000e6); + (debtReserveHub, ) = hub2Fixture(); + _mockInterestRateBps(debtReserveHub.getAsset(wethAssetId).irStrategy, 5_00); // Mock params usdxReserveId = _usdxReserveId(spoke1); @@ -40,69 +42,67 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { debtReserveId: wethReserveId, oracle: address(oracle1), user: makeAddr('user'), + liquidationConfig: ISpoke.LiquidationConfig({ + targetHealthFactor: 1e18, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00 + }), debtToCover: 3e18, - healthFactor: 0.8e18, - drawnDebt: 4.5e18, - premiumDebtRay: 0.5e18 * WadRayMath.RAY, - drawnIndex: 1.05e27, - totalDebtValue: 10_000e26, + userAccountData: ISpoke.UserAccountData({ + healthFactor: 0.8e18, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + activeCollateralCount: 1, + borrowCount: 1, + totalCollateralValue: 0, // not used + riskPremium: 0, // not used + avgCollateralFactor: 0 // not used + }), liquidator: makeAddr('liquidator'), - activeCollateralCount: 1, - borrowedCount: 1, receiveShares: false }); - // Set liquidationLogicWrapper as a spoke - IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ - active: true, - paused: false, - addCap: Constants.MAX_ALLOWED_SPOKE_CAP, - drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, - riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK - }); - vm.startPrank(HUB_ADMIN); - hub1.addSpoke(usdxAssetId, address(liquidationLogicWrapper), spokeConfig); - hub2.addSpoke(wethAssetId, address(liquidationLogicWrapper), spokeConfig); - vm.stopPrank(); - - // set borrower + // Mock storage liquidationLogicWrapper.setBorrower(params.user); liquidationLogicWrapper.setLiquidator(params.liquidator); - - // Mock storage for collateral side - require(hub1.getAsset(usdxAssetId).underlying == address(tokenList.usdx)); liquidationLogicWrapper.setCollateralReserveId(usdxReserveId); - liquidationLogicWrapper.setCollateralLiquidatable(true); - liquidationLogicWrapper.setCollateralReserveHub(hub1); - liquidationLogicWrapper.setCollateralReserveAssetId(usdxAssetId); + liquidationLogicWrapper.setCollateralReserveHub(collateralReserveHub); liquidationLogicWrapper.setCollateralReserveDecimals(6); - liquidationLogicWrapper.setCollateralPositionSuppliedShares(10_200e6); - liquidationLogicWrapper.setBorrowerCollateralStatus(usdxReserveId, true); - - // Mock storage for debt side - require(hub2.getAsset(wethAssetId).underlying == address(tokenList.weth)); + liquidationLogicWrapper.setCollateralReserveAssetId(usdxAssetId); + liquidationLogicWrapper.setCollateralReserveFlags( + ReserveFlagsMap.create(false, false, false, true) + ); + liquidationLogicWrapper.setDynamicCollateralConfig( + ISpoke.DynamicReserveConfig({ + maxLiquidationBonus: 120_00, + collateralFactor: 50_00, + liquidationFee: 10_00 + }) + ); + liquidationLogicWrapper.setCollateralPositionSuppliedShares(10_000e6); liquidationLogicWrapper.setDebtReserveId(wethReserveId); - liquidationLogicWrapper.setDebtReserveHub(hub2); + liquidationLogicWrapper.setDebtReserveHub(debtReserveHub); + liquidationLogicWrapper.setDebtReserveDecimals(18); liquidationLogicWrapper.setDebtReserveAssetId(wethAssetId); liquidationLogicWrapper.setDebtReserveUnderlying(address(tokenList.weth)); - liquidationLogicWrapper.setDebtReserveDecimals(18); + liquidationLogicWrapper.setDebtReserveFlags(ReserveFlagsMap.create(false, false, false, false)); + liquidationLogicWrapper.setDebtPositionDrawnShares(4.4e18); + liquidationLogicWrapper.setDebtPositionPremiumShares(1e18); + liquidationLogicWrapper.setDebtPositionPremiumOffsetRay((0.65e18 * WadRayMath.RAY).toInt256()); + liquidationLogicWrapper.setBorrowerCollateralStatus(usdxReserveId, true); liquidationLogicWrapper.setBorrowerBorrowingStatus(wethReserveId, true); - // Mock storage for liquidation config - liquidationConfig = ISpoke.LiquidationConfig({ - healthFactorForMaxBonus: 0.8e18, - liquidationBonusFactor: 50_00, - targetHealthFactor: 1e18 - }); - updateStorage(liquidationConfig); - - // Mock storage for dynamic collateral config - dynamicCollateralConfig = ISpoke.DynamicReserveConfig({ - maxLiquidationBonus: 120_00, - collateralFactor: 50_00, - liquidationFee: 10_00 + // Set liquidationLogicWrapper as a spoke + IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ + active: true, + halted: false, + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK }); - updateStorage(dynamicCollateralConfig); + vm.startPrank(HUB_ADMIN); + collateralReserveHub.addSpoke(usdxAssetId, address(liquidationLogicWrapper), spokeConfig); + debtReserveHub.addSpoke(wethAssetId, address(liquidationLogicWrapper), spokeConfig); + vm.stopPrank(); // Collateral hub: Add liquidity address tempUser = makeUser(); @@ -111,13 +111,25 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { // Debt hub: Add liquidity, remove liquidity, refresh premium and skip time to accrue both drawn and premium debt deal(address(tokenList.weth), tempUser, MAX_SUPPLY_AMOUNT); - Utils.add(hub2, wethAssetId, address(liquidationLogicWrapper), MAX_SUPPLY_AMOUNT, tempUser); - Utils.draw(hub2, wethAssetId, address(liquidationLogicWrapper), tempUser, MAX_SUPPLY_AMOUNT); + Utils.add( + debtReserveHub, + wethAssetId, + address(liquidationLogicWrapper), + MAX_SUPPLY_AMOUNT, + tempUser + ); + Utils.draw( + debtReserveHub, + wethAssetId, + address(liquidationLogicWrapper), + tempUser, + MAX_SUPPLY_AMOUNT + ); vm.startPrank(address(liquidationLogicWrapper)); - hub2.refreshPremium( + debtReserveHub.refreshPremium( wethAssetId, _getExpectedPremiumDelta({ - hub: hub2, + hub: debtReserveHub, assetId: wethAssetId, oldPremiumShares: 0, oldPremiumOffsetRay: 0, @@ -128,23 +140,13 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { ); vm.stopPrank(); skip(365 days); - (uint256 spokeDrawnOwed, uint256 spokePremiumOwed) = hub2.getSpokeOwed( + (uint256 spokeDrawnOwed, uint256 spokePremiumOwed) = debtReserveHub.getSpokeOwed( wethAssetId, address(liquidationLogicWrapper) ); assertGt(spokeDrawnOwed, 10000e18); assertGt(spokePremiumOwed, 10000e18); - // Mock user debt position - liquidationLogicWrapper.setDebtPositionDrawnShares( - hub2.previewRestoreByAssets(wethAssetId, params.drawnDebt) - ); - liquidationLogicWrapper.setDebtPositionPremiumShares(params.premiumDebtRay.fromRayUp()); - liquidationLogicWrapper.setDebtPositionPremiumOffsetRay( - _calculatePremiumAssetsRay(hub2, wethAssetId, params.premiumDebtRay.fromRayUp()).toInt256() - - params.premiumDebtRay.toInt256() - ); - // Mint tokens to liquidator and approve spoke deal(address(tokenList.weth), params.liquidator, spokeDrawnOwed + spokePremiumOwed); Utils.approve( @@ -156,48 +158,53 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { } function test_liquidateUser() public { - uint256 initialHub1UsdxBalance = tokenList.usdx.balanceOf(address(hub1)); - uint256 initialHub2Balance = tokenList.weth.balanceOf(address(hub2)); + uint256 initialCollateralReserveBalance = tokenList.usdx.balanceOf( + address(collateralReserveHub) + ); + uint256 initialDebtReserveBalance = tokenList.weth.balanceOf(address(debtReserveHub)); uint256 initialLiquidatorWethBalance = tokenList.weth.balanceOf(address(params.liquidator)); ISpoke.UserPosition memory debtPosition = liquidationLogicWrapper.getDebtPosition(params.user); - uint256 feeShares = hub1.previewRemoveByAssets(usdxAssetId, 6000e6) - - hub1.previewRemoveByAssets(usdxAssetId, 5900e6); + vm.expectCall( + address(collateralReserveHub), + abi.encodeCall(IHubBase.previewRemoveByShares, (usdxAssetId, 4800e6)), + 1 + ); vm.expectCall( - address(hub1), - abi.encodeCall(IHubBase.previewRemoveByAssets, (usdxAssetId, 6000e6)), + address(collateralReserveHub), + abi.encodeCall(IHubBase.previewRemoveByShares, (usdxAssetId, 4720e6)), 1 ); vm.expectCall( - address(hub1), + address(collateralReserveHub), abi.encodeCall(IHubBase.remove, (usdxAssetId, 5900e6, params.liquidator)), 1 ); vm.expectCall( - address(hub1), - abi.encodeCall(IHubBase.payFeeShares, (usdxAssetId, feeShares)), + address(collateralReserveHub), + abi.encodeCall(IHubBase.payFeeShares, (usdxAssetId, 80e6)), 1 ); vm.expectCall( - address(hub2), + address(debtReserveHub), abi.encodeCall( IHubBase.restore, ( wethAssetId, - 2e18, + 2.1e18, _getExpectedPremiumDelta({ - hub: hub2, + hub: IHub(address(debtReserveHub)), assetId: wethAssetId, oldPremiumShares: debtPosition.premiumShares, oldPremiumOffsetRay: debtPosition.premiumOffsetRay, drawnShares: 0, riskPremium: 0, - restoredPremiumRay: 0.5e18 * WadRayMath.RAY + restoredPremiumRay: 0.4e18 * WadRayMath.RAY }) ) ), @@ -207,11 +214,18 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { bool hasDeficit = liquidationLogicWrapper.liquidateUser(params); assertEq(hasDeficit, false); - assertEq(tokenList.usdx.balanceOf(address(hub1)), initialHub1UsdxBalance - 5900e6); + assertEq( + tokenList.usdx.balanceOf(address(collateralReserveHub)), + initialCollateralReserveBalance - 5900e6 + ); assertEq(tokenList.usdx.balanceOf(address(params.liquidator)), 5900e6); - assertApproxEqAbs(hub1.getSpokeAddedShares(usdxAssetId, address(treasurySpoke)), 100e6, 1); + assertApproxEqAbs( + collateralReserveHub.getSpokeAddedShares(usdxAssetId, address(treasurySpoke)), + 80e6, + 1 + ); - assertEq(tokenList.weth.balanceOf(address(hub2)), initialHub2Balance + 2.5e18); + assertEq(tokenList.weth.balanceOf(address(debtReserveHub)), initialDebtReserveBalance + 2.5e18); assertEq( tokenList.weth.balanceOf(address(params.liquidator)), initialLiquidatorWethBalance - 2.5e18 @@ -225,7 +239,7 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { } function test_liquidateUser_revertsWith_MustNotLeaveDust_Debt() public { - params.totalDebtValue *= 2; + params.userAccountData.totalDebtValueRay *= 2; params.debtToCover = 4.9e18; liquidationLogicWrapper.setCollateralPositionSuppliedShares( liquidationLogicWrapper.getCollateralPosition(params.user).suppliedShares * 2 @@ -235,17 +249,9 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { } function test_liquidateUser_revertsWith_MustNotLeaveDust_Collateral() public { - liquidationLogicWrapper.setCollateralPositionSuppliedShares(6500e6); + liquidationLogicWrapper.setCollateralPositionSuppliedShares(5200e6); params.debtToCover = 2.6e18; vm.expectRevert(ISpoke.MustNotLeaveDust.selector); liquidationLogicWrapper.liquidateUser(params); } - - function updateStorage(ISpoke.LiquidationConfig memory config) internal { - liquidationLogicWrapper.setLiquidationConfig(config); - } - - function updateStorage(ISpoke.DynamicReserveConfig memory config) internal { - liquidationLogicWrapper.setDynamicCollateralConfig(config); - } } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidationAmounts.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidationAmounts.t.sol index 31068db8f..2aeabeeef 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidationAmounts.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidationAmounts.t.sol @@ -7,6 +7,8 @@ import 'tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol'; contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { using MathUtils for uint256; using PercentageMath for uint256; + using WadRayMath for uint256; + using LiquidationLogic for uint256; function test_calculateLiquidationAmounts_fuzz_EnoughCollateral_NoCollateralDust( LiquidationLogic.CalculateLiquidationAmountsParams memory params @@ -15,49 +17,38 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { LiquidationLogic.LiquidationAmounts memory expectedLiquidationAmounts = _calculateRawLiquidationAmounts(params); - params.collateralReserveBalance = bound( - params.collateralReserveBalance, - expectedLiquidationAmounts.collateralToLiquidate + - _convertValueToAmount( - LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, - params.collateralAssetPrice, - params.collateralAssetUnit - ) + - 1, - expectedLiquidationAmounts.collateralToLiquidate + - _convertValueToAmount( - LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, - params.collateralAssetPrice, - params.collateralAssetUnit - ) + + uint256 dustSharesBufferLowerBound = params.collateralReserveHub.previewRemoveByAssets( + params.collateralReserveAssetId, + _convertValueToAmount( + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + params.collateralAssetPrice, + 10 ** params.collateralAssetDecimals + ) + 1 + ); + + params.suppliedShares = bound( + params.suppliedShares, + expectedLiquidationAmounts.collateralSharesToLiquidate + dustSharesBufferLowerBound + 1, + expectedLiquidationAmounts.collateralSharesToLiquidate + + dustSharesBufferLowerBound + + 1 + MAX_SUPPLY_AMOUNT ); params.debtToCover = bound( params.debtToCover, - expectedLiquidationAmounts.debtToLiquidate, - MAX_SUPPLY_AMOUNT + _calculateDebtAssetsToRestore( + expectedLiquidationAmounts.drawnSharesToLiquidate, + expectedLiquidationAmounts.premiumDebtRayToLiquidate, + params.drawnIndex + ), + UINT256_MAX ); LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts(params); - assertEq( - liquidationAmounts.collateralToLiquidate, - expectedLiquidationAmounts.collateralToLiquidate, - 'collateralToLiquidate' - ); - assertApproxEqAbs( - liquidationAmounts.collateralToLiquidator, - expectedLiquidationAmounts.collateralToLiquidator, - 1, - 'collateralToLiquidator' - ); - assertEq( - liquidationAmounts.debtToLiquidate, - expectedLiquidationAmounts.debtToLiquidate, - 'debtToLiquidate' - ); + assertApproxEqAbs(liquidationAmounts, expectedLiquidationAmounts); } function test_calculateLiquidationAmounts_fuzz_EnoughCollateral_NoDebtLeft( @@ -68,79 +59,42 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { LiquidationLogic.LiquidationAmounts memory expectedLiquidationAmounts = _calculateRawLiquidationAmounts(params); - params.collateralReserveBalance = bound( - params.collateralReserveBalance, - expectedLiquidationAmounts.collateralToLiquidate, - expectedLiquidationAmounts.collateralToLiquidate + MAX_SUPPLY_AMOUNT + params.suppliedShares = bound( + params.suppliedShares, + expectedLiquidationAmounts.collateralSharesToLiquidate, + expectedLiquidationAmounts.collateralSharesToLiquidate + MAX_SUPPLY_AMOUNT ); - params.debtToCover = bound(params.debtToCover, params.debtReserveBalance, UINT256_MAX); + params.debtToCover = bound( + params.debtToCover, + _calculateDebtAssetsToRestore( + expectedLiquidationAmounts.drawnSharesToLiquidate, + expectedLiquidationAmounts.premiumDebtRayToLiquidate, + params.drawnIndex + ), + UINT256_MAX + ); LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts(params); - assertEq( - liquidationAmounts.collateralToLiquidate, - expectedLiquidationAmounts.collateralToLiquidate, - 'collateralToLiquidate' - ); - assertApproxEqAbs( - liquidationAmounts.collateralToLiquidator, - expectedLiquidationAmounts.collateralToLiquidator, - 1, - 'collateralToLiquidator' - ); - assertEq(liquidationAmounts.debtToLiquidate, params.debtReserveBalance, 'debtToLiquidate'); + assertApproxEqAbs(liquidationAmounts, expectedLiquidationAmounts); } function test_calculateLiquidationAmounts_fuzz_EnoughCollateral_CollateralDust( LiquidationLogic.CalculateLiquidationAmountsParams memory params ) public { - params = _bound(params); - params.debtToCover = bound(params.debtToCover, params.debtReserveBalance, UINT256_MAX); + params = _boundWithCollateralDustAdjustment(params); LiquidationLogic.LiquidationAmounts memory expectedLiquidationAmounts = _calculateRawLiquidationAmounts(params); - - params.collateralReserveBalance = bound( - params.collateralReserveBalance, - expectedLiquidationAmounts.collateralToLiquidate + 1, - expectedLiquidationAmounts.collateralToLiquidate + - _convertValueToAmount( - LiquidationLogic.DUST_LIQUIDATION_THRESHOLD - 1, - params.collateralAssetPrice, - params.collateralAssetUnit - ) - ); - - if (expectedLiquidationAmounts.debtToLiquidate < params.debtReserveBalance) { + if (expectedLiquidationAmounts.drawnSharesToLiquidate < params.drawnShares) { expectedLiquidationAmounts = _calculateAdjustedLiquidationAmounts(params); } - params.debtToCover = bound( - params.debtToCover, - expectedLiquidationAmounts.debtToLiquidate, - MAX_SUPPLY_AMOUNT - ); - LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts(params); - assertEq( - liquidationAmounts.collateralToLiquidate, - expectedLiquidationAmounts.collateralToLiquidate, - 'collateralToLiquidate' - ); - assertApproxEqAbs( - liquidationAmounts.collateralToLiquidator, - expectedLiquidationAmounts.collateralToLiquidator, - 1, - 'collateralToLiquidator' - ); - assertEq( - liquidationAmounts.debtToLiquidate, - expectedLiquidationAmounts.debtToLiquidate, - 'debtToLiquidate' - ); + assertApproxEqAbs(liquidationAmounts, expectedLiquidationAmounts); } function test_calculateLiquidationAmounts_fuzz_InsufficientCollateral( @@ -149,11 +103,11 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { params = _bound(params); LiquidationLogic.LiquidationAmounts memory rawLiquidationAmounts = _calculateRawLiquidationAmounts(params); - vm.assume(rawLiquidationAmounts.collateralToLiquidate > 0); - params.collateralReserveBalance = bound( - params.collateralReserveBalance, + vm.assume(rawLiquidationAmounts.collateralSharesToLiquidate > 0); + params.suppliedShares = bound( + params.suppliedShares, 0, - rawLiquidationAmounts.collateralToLiquidate - 1 + rawLiquidationAmounts.collateralSharesToLiquidate - 1 ); LiquidationLogic.LiquidationAmounts @@ -161,41 +115,35 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { params.debtToCover = bound( params.debtToCover, - expectedLiquidationAmounts.debtToLiquidate, + _calculateDebtAssetsToRestore( + expectedLiquidationAmounts.drawnSharesToLiquidate, + expectedLiquidationAmounts.premiumDebtRayToLiquidate, + params.drawnIndex + ), MAX_SUPPLY_AMOUNT ); LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts(params); - assertEq( - liquidationAmounts.collateralToLiquidate, - expectedLiquidationAmounts.collateralToLiquidate, - 'collateralToLiquidate' - ); - assertApproxEqAbs( - liquidationAmounts.collateralToLiquidator, - expectedLiquidationAmounts.collateralToLiquidator, - 1, - 'collateralToLiquidator' - ); - assertEq( - liquidationAmounts.debtToLiquidate, - expectedLiquidationAmounts.debtToLiquidate, - 'debtToLiquidate' - ); + assertApproxEqAbs(liquidationAmounts, expectedLiquidationAmounts); } function test_calculateLiquidationAmounts_fuzz_revertsWith_MustNotLeaveDust_Debt( LiquidationLogic.CalculateLiquidationAmountsParams memory params ) public { params = _boundWithDebtDustAdjustment(params); - if (params.debtToCover >= params.debtReserveBalance) { - params.debtToCover = params.debtReserveBalance - 1; + uint256 debtAssetsToRestore = _calculateDebtAssetsToRestore( + params.drawnShares, + params.premiumDebtRay, + params.drawnIndex + ); + if (params.debtToCover >= debtAssetsToRestore) { + params.debtToCover = debtAssetsToRestore - 1; } LiquidationLogic.LiquidationAmounts memory rawLiquidationAmounts = _calculateRawLiquidationAmounts(params); - params.collateralReserveBalance = rawLiquidationAmounts.collateralToLiquidate; + params.suppliedShares = rawLiquidationAmounts.collateralSharesToLiquidate; vm.expectRevert(ISpoke.MustNotLeaveDust.selector); liquidationLogicWrapper.calculateLiquidationAmounts(params); @@ -204,52 +152,50 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { function test_calculateLiquidationAmounts_fuzz_revertsWith_MustNotLeaveDust_Collateral( LiquidationLogic.CalculateLiquidationAmountsParams memory params ) public { - params = _bound(params); - params.debtToCover = bound(params.debtToCover, params.debtReserveBalance, UINT256_MAX); - + params = _boundWithCollateralDustAdjustment(params); LiquidationLogic.LiquidationAmounts - memory expectedLiquidationAmounts = _calculateRawLiquidationAmounts(params); - - params.collateralReserveBalance = bound( - params.collateralReserveBalance, - expectedLiquidationAmounts.collateralToLiquidate + 1, - expectedLiquidationAmounts.collateralToLiquidate + - _convertValueToAmount( - LiquidationLogic.DUST_LIQUIDATION_THRESHOLD - 1, - params.collateralAssetPrice, - params.collateralAssetUnit - ) - ); - - if (expectedLiquidationAmounts.debtToLiquidate < params.debtReserveBalance) { - expectedLiquidationAmounts = _calculateAdjustedLiquidationAmounts(params); - } - - vm.assume(expectedLiquidationAmounts.debtToLiquidate > 0); - params.debtToCover = expectedLiquidationAmounts.debtToLiquidate - 1; + memory expectedLiquidationAmounts = _calculateAdjustedLiquidationAmounts(params); + vm.assume(expectedLiquidationAmounts.premiumDebtRayToLiquidate > 0); + params.debtToCover = + _calculateDebtAssetsToRestore( + expectedLiquidationAmounts.drawnSharesToLiquidate, + expectedLiquidationAmounts.premiumDebtRayToLiquidate, + params.drawnIndex + ) - 1; vm.expectRevert(ISpoke.MustNotLeaveDust.selector); liquidationLogicWrapper.calculateLiquidationAmounts(params); } - function test_calculateLiquidationAmounts_EnoughCollateral() public view { + function test_calculateLiquidationAmounts_EnoughCollateral() public { // variable liquidation bonus is max: 120% // liquidation penalty: 1.2 * 0.5 = 0.6 // debtToTarget = $10000 * (1 - 0.8) / (1 - 0.6) / $2000 = 2.5 - // max debt to liquidate = min(2.5, 5, 3) = 2.5 + // max debt to liquidate = min(2.5, 3 * 1.6 + 0.5, 3) = 2.5 + // premiumDebtRayToLiquidate = 0.5 + // drawnSharesToLiquidate = (2.5 - 0.5) / 1.6 = 1.25 // collateral to liquidate = 2.5 * 120% * $2000 / $1 = 6000 - // bonus collateral = 6000 - 6000 / 120% = 1000 - // collateral fee = 1000 * 10% = 100 - // collateral to liquidator = 6000 - 100 = 5900 + // collateral shares to liquidate = 6000 / 1.25 = 4800 + // bonus collateral shares = 4800 - 4800 / 120% = 800 + // collateral fee shares = 800 * 10% = 80 + // collateral shares to liquidator = 4800 - 80 = 4720 + IHub collateralReserveHub = hub1; + uint256 collateralAssetId = vm.randomUint(0, collateralReserveHub.getAssetCount() - 1); + _mockSupplySharePrice(collateralReserveHub, collateralAssetId, 12_500.25e6, 10_000e6); + LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts( LiquidationLogic.CalculateLiquidationAmountsParams({ - collateralReserveBalance: 11_000e6, - collateralAssetUnit: 10 ** 6, + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 10_000e6, + collateralAssetDecimals: 6, collateralAssetPrice: 1e8, - debtReserveBalance: 5e18, - totalDebtValue: 10_000e26, - debtAssetUnit: 10 ** 18, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, debtAssetPrice: 2000e8, debtToCover: 3e18, collateralFactor: 50_00, @@ -262,31 +208,48 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { }) ); - assertEq(liquidationAmounts.collateralToLiquidate, 6000e6, 'collateralToLiquidate'); - assertEq(liquidationAmounts.collateralToLiquidator, 5900e6, 'collateralToLiquidator'); - assertEq(liquidationAmounts.debtToLiquidate, 2.5e18, 'debtToLiquidate'); + assertApproxEqAbs( + liquidationAmounts, + LiquidationLogic.LiquidationAmounts({ + collateralSharesToLiquidate: 4800e6, + collateralSharesToLiquidator: 4720e6, + drawnSharesToLiquidate: 1.25e18, + premiumDebtRayToLiquidate: 0.5e18 * 1e27 + }) + ); } - function test_calculateLiquidationAmounts_InsufficientCollateral() public view { + function test_calculateLiquidationAmounts_InsufficientCollateral() public { // variable liquidation bonus is max: 120% // liquidation penalty: 1.2 * 0.5 = 0.6 // debtToTarget = $10000 * (1 - 0.8) / (1 - 0.6) / $2000 = 2.5 - // max debt to liquidate = min(2.5, 5, 3) = 2.5 + // max debt to liquidate = min(2.5, 3 * 1.6 + 0.5, 3) = 2.5 // collateral to liquidate = 2.5 * 120% * $2000 / $1 = 6000 - // total reserve collateral = 3000 - // adjusted debt to liquidate = 3000 / 120% * $1 / $2000 = 1.25 - // bonus collateral = 3000 - 3000 / 120% = 500 - // collateral fee = 500 * 10% = 50 - // collateral to liquidator = 3000 - 50 = 2950 + // collateral shares to liquidate = 6000 / 1.25 = 4800 + // supplied shares: 4500 + // adjusted debt to liquidate = 4500 * 1.25 / 120% * $1 / $2000 = 2.34375 + // premiumDebtRayToLiquidate = 0.5 + // drawnSharesToLiquidate = (2.34375 - 0.5) / 1.6 = 1.15234375 + // bonus collateral shares = 4500 - 4500 / 120% = 750 + // collateral fee shares = 750 * 10% = 75 + // collateral shares to liquidator = 4500 - 75 = 4425 + IHub collateralReserveHub = hub1; + uint256 collateralAssetId = vm.randomUint(0, collateralReserveHub.getAssetCount() - 1); + _mockSupplySharePrice(collateralReserveHub, collateralAssetId, 12500.25e6, 10_000e6); + LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts( LiquidationLogic.CalculateLiquidationAmountsParams({ - collateralReserveBalance: 3000e6, - collateralAssetUnit: 10 ** 6, + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 4500e6, + collateralAssetDecimals: 6, collateralAssetPrice: 1e8, - debtReserveBalance: 5e18, - totalDebtValue: 10_000e26, - debtAssetUnit: 10 ** 18, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, debtAssetPrice: 2000e8, debtToCover: 3e18, collateralFactor: 50_00, @@ -299,9 +262,15 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { }) ); - assertEq(liquidationAmounts.collateralToLiquidate, 3000e6, 'collateralToLiquidate'); - assertEq(liquidationAmounts.collateralToLiquidator, 2950e6, 'collateralToLiquidator'); - assertEq(liquidationAmounts.debtToLiquidate, 1.25e18, 'debtToLiquidate'); + assertApproxEqAbs( + liquidationAmounts, + LiquidationLogic.LiquidationAmounts({ + collateralSharesToLiquidate: 4500e6, + collateralSharesToLiquidator: 4425e6, + drawnSharesToLiquidate: 1.15234375e18, + premiumDebtRayToLiquidate: 0.5e18 * 1e27 + }) + ); } function _calculateRawLiquidationAmounts( @@ -314,24 +283,35 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { maxLiquidationBonus: params.maxLiquidationBonus }); - uint256 debtToLiquidate = liquidationLogicWrapper.calculateDebtToLiquidate( - _getCalculateDebtToLiquidateParams(params) + (uint256 drawnSharesToLiquidate, uint256 premiumDebtRayToLiquidate) = liquidationLogicWrapper + .calculateDebtToLiquidate(_getCalculateDebtToLiquidateParams(params)); + uint256 debtRayToLiquidate = drawnSharesToLiquidate * params.drawnIndex + + premiumDebtRayToLiquidate; + uint256 collateralToLiquidate = Math.mulDiv( + debtRayToLiquidate, + params.debtAssetPrice * (10 ** params.collateralAssetDecimals) * liquidationBonus, + (10 ** params.debtAssetDecimals) * + params.collateralAssetPrice * + PercentageMath.PERCENTAGE_FACTOR * + WadRayMath.RAY, + Math.Rounding.Floor ); - uint256 collateralToLiquidate = debtToLiquidate.mulDivDown( - params.debtAssetPrice * params.collateralAssetUnit * liquidationBonus, - params.debtAssetUnit * params.collateralAssetPrice * PercentageMath.PERCENTAGE_FACTOR + uint256 collateralSharesToLiquidate = params.collateralReserveHub.previewAddByAssets( + params.collateralReserveAssetId, + collateralToLiquidate ); - uint256 collateralToLiquidator = _calculateCollateralToLiquidator( - collateralToLiquidate, + uint256 collateralSharesToLiquidator = _calculateCollateralSharesToLiquidator( + collateralSharesToLiquidate, liquidationBonus, params.liquidationFee ); return LiquidationLogic.LiquidationAmounts({ - collateralToLiquidate: collateralToLiquidate, - collateralToLiquidator: collateralToLiquidator, - debtToLiquidate: debtToLiquidate + collateralSharesToLiquidate: collateralSharesToLiquidate, + collateralSharesToLiquidator: collateralSharesToLiquidator, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate }); } @@ -345,34 +325,130 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { maxLiquidationBonus: params.maxLiquidationBonus }); - uint256 collateralToLiquidate = params.collateralReserveBalance; - uint256 collateralToLiquidator = _calculateCollateralToLiquidator( - collateralToLiquidate, + uint256 collateralSharesToLiquidate = params.suppliedShares; + uint256 collateralSharesToLiquidator = _calculateCollateralSharesToLiquidator( + collateralSharesToLiquidate, liquidationBonus, params.liquidationFee ); - uint256 debtToLiquidate = collateralToLiquidate - .mulDivUp( - params.collateralAssetPrice * params.debtAssetUnit * PercentageMath.PERCENTAGE_FACTOR, - params.collateralAssetUnit * params.debtAssetPrice * liquidationBonus - ) - .min(params.debtReserveBalance); + + uint256 debtRayToLiquidate = Math.mulDiv( + params.collateralReserveHub.previewAddByShares( + params.collateralReserveAssetId, + collateralSharesToLiquidate + ), + params.collateralAssetPrice * + (10 ** params.debtAssetDecimals) * + PercentageMath.PERCENTAGE_FACTOR * + WadRayMath.RAY, + (10 ** params.collateralAssetDecimals) * params.debtAssetPrice * liquidationBonus, + Math.Rounding.Ceil + ); + + uint256 premiumDebtRayToLiquidate = debtRayToLiquidate.fromRayUp().toRay().min( + params.premiumDebtRay + ); + uint256 drawnSharesToLiquidate; + if (premiumDebtRayToLiquidate < debtRayToLiquidate) { + drawnSharesToLiquidate = (debtRayToLiquidate - premiumDebtRayToLiquidate).divUp( + params.drawnIndex + ); + } + + if (drawnSharesToLiquidate > params.drawnShares) { + drawnSharesToLiquidate = params.drawnShares; + } return LiquidationLogic.LiquidationAmounts({ - collateralToLiquidate: collateralToLiquidate, - collateralToLiquidator: collateralToLiquidator, - debtToLiquidate: debtToLiquidate + collateralSharesToLiquidate: collateralSharesToLiquidate, + collateralSharesToLiquidator: collateralSharesToLiquidator, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate }); } - function _calculateCollateralToLiquidator( - uint256 collateralToLiquidate, + function _boundWithCollateralDustAdjustment( + LiquidationLogic.CalculateLiquidationAmountsParams memory params + ) internal virtual returns (LiquidationLogic.CalculateLiquidationAmountsParams memory) { + params = _bound(params); + params.drawnShares = MAX_SUPPLY_ASSET_UNITS * 10 ** params.debtAssetDecimals; + params.debtToCover = UINT256_MAX; + + // bound price such that 1 supply share is worth less than DUST_LIQUIDATION_THRESHOLD + params.collateralAssetPrice = bound( + params.collateralAssetPrice, + 1, + params.collateralReserveHub.previewAddByAssets( + params.collateralReserveAssetId, + _convertDecimals( + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + 18, + params.collateralAssetDecimals, + false + ) + ) + ); + + LiquidationLogic.LiquidationAmounts + memory expectedLiquidationAmounts = _calculateRawLiquidationAmounts(params); + + uint256 dustSharesBufferUpperBound = params.collateralReserveHub.previewAddByAssets( + params.collateralReserveAssetId, + _convertValueToAmount( + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD - 1, + params.collateralAssetPrice, + 10 ** params.collateralAssetDecimals + ) + ); + + params.suppliedShares = bound( + params.suppliedShares, + expectedLiquidationAmounts.collateralSharesToLiquidate + 1, + expectedLiquidationAmounts.collateralSharesToLiquidate + _max(1, dustSharesBufferUpperBound) + ); + + expectedLiquidationAmounts = _calculateAdjustedLiquidationAmounts(params); + + params.debtToCover = bound( + params.debtToCover, + _calculateDebtAssetsToRestore( + expectedLiquidationAmounts.drawnSharesToLiquidate, + expectedLiquidationAmounts.premiumDebtRayToLiquidate, + params.drawnIndex + ), + UINT256_MAX + ); + + return params; + } + + function _calculateCollateralSharesToLiquidator( + uint256 collateralSharesToLiquidate, uint256 liquidationBonus, uint256 liquidationFee ) internal pure returns (uint256) { - uint256 bonusCollateral = collateralToLiquidate - - collateralToLiquidate.percentDivUp(liquidationBonus); - return collateralToLiquidate - bonusCollateral.percentMulDown(liquidationFee); + uint256 bonusCollateralShares = collateralSharesToLiquidate - + collateralSharesToLiquidate.percentDivUp(liquidationBonus); + return collateralSharesToLiquidate - bonusCollateralShares.percentMulDown(liquidationFee); + } + + function assertApproxEqAbs( + LiquidationLogic.LiquidationAmounts memory a, + LiquidationLogic.LiquidationAmounts memory b + ) internal pure { + assertEq( + a.collateralSharesToLiquidate, + b.collateralSharesToLiquidate, + 'collateralSharesToLiquidate' + ); + assertApproxEqAbs( + a.collateralSharesToLiquidator, + b.collateralSharesToLiquidator, + 1, + 'collateralSharesToLiquidator' + ); + assertEq(a.drawnSharesToLiquidate, b.drawnSharesToLiquidate, 'drawnSharesToLiquidate'); + assertEq(a.premiumDebtRayToLiquidate, b.premiumDebtRayToLiquidate, 'premiumDebtRayToLiquidate'); } } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ValidateLiquidationCall.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ValidateLiquidationCall.t.sol index 675ac08e2..3a35285bc 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ValidateLiquidationCall.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ValidateLiquidationCall.t.sol @@ -12,15 +12,15 @@ contract LiquidationLogicValidateLiquidationCallTest is LiquidationLogicBaseTest function setUp() public override { super.setUp(); - ReserveFlags collateralReserveFlags = ReserveFlagsMap.create(false, false, true, true, true); - ReserveFlags debtReserveFlags = ReserveFlagsMap.create(false, false, true, true, true); + ReserveFlags collateralReserveFlags = ReserveFlagsMap.create(false, false, true, true); + ReserveFlags debtReserveFlags = ReserveFlagsMap.create(false, false, true, true); params = LiquidationLogic.ValidateLiquidationCallParams({ user: alice, liquidator: bob, collateralReserveFlags: collateralReserveFlags, debtReserveFlags: debtReserveFlags, - collateralReserveBalance: 120e6, - debtReserveBalance: 100e18, + suppliedShares: 120e6, + drawnShares: 100e18, debtToCover: 5e18, collateralFactor: 75_00, isUsingAsCollateral: true, @@ -191,41 +191,17 @@ contract LiquidationLogicValidateLiquidationCallTest is LiquidationLogicBaseTest } function test_validateLiquidationCall_revertsWith_ReserveNotSupplied() public { - params.collateralReserveBalance = 0; + params.suppliedShares = 0; vm.expectRevert(ISpoke.ReserveNotSupplied.selector); liquidationLogicWrapper.validateLiquidationCall(params); } function test_validateLiquidationCall_revertsWith_ReserveNotBorrowed() public { - params.debtReserveBalance = 0; + params.drawnShares = 0; vm.expectRevert(ISpoke.ReserveNotBorrowed.selector); liquidationLogicWrapper.validateLiquidationCall(params); } - function test_validateLiquidationCall_revertsWith_CollateralCannotBeLiquidated() public { - // collateral.liquidatable = false; debt.liquidatable = false; => revert - params.collateralReserveFlags = params.collateralReserveFlags.setLiquidatable(false); - params.debtReserveFlags = params.debtReserveFlags.setLiquidatable(false); - vm.expectRevert(ISpoke.CollateralCannotBeLiquidated.selector); - liquidationLogicWrapper.validateLiquidationCall(params); - - // collateral.liquidatable = false; debt.liquidatable = true; => revert - params.collateralReserveFlags = params.collateralReserveFlags.setLiquidatable(false); - params.debtReserveFlags = params.debtReserveFlags.setLiquidatable(true); - vm.expectRevert(ISpoke.CollateralCannotBeLiquidated.selector); - liquidationLogicWrapper.validateLiquidationCall(params); - - // collateral.liquidatable = true; debt.liquidatable = true; => allowed - params.collateralReserveFlags = params.collateralReserveFlags.setLiquidatable(true); - params.debtReserveFlags = params.debtReserveFlags.setLiquidatable(true); - liquidationLogicWrapper.validateLiquidationCall(params); - - // collateral.liquidatable = true; debt.liquidatable = false; => allowed - params.collateralReserveFlags = params.collateralReserveFlags.setLiquidatable(true); - params.debtReserveFlags = params.debtReserveFlags.setLiquidatable(false); - liquidationLogicWrapper.validateLiquidationCall(params); - } - function test_validateLiquidationCall() public view { liquidationLogicWrapper.validateLiquidationCall(params); } diff --git a/tests/unit/libraries/PositionStatusMap.t.sol b/tests/unit/libraries/PositionStatusMap.t.sol index 1f745f973..d0d74aa36 100644 --- a/tests/unit/libraries/PositionStatusMap.t.sol +++ b/tests/unit/libraries/PositionStatusMap.t.sol @@ -202,12 +202,79 @@ contract PositionStatusMapTest is Base { for (uint256 reserveId; reserveId < reserveCount; ++reserveId) { if (p.isUsingAsCollateral(reserveId)) ++collateralCount; // reserveId is 0-base indexed, assert running collateralCount is maintained correctly - assertEq(p.collateralCount({reserveCount: reserveId + 1}), collateralCount); + assertEq(p.collateralCount(reserveId + 1), collateralCount); } assertEq(p.collateralCount(reserveCount), collateralCount); } + function test_borrowCount() public { + p.setBorrowing(127, true); + assertEq(p.borrowCount(128), 1); + + p.setBorrowing(128, true); + assertEq(p.borrowCount(128), 1); + assertEq(p.borrowCount(129), 2); + + // ignore invalid bits + assertEq(p.borrowCount(100), 0); + + p.setBorrowing(2, true); + assertEq(p.borrowCount(128), 2); + + p.setBorrowing(32, true); + assertEq(p.borrowCount(128), 3); + p.setBorrowing(342, true); + assertEq(p.borrowCount(343), 5); + + p.setBorrowing(32, false); + assertEq(p.borrowCount(343), 4); + + // disregards collateral reserves + p.setUsingAsCollateral(32, true); + assertEq(p.borrowCount(343), 4); + + p.setUsingAsCollateral(79, true); + assertEq(p.borrowCount(343), 4); + + p.setUsingAsCollateral(255, true); + assertEq(p.borrowCount(343), 4); + } + + function test_borrowCount_ignoresInvalidBits() public { + p.setBorrowing(127, true); + assertEq(p.borrowCount(100), 0); + assertEq(p.borrowCount(200), 1); + + p.setBorrowing(255, true); + assertEq(p.borrowCount(200), 1); + p.setBorrowing(133, true); + assertEq(p.borrowCount(200), 2); + + p.setBorrowing(383, true); + assertEq(p.borrowCount(300), 3); + p.setBorrowing(283, true); + assertEq(p.borrowCount(300), 4); + + p.setBorrowing(511, true); + assertEq(p.borrowCount(500), 5); + assertEq(p.borrowCount(600), 6); + } + + function test_borrowCount(uint256 reserveCount) public { + reserveCount = bound(reserveCount, 0, 1 << 10); // gas limit + vm.setArbitraryStorage(address(p)); + + uint256 borrowCount; + for (uint256 reserveId; reserveId < reserveCount; ++reserveId) { + if (p.isBorrowing(reserveId)) ++borrowCount; + // reserveId is 0-base indexed, assert running borrowCount is maintained correctly + assertEq(p.borrowCount(reserveId + 1), borrowCount); + } + + assertEq(p.borrowCount(reserveCount), borrowCount); + } + function test_setters_use_correct_slot(uint256 a) public { uint256 bucket = a / 128; bytes32 slot = keccak256(abi.encode(bucket, p.slot())); @@ -249,19 +316,18 @@ contract PositionStatusMapTest is Base { uint256 startReserveId = vm.randomUint(1, reserveCount); uint256 expectedReserveId = PositionStatusMap.NOT_FOUND; - for (uint256 i = startReserveId - 1; i >= 0; --i) { - if (p.isUsingAsCollateral(i) || p.isBorrowing(i)) { - expectedReserveId = i; + for (uint256 i = startReserveId; i > 0; --i) { + if (p.isUsingAsCollateral(i - 1) || p.isBorrowing(i - 1)) { + expectedReserveId = i - 1; break; } } (uint256 reserveId, bool borrowing, bool collateral) = p.next(startReserveId); assertEq(reserveId, expectedReserveId); - assertEq(borrowing, reserveId != PositionStatusMap.NOT_FOUND && p.isBorrowing(reserveId)); - assertEq( - collateral, - reserveId != PositionStatusMap.NOT_FOUND && p.isUsingAsCollateral(reserveId) - ); + if (reserveId != PositionStatusMap.NOT_FOUND) { + assertEq(borrowing, p.isBorrowing(reserveId)); + assertEq(collateral, p.isUsingAsCollateral(reserveId)); + } } function test_nextBorrowing(uint256 reserveCount) public { @@ -270,15 +336,17 @@ contract PositionStatusMapTest is Base { uint256 startReserveId = vm.randomUint(1, reserveCount); uint256 expectedReserveId = PositionStatusMap.NOT_FOUND; - for (uint256 i = startReserveId - 1; i >= 0; --i) { - if (p.isBorrowing(i)) { - expectedReserveId = i; + for (uint256 i = startReserveId; i > 0; --i) { + if (p.isBorrowing(i - 1)) { + expectedReserveId = i - 1; break; } } uint256 reserveId = p.nextBorrowing(startReserveId); assertEq(reserveId, expectedReserveId); - assertEq(p.isBorrowing(reserveId), reserveId != PositionStatusMap.NOT_FOUND); + if (reserveId != PositionStatusMap.NOT_FOUND) { + assertTrue(p.isBorrowing(reserveId)); + } } function test_nextCollateral(uint256 reserveCount) public { @@ -287,15 +355,17 @@ contract PositionStatusMapTest is Base { uint256 startReserveId = vm.randomUint(1, reserveCount); uint256 expectedReserveId = PositionStatusMap.NOT_FOUND; - for (uint256 i = startReserveId - 1; i >= 0; --i) { - if (p.isUsingAsCollateral(i)) { - expectedReserveId = i; + for (uint256 i = startReserveId; i > 0; --i) { + if (p.isUsingAsCollateral(i - 1)) { + expectedReserveId = i - 1; break; } } uint256 reserveId = p.nextCollateral(startReserveId); assertEq(reserveId, expectedReserveId); - assertEq(p.isUsingAsCollateral(reserveId), reserveId != PositionStatusMap.NOT_FOUND); + if (reserveId != PositionStatusMap.NOT_FOUND) { + assertTrue(p.isUsingAsCollateral(reserveId)); + } } function test_next_continuous() public { diff --git a/tests/unit/libraries/SpokeUtils.t.sol b/tests/unit/libraries/SpokeUtils.t.sol new file mode 100644 index 000000000..dfe774658 --- /dev/null +++ b/tests/unit/libraries/SpokeUtils.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/Spoke/SpokeBase.t.sol'; + +contract SpokeUtilsTest is SpokeBase { + SpokeUtilsWrapper internal w; + + ISpoke.Reserve reserve0; + ISpoke.Reserve reserve1; + ISpoke.Reserve reserve2; + + function setUp() public virtual override { + super.setUp(); + + w = new SpokeUtilsWrapper(); + + reserve0 = ISpoke.Reserve({ + underlying: address(tokenList.usdx), + hub: IHubBase(address(1)), + assetId: 1, + decimals: 6, + collateralRisk: 10_00, + flags: ReserveFlagsMap.create(false, false, true, true), + dynamicConfigKey: 0 + }); + + reserve1 = ISpoke.Reserve({ + underlying: address(tokenList.weth), + hub: IHubBase(address(2)), + assetId: 2, + decimals: 18, + collateralRisk: 15_00, + flags: ReserveFlagsMap.create(false, false, true, true), + dynamicConfigKey: 3 + }); + + reserve2 = ISpoke.Reserve({ + underlying: address(tokenList.dai), + hub: IHubBase(address(0)), + assetId: 3, + decimals: 18, + collateralRisk: 20_00, + flags: ReserveFlagsMap.create(false, false, true, true), + dynamicConfigKey: 1 + }); + } + + function _populateReserves() public { + w.setReserve(0, reserve0); + w.setReserve(1, reserve1); + w.setReserve(2, reserve2); + } + + function test_get_revertsWith_ReserveNotListed() public { + vm.expectRevert(ISpoke.ReserveNotListed.selector); + w.get(0); + _populateReserves(); + vm.expectRevert(ISpoke.ReserveNotListed.selector); + w.get(2); + } + + function test_get() public { + _populateReserves(); + assertEq(w.get(0), reserve0); + assertEq(w.get(1), reserve1); + } + + // Reverts if asset uses more than 18 decimals. + function test_toValue_revertsWith_ArithmeticUnderflow() public { + vm.expectRevert(stdError.arithmeticError); + w.toValue(1, 19, 1e8); + } + + // Reverts if multiplication overflows. + function test_toValue_revertsWith_ArithmeticOverflow() public { + vm.expectRevert(stdError.arithmeticError); + w.toValue(1e50, 6, 1e16); + } + + function test_toValue() public view { + assertEq(w.toValue(4.2e6, 6, 200e8), 840e26); + } + + function test_fuzz_toValue(uint256 amount, uint256 decimals, uint256 price) public view { + amount = bound(amount, 0, MAX_SUPPLY_AMOUNT); + decimals = bound(decimals, MIN_TOKEN_DECIMALS_SUPPORTED, MAX_TOKEN_DECIMALS_SUPPORTED); + price = bound(price, 0, MAX_ASSET_PRICE); + assertEq(w.toValue(amount, decimals, price), amount * price * (10 ** (18 - decimals))); + } +} diff --git a/tests/unit/libraries/UserPositionDebt.t.sol b/tests/unit/libraries/UserPositionDebt.t.sol index d45b236d1..6c202c8d9 100644 --- a/tests/unit/libraries/UserPositionDebt.t.sol +++ b/tests/unit/libraries/UserPositionDebt.t.sol @@ -4,9 +4,9 @@ pragma solidity ^0.8.0; import 'tests/Base.t.sol'; -import {UserPositionDebtWrapper} from 'tests/mocks/UserPositionDebtWrapper.sol'; +import {UserPositionUtilsWrapper} from 'tests/mocks/UserPositionUtilsWrapper.sol'; -contract UserPositionDebtTest is Base { +contract UserPositionUtilsTest is Base { using SafeCast for *; using WadRayMath for *; using MathUtils for uint256; @@ -21,7 +21,7 @@ contract UserPositionDebtTest is Base { uint256 restoredPremiumRay; } - UserPositionDebtWrapper internal u; + UserPositionUtilsWrapper internal u; uint256 internal constant DRAWN_SHARES = 200e18; uint256 internal constant PREMIUM_SHARES = 99e18; @@ -32,7 +32,7 @@ contract UserPositionDebtTest is Base { uint256 internal assetId; function setUp() public override { - u = new UserPositionDebtWrapper(); + u = new UserPositionUtilsWrapper(); hub = hub1; assetId = wethAssetId; @@ -64,12 +64,12 @@ contract UserPositionDebtTest is Base { assertEq(u.getUserPosition().premiumOffsetRay, -90e18 * 1e27); } - function test_fuzz_getPremiumDelta(BoundParams memory params) public { + function test_fuzz_calculatePremiumDelta(BoundParams memory params) public { params = _bound(params); _mockUserDrawnShares(params.drawnShares); _mockUserPremiumData(params.premiumShares, params.premiumOffsetRay); assertEq( - u.getPremiumDelta( + u.calculatePremiumDelta( params.drawnSharesTaken, params.drawnIndex, params.riskPremium, @@ -86,9 +86,9 @@ contract UserPositionDebtTest is Base { ); } - function test_getPremiumDelta() public view { + function test_calculatePremiumDelta() public view { assertEq( - u.getPremiumDelta(0, DRAWN_INDEX, 20_00, 48.5e18 * 1e27), + u.calculatePremiumDelta(0, DRAWN_INDEX, 20_00, 48.5e18 * 1e27), IHubBase.PremiumDelta({ sharesDelta: -59e18, // 40 - 99 offsetRayDelta: -40e18 * 1e27, // (60 - (248.5 - 48.5)) - (-100) diff --git a/tests/unit/libraries/UserPositionUtils.t.sol b/tests/unit/libraries/UserPositionUtils.t.sol new file mode 100644 index 000000000..6c202c8d9 --- /dev/null +++ b/tests/unit/libraries/UserPositionUtils.t.sol @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/Base.t.sol'; + +import {UserPositionUtilsWrapper} from 'tests/mocks/UserPositionUtilsWrapper.sol'; + +contract UserPositionUtilsTest is Base { + using SafeCast for *; + using WadRayMath for *; + using MathUtils for uint256; + + struct BoundParams { + uint256 drawnShares; + uint256 premiumShares; + int256 premiumOffsetRay; + uint256 drawnSharesTaken; + uint256 drawnIndex; + uint256 riskPremium; + uint256 restoredPremiumRay; + } + + UserPositionUtilsWrapper internal u; + + uint256 internal constant DRAWN_SHARES = 200e18; + uint256 internal constant PREMIUM_SHARES = 99e18; + int256 internal constant PREMIUM_OFFSET_RAY = -100e18 * 1e27; + uint256 internal constant DRAWN_INDEX = 1.5e27; + + IHub internal hub; + uint256 internal assetId; + + function setUp() public override { + u = new UserPositionUtilsWrapper(); + hub = hub1; + assetId = wethAssetId; + + _mockUserDrawnShares(DRAWN_SHARES); + _mockUserPremiumData(PREMIUM_SHARES, PREMIUM_OFFSET_RAY); + _mockHubDrawnIndex(DRAWN_INDEX); + } + + function test_fuzz_applyPremiumDelta(IHubBase.PremiumDelta memory premiumDelta) public { + premiumDelta = _bound(premiumDelta); + + u.applyPremiumDelta(premiumDelta); + assertEq(u.getUserPosition().premiumShares, PREMIUM_SHARES.add(premiumDelta.sharesDelta)); + assertEq( + u.getUserPosition().premiumOffsetRay, + PREMIUM_OFFSET_RAY + premiumDelta.offsetRayDelta + ); + } + + function test_applyPremiumDelta() public { + u.applyPremiumDelta( + IHubBase.PremiumDelta({ + sharesDelta: -10e18, + offsetRayDelta: 10e18 * 1e27, + restoredPremiumRay: vm.randomUint() + }) + ); + assertEq(u.getUserPosition().premiumShares, 89e18); + assertEq(u.getUserPosition().premiumOffsetRay, -90e18 * 1e27); + } + + function test_fuzz_calculatePremiumDelta(BoundParams memory params) public { + params = _bound(params); + _mockUserDrawnShares(params.drawnShares); + _mockUserPremiumData(params.premiumShares, params.premiumOffsetRay); + assertEq( + u.calculatePremiumDelta( + params.drawnSharesTaken, + params.drawnIndex, + params.riskPremium, + params.restoredPremiumRay + ), + _getExpectedPremiumDelta({ + drawnIndex: params.drawnIndex, + oldPremiumShares: params.premiumShares, + oldPremiumOffsetRay: params.premiumOffsetRay, + drawnShares: params.drawnShares - params.drawnSharesTaken, + riskPremium: params.riskPremium, + restoredPremiumRay: params.restoredPremiumRay + }) + ); + } + + function test_calculatePremiumDelta() public view { + assertEq( + u.calculatePremiumDelta(0, DRAWN_INDEX, 20_00, 48.5e18 * 1e27), + IHubBase.PremiumDelta({ + sharesDelta: -59e18, // 40 - 99 + offsetRayDelta: -40e18 * 1e27, // (60 - (248.5 - 48.5)) - (-100) + restoredPremiumRay: 48.5e18 * 1e27 + }) + ); + } + + function test_fuzz_calculatePremiumRay( + uint256 premiumShares, + int256 premiumOffsetRay, + uint256 drawnIndex + ) public { + (premiumShares, premiumOffsetRay, drawnIndex) = _bound({ + premiumShares: premiumShares, + premiumOffsetRay: premiumOffsetRay, + drawnIndex: drawnIndex + }); + _mockUserPremiumData(premiumShares, premiumOffsetRay); + assertEq( + u.calculatePremiumRay(drawnIndex), + _calculatePremiumDebtRay(premiumShares, premiumOffsetRay, drawnIndex) + ); + } + + function test_calculatePremiumRay() public { + _mockUserPremiumData(PREMIUM_SHARES, PREMIUM_OFFSET_RAY); + assertEq(u.calculatePremiumRay(DRAWN_INDEX), 248.5e18 * 1e27); + } + + function test_fuzz_getUserDebt_HubAndAssetId( + uint256 drawnShares, + uint256 premiumShares, + int256 premiumOffsetRay, + uint256 drawnIndex + ) public { + (drawnShares, premiumShares, premiumOffsetRay, drawnIndex) = _bound({ + drawnShares: drawnShares, + premiumShares: premiumShares, + premiumOffsetRay: premiumOffsetRay, + drawnIndex: drawnIndex + }); + + _mockUserDrawnShares(drawnShares); + _mockUserPremiumData(premiumShares, premiumOffsetRay); + _mockHubDrawnIndex(drawnIndex); + + (uint256 drawnDebt, uint256 premiumDebtRay) = u.getDebt(hub, assetId); + assertEq(drawnDebt, drawnShares.rayMulUp(drawnIndex)); + uint256 expectedPremiumDebtRay = _calculatePremiumDebtRay( + premiumShares, + premiumOffsetRay, + drawnIndex + ); + assertEq(premiumDebtRay, expectedPremiumDebtRay); + } + + function test_getUserDebt_HubAndAssetId() public { + (uint256 drawnDebt, uint256 premiumDebtRay) = u.getDebt(hub, assetId); + assertEq(drawnDebt, 300e18); + assertEq(premiumDebtRay, 248.5e18 * 1e27); + + _mockUserPremiumData(70e18, 0); + _mockHubDrawnIndex(1.777777777777777777777777777e27); + (drawnDebt, premiumDebtRay) = u.getDebt(hub, assetId); + assertEq(drawnDebt, 355.555555555555555556e18); + assertEq(premiumDebtRay, 124.44444444444444444444444439e45); + } + + function test_fuzz_getUserDebt_DrawnIndex( + uint256 drawnShares, + uint256 premiumShares, + int256 premiumOffsetRay, + uint256 drawnIndex + ) public { + (drawnShares, premiumShares, premiumOffsetRay, drawnIndex) = _bound({ + drawnShares: drawnShares, + premiumShares: premiumShares, + premiumOffsetRay: premiumOffsetRay, + drawnIndex: drawnIndex + }); + _mockUserDrawnShares(drawnShares); + _mockUserPremiumData(premiumShares, premiumOffsetRay); + + (uint256 drawnDebt, uint256 premiumDebtRay) = u.getDebt(drawnIndex); + assertEq(drawnDebt, drawnShares.rayMulUp(drawnIndex)); + uint256 expectedPremiumDebtRay = _calculatePremiumDebtRay( + premiumShares, + premiumOffsetRay, + drawnIndex + ); + assertEq(premiumDebtRay, expectedPremiumDebtRay); + } + + function test_getUserDebt_DrawnIndex() public { + (uint256 drawnDebt, uint256 premiumDebtRay) = u.getDebt(DRAWN_INDEX); + assertEq(drawnDebt, 300e18); + assertEq(premiumDebtRay, 248.5e18 * 1e27); + + _mockUserPremiumData(70e18, 0); + (drawnDebt, premiumDebtRay) = u.getDebt(1.777777777777777777777777777e27); + assertEq(drawnDebt, 355.555555555555555556e18); + assertEq(premiumDebtRay, 124.44444444444444444444444439e45); + } + + function test_fuzz_calculateRestoreAmount( + uint256 drawnShares, + uint256 premiumShares, + int256 premiumOffsetRay, + uint256 drawnIndex, + uint256 amount + ) public { + (drawnShares, premiumShares, premiumOffsetRay, drawnIndex) = _bound({ + drawnShares: drawnShares, + premiumShares: premiumShares, + premiumOffsetRay: premiumOffsetRay, + drawnIndex: drawnIndex + }); + amount = bound(amount, 0, 1e40); + _mockUserDrawnShares(drawnShares); + _mockUserPremiumData(premiumShares, premiumOffsetRay); + + (uint256 drawnDebt, uint256 premiumDebtRay) = u.getDebt(drawnIndex); + + (uint256 restoredDrawnDebt, uint256 restoredPremiumDebtRay) = u.calculateRestoreAmount( + drawnIndex, + amount + ); + + if (amount >= drawnDebt + premiumDebtRay.fromRayUp()) { + assertEq(restoredDrawnDebt, drawnDebt); + assertEq(restoredPremiumDebtRay, premiumDebtRay); + } else if (amount < premiumDebtRay.fromRayUp()) { + assertEq(restoredDrawnDebt, 0); + assertEq(restoredPremiumDebtRay, amount.toRay()); + } else { + assertEq(restoredDrawnDebt, amount - premiumDebtRay.fromRayUp()); + assertEq(restoredPremiumDebtRay, premiumDebtRay); + } + } + + function test_calculateRestoreAmount() public { + (uint256 restoredDrawnDebt, uint256 restoredPremiumDebtRay) = u.calculateRestoreAmount( + DRAWN_INDEX, + 400e18 + ); + assertEq(restoredDrawnDebt, 151.5e18); + assertEq(restoredPremiumDebtRay, 2.485e47); + + _mockUserPremiumData(70e18, 0); + (restoredDrawnDebt, restoredPremiumDebtRay) = u.calculateRestoreAmount(1.75e27, 372.5e18); + assertEq(restoredDrawnDebt, 250e18); + assertEq(restoredPremiumDebtRay, 1.225e47); + } + + function _mockUserDrawnShares(uint256 drawnShares) internal { + ISpoke.UserPosition memory userPosition = u.getUserPosition(); + userPosition.drawnShares = drawnShares.toUint120(); + u.setUserPosition(userPosition); + } + + function _mockUserPremiumData(uint256 premiumShares, int256 premiumOffsetRay) internal { + ISpoke.UserPosition memory userPosition = u.getUserPosition(); + userPosition.premiumShares = premiumShares.toUint120(); + userPosition.premiumOffsetRay = premiumOffsetRay.toInt200(); + u.setUserPosition(userPosition); + } + + function _mockHubDrawnIndex(uint256 drawnIndex) internal { + vm.mockCall( + address(hub), + abi.encodeCall(IHubBase.getAssetDrawnIndex, (assetId)), + abi.encode(drawnIndex) + ); + } + + function _bound( + IHubBase.PremiumDelta memory premiumDelta + ) internal pure returns (IHubBase.PremiumDelta memory) { + premiumDelta.sharesDelta = bound(premiumDelta.sharesDelta, -PREMIUM_SHARES.toInt256(), 1e30); + premiumDelta.offsetRayDelta = bound(premiumDelta.offsetRayDelta, -1e30, 1e30); + return premiumDelta; + } + + function _bound( + uint256 premiumShares, + int256 premiumOffsetRay, + uint256 drawnIndex + ) internal pure returns (uint256, int256, uint256) { + drawnIndex = bound(drawnIndex, WadRayMath.RAY, 100 * WadRayMath.RAY); + premiumShares = bound(premiumShares, 0, 1e30); + premiumOffsetRay = bound(premiumOffsetRay, -1e30, (premiumShares * drawnIndex).toInt256()); + return (premiumShares, premiumOffsetRay, drawnIndex); + } + + function _bound( + uint256 drawnShares, + uint256 premiumShares, + int256 premiumOffsetRay, + uint256 drawnIndex + ) internal pure returns (uint256, uint256, int256, uint256) { + (premiumShares, premiumOffsetRay, drawnIndex) = _bound({ + premiumShares: premiumShares, + premiumOffsetRay: premiumOffsetRay, + drawnIndex: drawnIndex + }); + drawnShares = bound(drawnShares, 0, 1e30); + return (drawnShares, premiumShares, premiumOffsetRay, drawnIndex); + } + + function _bound(BoundParams memory params) internal pure returns (BoundParams memory) { + (params.drawnShares, params.premiumShares, params.premiumOffsetRay, params.drawnIndex) = _bound( + params.drawnShares, + params.premiumShares, + params.premiumOffsetRay, + params.drawnIndex + ); + params.drawnSharesTaken = bound(params.drawnSharesTaken, 0, params.drawnShares); + params.riskPremium = bound(params.riskPremium, 0, MAX_COLLATERAL_RISK_BPS); + params.restoredPremiumRay = bound( + params.restoredPremiumRay, + 0, + _calculatePremiumDebtRay(params.premiumShares, params.premiumOffsetRay, params.drawnIndex) + ); + return params; + } +} diff --git a/tests/unit/misc/EIP712Hash.t.sol b/tests/unit/misc/EIP712Hash.t.sol index 2a5b29148..3512d7f17 100644 --- a/tests/unit/misc/EIP712Hash.t.sol +++ b/tests/unit/misc/EIP712Hash.t.sol @@ -4,165 +4,256 @@ pragma solidity ^0.8.0; import {Test} from 'forge-std/Test.sol'; -import {EIP712Hash, EIP712Types} from 'src/position-manager/libraries/EIP712Hash.sol'; +import {ISignatureGateway} from 'src/position-manager/interfaces/ISignatureGateway.sol'; +import {ITokenizationSpoke} from 'src/spoke/interfaces/ITokenizationSpoke.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +import {EIP712Hash as PositionManagerEIP712Hash} from 'src/position-manager/libraries/EIP712Hash.sol'; +import {EIP712Hash as SpokeEIP712Hash} from 'src/spoke/libraries/EIP712Hash.sol'; contract EIP712HashTest is Test { - using EIP712Hash for *; + using PositionManagerEIP712Hash for *; + using SpokeEIP712Hash for *; function test_constants() public pure { assertEq( - EIP712Hash.SUPPLY_TYPEHASH, + PositionManagerEIP712Hash.SUPPLY_TYPEHASH, keccak256( 'Supply(address spoke,uint256 reserveId,uint256 amount,address onBehalfOf,uint256 nonce,uint256 deadline)' ) ); + assertEq(PositionManagerEIP712Hash.SUPPLY_TYPEHASH, vm.eip712HashType('Supply')); + assertEq( - EIP712Hash.WITHDRAW_TYPEHASH, + PositionManagerEIP712Hash.WITHDRAW_TYPEHASH, keccak256( 'Withdraw(address spoke,uint256 reserveId,uint256 amount,address onBehalfOf,uint256 nonce,uint256 deadline)' ) ); + assertEq(PositionManagerEIP712Hash.WITHDRAW_TYPEHASH, vm.eip712HashType('Withdraw')); + assertEq( - EIP712Hash.BORROW_TYPEHASH, + PositionManagerEIP712Hash.BORROW_TYPEHASH, keccak256( 'Borrow(address spoke,uint256 reserveId,uint256 amount,address onBehalfOf,uint256 nonce,uint256 deadline)' ) ); + assertEq(PositionManagerEIP712Hash.BORROW_TYPEHASH, vm.eip712HashType('Borrow')); + assertEq( - EIP712Hash.REPAY_TYPEHASH, + PositionManagerEIP712Hash.REPAY_TYPEHASH, keccak256( 'Repay(address spoke,uint256 reserveId,uint256 amount,address onBehalfOf,uint256 nonce,uint256 deadline)' ) ); + assertEq(PositionManagerEIP712Hash.REPAY_TYPEHASH, vm.eip712HashType('Repay')); + assertEq( - EIP712Hash.SET_USING_AS_COLLATERAL_TYPEHASH, + PositionManagerEIP712Hash.SET_USING_AS_COLLATERAL_TYPEHASH, keccak256( 'SetUsingAsCollateral(address spoke,uint256 reserveId,bool useAsCollateral,address onBehalfOf,uint256 nonce,uint256 deadline)' ) ); assertEq( - EIP712Hash.UPDATE_USER_RISK_PREMIUM_TYPEHASH, - keccak256('UpdateUserRiskPremium(address spoke,address user,uint256 nonce,uint256 deadline)') + PositionManagerEIP712Hash.SET_USING_AS_COLLATERAL_TYPEHASH, + vm.eip712HashType('SetUsingAsCollateral') ); + assertEq( - EIP712Hash.UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH, + PositionManagerEIP712Hash.UPDATE_USER_RISK_PREMIUM_TYPEHASH, keccak256( - 'UpdateUserDynamicConfig(address spoke,address user,uint256 nonce,uint256 deadline)' + 'UpdateUserRiskPremium(address spoke,address onBehalfOf,uint256 nonce,uint256 deadline)' ) ); - } + assertEq( + PositionManagerEIP712Hash.UPDATE_USER_RISK_PREMIUM_TYPEHASH, + vm.eip712HashType('UpdateUserRiskPremium') + ); - function test_hash_supply_fuzz(EIP712Types.Supply calldata params) public pure { - bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.SUPPLY_TYPEHASH, - params.spoke, - params.reserveId, - params.amount, - params.onBehalfOf, - params.nonce, - params.deadline + assertEq( + PositionManagerEIP712Hash.UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH, + keccak256( + 'UpdateUserDynamicConfig(address spoke,address onBehalfOf,uint256 nonce,uint256 deadline)' ) ); + assertEq( + PositionManagerEIP712Hash.UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH, + vm.eip712HashType('UpdateUserDynamicConfig') + ); + assertEq( + SpokeEIP712Hash.SET_USER_POSITION_MANAGERS_TYPEHASH, + keccak256( + 'SetUserPositionManagers(address onBehalfOf,PositionManagerUpdate[] updates,uint256 nonce,uint256 deadline)PositionManagerUpdate(address positionManager,bool approve)' + ) + ); + assertEq( + SpokeEIP712Hash.SET_USER_POSITION_MANAGERS_TYPEHASH, + vm.eip712HashType('SetUserPositionManagers') + ); + + assertEq( + SpokeEIP712Hash.POSITION_MANAGER_UPDATE, + keccak256('PositionManagerUpdate(address positionManager,bool approve)') + ); + assertEq(SpokeEIP712Hash.POSITION_MANAGER_UPDATE, vm.eip712HashType('PositionManagerUpdate')); + + assertEq( + SpokeEIP712Hash.TOKENIZED_DEPOSIT_TYPEHASH, + keccak256( + 'TokenizedDeposit(address depositor,uint256 assets,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + assertEq(SpokeEIP712Hash.TOKENIZED_DEPOSIT_TYPEHASH, vm.eip712HashType('TokenizedDeposit')); + + assertEq( + SpokeEIP712Hash.TOKENIZED_MINT_TYPEHASH, + keccak256( + 'TokenizedMint(address depositor,uint256 shares,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + assertEq(SpokeEIP712Hash.TOKENIZED_MINT_TYPEHASH, vm.eip712HashType('TokenizedMint')); + + assertEq( + SpokeEIP712Hash.TOKENIZED_WITHDRAW_TYPEHASH, + keccak256( + 'TokenizedWithdraw(address owner,uint256 assets,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + assertEq(SpokeEIP712Hash.TOKENIZED_WITHDRAW_TYPEHASH, vm.eip712HashType('TokenizedWithdraw')); + + assertEq( + SpokeEIP712Hash.TOKENIZED_REDEEM_TYPEHASH, + keccak256( + 'TokenizedRedeem(address owner,uint256 shares,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + assertEq(SpokeEIP712Hash.TOKENIZED_REDEEM_TYPEHASH, vm.eip712HashType('TokenizedRedeem')); + } + + // @dev all struct params should be hashed & placed in the same order as the typehash + function test_hash_supply_fuzz(ISignatureGateway.Supply calldata params) public pure { + bytes32 expectedHash = keccak256(abi.encode(PositionManagerEIP712Hash.SUPPLY_TYPEHASH, params)); assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('Supply', abi.encode(params))); } - function test_hash_withdraw_fuzz(EIP712Types.Withdraw calldata params) public pure { + function test_hash_withdraw_fuzz(ISignatureGateway.Withdraw calldata params) public pure { bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.WITHDRAW_TYPEHASH, - params.spoke, - params.reserveId, - params.amount, - params.onBehalfOf, - params.nonce, - params.deadline - ) + abi.encode(PositionManagerEIP712Hash.WITHDRAW_TYPEHASH, params) ); + assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('Withdraw', abi.encode(params))); + } + function test_hash_borrow_fuzz(ISignatureGateway.Borrow calldata params) public pure { + bytes32 expectedHash = keccak256(abi.encode(PositionManagerEIP712Hash.BORROW_TYPEHASH, params)); assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('Borrow', abi.encode(params))); } - function test_hash_borrow_fuzz(EIP712Types.Borrow calldata params) public pure { + function test_hash_repay_fuzz(ISignatureGateway.Repay calldata params) public pure { + bytes32 expectedHash = keccak256(abi.encode(PositionManagerEIP712Hash.REPAY_TYPEHASH, params)); + assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('Repay', abi.encode(params))); + } + + function test_hash_setUsingAsCollateral_fuzz( + ISignatureGateway.SetUsingAsCollateral calldata params + ) public pure { bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.BORROW_TYPEHASH, - params.spoke, - params.reserveId, - params.amount, - params.onBehalfOf, - params.nonce, - params.deadline - ) + abi.encode(PositionManagerEIP712Hash.SET_USING_AS_COLLATERAL_TYPEHASH, params) ); + assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('SetUsingAsCollateral', abi.encode(params))); + } + function test_hash_updateUserRiskPremium_fuzz( + ISignatureGateway.UpdateUserRiskPremium calldata params + ) public pure { + bytes32 expectedHash = keccak256( + abi.encode(PositionManagerEIP712Hash.UPDATE_USER_RISK_PREMIUM_TYPEHASH, params) + ); assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('UpdateUserRiskPremium', abi.encode(params))); } - function test_hash_repay_fuzz(EIP712Types.Repay calldata params) public pure { + function test_hash_updateUserDynamicConfig_fuzz( + ISignatureGateway.UpdateUserDynamicConfig calldata params + ) public pure { bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.REPAY_TYPEHASH, - params.spoke, - params.reserveId, - params.amount, - params.onBehalfOf, - params.nonce, - params.deadline - ) + abi.encode(PositionManagerEIP712Hash.UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH, params) ); + assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('UpdateUserDynamicConfig', abi.encode(params))); + } + function test_hash_tokenizedDeposit_fuzz( + ITokenizationSpoke.TokenizedDeposit calldata params + ) public pure { + bytes32 expectedHash = keccak256( + abi.encode(SpokeEIP712Hash.TOKENIZED_DEPOSIT_TYPEHASH, params) + ); assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('TokenizedDeposit', abi.encode(params))); } - function test_hash_setUsingAsCollateral_fuzz( - EIP712Types.SetUsingAsCollateral calldata params + function test_hash_tokenizedMint_fuzz( + ITokenizationSpoke.TokenizedMint calldata params + ) public pure { + bytes32 expectedHash = keccak256(abi.encode(SpokeEIP712Hash.TOKENIZED_MINT_TYPEHASH, params)); + assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('TokenizedMint', abi.encode(params))); + } + + function test_hash_tokenizedWithdraw_fuzz( + ITokenizationSpoke.TokenizedWithdraw calldata params ) public pure { bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.SET_USING_AS_COLLATERAL_TYPEHASH, - params.spoke, - params.reserveId, - params.useAsCollateral, - params.onBehalfOf, - params.nonce, - params.deadline - ) + abi.encode(SpokeEIP712Hash.TOKENIZED_WITHDRAW_TYPEHASH, params) ); + assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('TokenizedWithdraw', abi.encode(params))); + } + function test_hash_tokenizedRedeem_fuzz( + ITokenizationSpoke.TokenizedRedeem calldata params + ) public pure { + bytes32 expectedHash = keccak256(abi.encode(SpokeEIP712Hash.TOKENIZED_REDEEM_TYPEHASH, params)); assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('TokenizedRedeem', abi.encode(params))); } - function test_hash_updateUserRiskPremium_fuzz( - EIP712Types.UpdateUserRiskPremium calldata params + function test_hash_setUserPositionManagers_fuzz( + ISpoke.SetUserPositionManagers calldata params ) public pure { + bytes32[] memory updatesHashes = new bytes32[](params.updates.length); + for (uint256 i = 0; i < updatesHashes.length; ++i) { + updatesHashes[i] = params.updates[i].hash(); + } + bytes32 expectedHash = keccak256( abi.encode( - EIP712Hash.UPDATE_USER_RISK_PREMIUM_TYPEHASH, - params.spoke, - params.user, + SpokeEIP712Hash.SET_USER_POSITION_MANAGERS_TYPEHASH, + params.onBehalfOf, + keccak256(abi.encodePacked(updatesHashes)), params.nonce, params.deadline ) ); assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('SetUserPositionManagers', abi.encode(params))); } - function test_hash_updateUserDynamicConfig_fuzz( - EIP712Types.UpdateUserDynamicConfig calldata params + function test_hash_positionManagerUpdate_fuzz( + ISpoke.PositionManagerUpdate calldata params ) public pure { bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH, - params.spoke, - params.user, - params.nonce, - params.deadline - ) + abi.encode(SpokeEIP712Hash.POSITION_MANAGER_UPDATE, params.positionManager, params.approve) ); assertEq(params.hash(), expectedHash); + assertEq(params.hash(), vm.eip712HashStruct('PositionManagerUpdate', abi.encode(params))); } } diff --git a/tests/unit/misc/ExtSload.t.sol b/tests/unit/misc/ExtSload.t.sol new file mode 100644 index 000000000..b4f533c42 --- /dev/null +++ b/tests/unit/misc/ExtSload.t.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {Test} from 'forge-std/Test.sol'; +import {ExtSloadWrapper, ExtSload} from 'tests/mocks/ExtSloadWrapper.sol'; + +contract ExtSloadTest is Test { + ExtSloadWrapper internal w; + + function setUp() public { + w = new ExtSloadWrapper(); + } + + function test_extSload(bytes32) public { + vm.setArbitraryStorage(address(w)); + bytes32 slot = bytes32(vm.randomUint()); + assertEq(w.extSload(slot), vm.load(address(w), slot)); + } + + function test_extSloads(uint256 count) public { + count = bound(count, 0, 1024); // for performance + vm.setArbitraryStorage(address(w)); + + bytes32[] memory slots = new bytes32[](count); + for (uint256 i; i < count; ++i) { + slots[i] = bytes32(vm.randomUint()); + } + + bytes32[] memory values = w.extSloads(slots); + assertEq(values.length, count); + for (uint256 i; i < count; ++i) { + assertEq(values[i], vm.load(address(w), slots[i])); + } + } + + function test_extSloads(uint256 count, bytes memory dirty) public { + count = bound(count, 0, 1024); // for performance + + bytes32[] memory slots = new bytes32[](count); + bytes32[] memory values = new bytes32[](count); + for (uint256 i; i < count; ++i) { + slots[i] = bytes32(vm.randomUint()); + values[i] = bytes32(vm.randomUint()); + vm.store(address(w), slots[i], values[i]); + } + + bytes memory malformed = bytes.concat(abi.encodeCall(ExtSload.extSloads, (slots)), dirty); + (bool ok, bytes memory ret) = address(w).staticcall(malformed); + + assertTrue(ok); + assertEq(ret, abi.encode(values)); + } +} diff --git a/tests/unit/misc/NativeTokenGateway.t.sol b/tests/unit/misc/NativeTokenGateway.t.sol index 7835e1ac2..dd6105774 100644 --- a/tests/unit/misc/NativeTokenGateway.t.sol +++ b/tests/unit/misc/NativeTokenGateway.t.sol @@ -27,10 +27,8 @@ contract NativeTokenGatewayTest is SpokeBase { NativeTokenGateway gateway = new NativeTokenGateway(address(tokenList.weth), address(ADMIN)); assertEq(gateway.NATIVE_WRAPPER(), address(tokenList.weth)); - assertEq(gateway.owner(), address(ADMIN)); assertEq(gateway.pendingOwner(), address(0)); - assertEq(gateway.rescueGuardian(), address(ADMIN)); } @@ -84,6 +82,47 @@ contract NativeTokenGatewayTest is SpokeBase { assertFalse(_isUsingAsCollateral(spoke1, _wethReserveId(spoke1), bob)); } + function test_supplyNative_revertsWith_ReentrancyGuardReentrantCall_spokeSupply() public { + vm.prank(bob); + spoke1.setUserPositionManager(address(nativeTokenGateway), true); + + uint256 amount = 100e18; + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(nativeTokenGateway), + INativeTokenGateway.supplyNative.selector + ); + + vm.mockFunction( + address(spoke1), + address(reentrantCaller), + abi.encodeWithSelector(ISpokeBase.supply.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + nativeTokenGateway.supplyNative{value: amount}(address(spoke1), _wethReserveId(spoke1), amount); + } + + function test_supplyNative_revertsWith_ReentrancyGuardReentrantCall_hubAdd() public { + vm.prank(bob); + spoke1.setUserPositionManager(address(nativeTokenGateway), true); + + uint256 amount = 100e18; + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(nativeTokenGateway), + INativeTokenGateway.supplyNative.selector + ); + + vm.mockFunction( + address(_hub(spoke1, _wethReserveId(spoke1))), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.add.selector) + ); + + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + nativeTokenGateway.supplyNative{value: amount}(address(spoke1), _wethReserveId(spoke1), amount); + } + function test_supplyNative_revertsWith_SpokeNotRegistered() public { uint256 amount = 100e18; vm.expectRevert(IGatewayBase.SpokeNotRegistered.selector); @@ -333,6 +372,46 @@ contract NativeTokenGatewayTest is SpokeBase { _checkFinalBalances(); } + function test_withdrawNative_revertsWith_ReentrancyGuardReentrantCall_spokeWithdraw() public { + vm.prank(bob); + spoke1.setUserPositionManager(address(nativeTokenGateway), true); + + uint256 amount = 100e18; + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(nativeTokenGateway), + INativeTokenGateway.withdrawNative.selector + ); + + vm.mockFunction( + address(spoke1), + address(reentrantCaller), + abi.encodeWithSelector(ISpokeBase.withdraw.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + nativeTokenGateway.withdrawNative(address(spoke1), _wethReserveId(spoke1), amount); + } + + function test_withdrawNative_revertsWith_ReentrancyGuardReentrantCall_hubRemove() public { + vm.prank(bob); + spoke1.setUserPositionManager(address(nativeTokenGateway), true); + + uint256 amount = 100e18; + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(nativeTokenGateway), + INativeTokenGateway.withdrawNative.selector + ); + + vm.mockFunction( + address(_hub(spoke1, _wethReserveId(spoke1))), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.remove.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + nativeTokenGateway.withdrawNative(address(spoke1), _wethReserveId(spoke1), amount); + } + function test_withdrawNative_revertsWith_SpokeNotRegistered() public { uint256 amount = 100e18; vm.expectRevert(IGatewayBase.SpokeNotRegistered.selector); @@ -405,6 +484,46 @@ contract NativeTokenGatewayTest is SpokeBase { _checkFinalBalances(); } + function test_borrowNative_revertsWith_ReentrancyGuardReentrantCall_spokeBorrow() public { + vm.prank(bob); + spoke1.setUserPositionManager(address(nativeTokenGateway), true); + + uint256 amount = 100e18; + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(nativeTokenGateway), + INativeTokenGateway.borrowNative.selector + ); + + vm.mockFunction( + address(spoke1), + address(reentrantCaller), + abi.encodeWithSelector(ISpokeBase.borrow.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + nativeTokenGateway.borrowNative(address(spoke1), _wethReserveId(spoke1), amount); + } + + function test_borrowNative_revertsWith_ReentrancyGuardReentrantCall_hubDraw() public { + vm.prank(bob); + spoke1.setUserPositionManager(address(nativeTokenGateway), true); + + uint256 amount = 100e18; + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(nativeTokenGateway), + INativeTokenGateway.borrowNative.selector + ); + + vm.mockFunction( + address(_hub(spoke1, _wethReserveId(spoke1))), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.draw.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + nativeTokenGateway.borrowNative(address(spoke1), _wethReserveId(spoke1), amount); + } + function test_borrowNative_revertsWith_SpokeNotRegistered() public { uint256 amount = 100e18; vm.expectRevert(IGatewayBase.SpokeNotRegistered.selector); @@ -630,6 +749,46 @@ contract NativeTokenGatewayTest is SpokeBase { _checkFinalBalances(); } + function test_repayNative_revertsWith_ReentrancyGuardReentrantCall_spokeRepay() public { + vm.prank(bob); + spoke1.setUserPositionManager(address(nativeTokenGateway), true); + + uint256 amount = 100e18; + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(nativeTokenGateway), + INativeTokenGateway.repayNative.selector + ); + + vm.mockFunction( + address(spoke1), + address(reentrantCaller), + abi.encodeWithSelector(ISpokeBase.repay.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + nativeTokenGateway.repayNative{value: amount}(address(spoke1), _wethReserveId(spoke1), amount); + } + + function test_repayNative_revertsWith_ReentrancyGuardReentrantCall_hubRestore() public { + vm.prank(bob); + spoke1.setUserPositionManager(address(nativeTokenGateway), true); + + uint256 amount = 100e18; + MockReentrantCaller reentrantCaller = new MockReentrantCaller( + address(nativeTokenGateway), + INativeTokenGateway.repayNative.selector + ); + + vm.mockFunction( + address(_hub(spoke1, _wethReserveId(spoke1))), + address(reentrantCaller), + abi.encodeWithSelector(IHubBase.restore.selector) + ); + vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); + vm.prank(bob); + nativeTokenGateway.repayNative{value: amount}(address(spoke1), _wethReserveId(spoke1), amount); + } + function test_repayNative_revertsWith_SpokeNotRegistered() public { uint256 repayAmount = 5e18; diff --git a/tests/unit/misc/SignatureGateway/SignatureGateway.Base.t.sol b/tests/unit/misc/SignatureGateway/SignatureGateway.Base.t.sol index 7fad83c12..4bb9d612a 100644 --- a/tests/unit/misc/SignatureGateway/SignatureGateway.Base.t.sol +++ b/tests/unit/misc/SignatureGateway/SignatureGateway.Base.t.sol @@ -6,30 +6,23 @@ import 'tests/unit/Spoke/SpokeBase.t.sol'; contract SignatureGatewayBaseTest is SpokeBase { ISignatureGateway public gateway; - uint256 public alicePk; function setUp() public virtual override { deployFixtures(); initEnvironment(); gateway = ISignatureGateway(new SignatureGateway(ADMIN)); - (alice, alicePk) = makeAddrAndKey('alice'); vm.prank(address(ADMIN)); gateway.registerSpoke(address(spoke1), true); } - function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); - return abi.encodePacked(r, s, v); - } - function _supplyData( ISpoke spoke, address who, uint256 deadline - ) internal returns (EIP712Types.Supply memory) { + ) internal returns (ISignatureGateway.Supply memory) { return - EIP712Types.Supply({ + ISignatureGateway.Supply({ spoke: address(spoke), reserveId: _randomReserveId(spoke), amount: vm.randomUint(1, MAX_SUPPLY_AMOUNT), @@ -43,9 +36,9 @@ contract SignatureGatewayBaseTest is SpokeBase { ISpoke spoke, address who, uint256 deadline - ) internal returns (EIP712Types.Withdraw memory) { + ) internal returns (ISignatureGateway.Withdraw memory) { return - EIP712Types.Withdraw({ + ISignatureGateway.Withdraw({ spoke: address(spoke), reserveId: _randomReserveId(spoke), amount: vm.randomUint(1, MAX_SUPPLY_AMOUNT), @@ -59,9 +52,9 @@ contract SignatureGatewayBaseTest is SpokeBase { ISpoke spoke, address who, uint256 deadline - ) internal returns (EIP712Types.Borrow memory) { + ) internal returns (ISignatureGateway.Borrow memory) { return - EIP712Types.Borrow({ + ISignatureGateway.Borrow({ spoke: address(spoke), reserveId: _randomReserveId(spoke), amount: vm.randomUint(1, MAX_SUPPLY_AMOUNT), @@ -75,9 +68,9 @@ contract SignatureGatewayBaseTest is SpokeBase { ISpoke spoke, address who, uint256 deadline - ) internal returns (EIP712Types.Repay memory) { + ) internal returns (ISignatureGateway.Repay memory) { return - EIP712Types.Repay({ + ISignatureGateway.Repay({ spoke: address(spoke), reserveId: _randomReserveId(spoke), amount: vm.randomUint(1, MAX_SUPPLY_AMOUNT), @@ -91,9 +84,9 @@ contract SignatureGatewayBaseTest is SpokeBase { ISpoke spoke, address who, uint256 deadline - ) internal returns (EIP712Types.SetUsingAsCollateral memory) { + ) internal returns (ISignatureGateway.SetUsingAsCollateral memory) { return - EIP712Types.SetUsingAsCollateral({ + ISignatureGateway.SetUsingAsCollateral({ spoke: address(spoke), reserveId: _randomReserveId(spoke), useAsCollateral: vm.randomBool(), @@ -107,11 +100,11 @@ contract SignatureGatewayBaseTest is SpokeBase { ISpoke spoke, address user, uint256 deadline - ) internal returns (EIP712Types.UpdateUserRiskPremium memory) { + ) internal returns (ISignatureGateway.UpdateUserRiskPremium memory) { return - EIP712Types.UpdateUserRiskPremium({ + ISignatureGateway.UpdateUserRiskPremium({ spoke: address(spoke), - user: user, + onBehalfOf: user, nonce: gateway.nonces(user, _randomNonceKey()), deadline: deadline }); @@ -121,11 +114,11 @@ contract SignatureGatewayBaseTest is SpokeBase { ISpoke spoke, address user, uint256 deadline - ) internal returns (EIP712Types.UpdateUserDynamicConfig memory) { + ) internal returns (ISignatureGateway.UpdateUserDynamicConfig memory) { return - EIP712Types.UpdateUserDynamicConfig({ + ISignatureGateway.UpdateUserDynamicConfig({ spoke: address(spoke), - user: user, + onBehalfOf: user, nonce: gateway.nonces(user, _randomNonceKey()), deadline: deadline }); @@ -133,35 +126,35 @@ contract SignatureGatewayBaseTest is SpokeBase { function _getTypedDataHash( ISignatureGateway _gateway, - EIP712Types.Supply memory _params + ISignatureGateway.Supply memory _params ) internal view returns (bytes32) { return _typedDataHash(_gateway, vm.eip712HashStruct('Supply', abi.encode(_params))); } function _getTypedDataHash( ISignatureGateway _gateway, - EIP712Types.Withdraw memory _params + ISignatureGateway.Withdraw memory _params ) internal view returns (bytes32) { return _typedDataHash(_gateway, vm.eip712HashStruct('Withdraw', abi.encode(_params))); } function _getTypedDataHash( ISignatureGateway _gateway, - EIP712Types.Borrow memory _params + ISignatureGateway.Borrow memory _params ) internal view returns (bytes32) { return _typedDataHash(_gateway, vm.eip712HashStruct('Borrow', abi.encode(_params))); } function _getTypedDataHash( ISignatureGateway _gateway, - EIP712Types.Repay memory _params + ISignatureGateway.Repay memory _params ) internal view returns (bytes32) { return _typedDataHash(_gateway, vm.eip712HashStruct('Repay', abi.encode(_params))); } function _getTypedDataHash( ISignatureGateway _gateway, - EIP712Types.SetUsingAsCollateral memory _params + ISignatureGateway.SetUsingAsCollateral memory _params ) internal view returns (bytes32) { return _typedDataHash(_gateway, vm.eip712HashStruct('SetUsingAsCollateral', abi.encode(_params))); @@ -169,7 +162,7 @@ contract SignatureGatewayBaseTest is SpokeBase { function _getTypedDataHash( ISignatureGateway _gateway, - EIP712Types.UpdateUserRiskPremium memory _params + ISignatureGateway.UpdateUserRiskPremium memory _params ) internal view returns (bytes32) { return _typedDataHash(_gateway, vm.eip712HashStruct('UpdateUserRiskPremium', abi.encode(_params))); @@ -177,7 +170,7 @@ contract SignatureGatewayBaseTest is SpokeBase { function _getTypedDataHash( ISignatureGateway _gateway, - EIP712Types.UpdateUserDynamicConfig memory _params + ISignatureGateway.UpdateUserDynamicConfig memory _params ) internal view returns (bytes32) { return _typedDataHash(_gateway, vm.eip712HashStruct('UpdateUserDynamicConfig', abi.encode(_params))); @@ -194,11 +187,13 @@ contract SignatureGatewayBaseTest is SpokeBase { ISpoke spoke, ISignatureGateway _gateway, address who - ) internal view { + ) internal { for (uint256 reserveId; reserveId < spoke.getReserveCount(); ++reserveId) { - IERC20 underlying = _underlying(spoke, reserveId); - assertEq(underlying.balanceOf(address(_gateway)), 0); - assertEq(underlying.allowance({owner: who, spender: address(_gateway)}), 0); + _assertEntityHasNoBalanceOrAllowance({ + underlying: _underlying(spoke, reserveId), + entity: address(_gateway), + user: who + }); } } diff --git a/tests/unit/misc/SignatureGateway/SignatureGateway.Constants.t.sol b/tests/unit/misc/SignatureGateway/SignatureGateway.Constants.t.sol index 0783683ab..eb8fc2087 100644 --- a/tests/unit/misc/SignatureGateway/SignatureGateway.Constants.t.sol +++ b/tests/unit/misc/SignatureGateway/SignatureGateway.Constants.t.sol @@ -112,7 +112,9 @@ contract SignatureGatewayConstantsTest is SignatureGatewayBaseTest { ); assertEq( gateway.UPDATE_USER_RISK_PREMIUM_TYPEHASH(), - keccak256('UpdateUserRiskPremium(address spoke,address user,uint256 nonce,uint256 deadline)') + keccak256( + 'UpdateUserRiskPremium(address spoke,address onBehalfOf,uint256 nonce,uint256 deadline)' + ) ); } @@ -124,7 +126,7 @@ contract SignatureGatewayConstantsTest is SignatureGatewayBaseTest { assertEq( gateway.UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH(), keccak256( - 'UpdateUserDynamicConfig(address spoke,address user,uint256 nonce,uint256 deadline)' + 'UpdateUserDynamicConfig(address spoke,address onBehalfOf,uint256 nonce,uint256 deadline)' ) ); } diff --git a/tests/unit/misc/SignatureGateway/SignatureGateway.PermitReserve.t.sol b/tests/unit/misc/SignatureGateway/SignatureGateway.PermitReserve.t.sol index 73e5ca927..bfb4ab39f 100644 --- a/tests/unit/misc/SignatureGateway/SignatureGateway.PermitReserve.t.sol +++ b/tests/unit/misc/SignatureGateway/SignatureGateway.PermitReserve.t.sol @@ -77,7 +77,7 @@ contract SignatureGatewayPermitReserveTest is SignatureGatewayBaseTest { function test_permitReserve() public { (address user, uint256 userPk) = makeAddrAndKey('user'); - uint256 reserveId = _randomReserveId(spoke1); + uint256 reserveId = _daiReserveId(spoke1); TestnetERC20 token = TestnetERC20(address(_underlying(spoke1, reserveId))); assertEq(token.allowance(user, address(gateway)), 0); diff --git a/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.InsufficientAllowance.t.sol b/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.InsufficientAllowance.t.sol index 82892cfc3..54cbf1d5b 100644 --- a/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.InsufficientAllowance.t.sol +++ b/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.InsufficientAllowance.t.sol @@ -20,18 +20,26 @@ contract SignatureGateway_InsufficientAllowance_Test is SignatureGatewayBaseTest function test_supplyWithSig_revertsWith_ERC20InsufficientAllowance() public { uint256 deadline = _warpBeforeRandomDeadline(); - EIP712Types.Supply memory p = _supplyData(spoke1, alice, deadline); + ISignatureGateway.Supply memory p = _supplyData(spoke1, alice, deadline); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); - vm.expectRevert( - abi.encodeWithSelector( - IERC20Errors.ERC20InsufficientAllowance.selector, - address(gateway), - 0, - p.amount, - address(_underlying(spoke1, p.reserveId)) - ) - ); + address underlying = ISpoke(p.spoke).getReserve(p.reserveId).underlying; + assertTrue(IERC20(underlying).allowance(alice, address(gateway)) < p.amount); + + if (underlying == address(tokenList.weth)) { + // WETH9 reverts with no data on insufficient allowance + vm.expectRevert(); + } else { + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(gateway), + 0, + p.amount + ) + ); + } + vm.prank(vm.randomAddress()); gateway.supplyWithSig(p, signature); } @@ -42,7 +50,7 @@ contract SignatureGateway_InsufficientAllowance_Test is SignatureGatewayBaseTest uint256 deadline = _warpBeforeRandomDeadline(); - EIP712Types.Repay memory p = _repayData(spoke1, alice, deadline); + ISignatureGateway.Repay memory p = _repayData(spoke1, alice, deadline); p.reserveId = _daiReserveId(spoke1); p.amount = 50e18; bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); diff --git a/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.InvalidSignature.t.sol b/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.InvalidSignature.t.sol index 2bf1c37a2..5d1f67d1e 100644 --- a/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.InvalidSignature.t.sol +++ b/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.InvalidSignature.t.sol @@ -6,37 +6,37 @@ import 'tests/unit/misc/SignatureGateway/SignatureGateway.Base.t.sol'; contract SignatureGatewayInvalidSignatureTest is SignatureGatewayBaseTest { function test_supplyWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { - EIP712Types.Supply memory p = _supplyData(spoke1, alice, _warpAfterRandomDeadline()); + ISignatureGateway.Supply memory p = _supplyData(spoke1, alice, _warpAfterRandomDeadline()); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.supplyWithSig(p, signature); } function test_withdrawWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { - EIP712Types.Withdraw memory p = _withdrawData(spoke1, alice, _warpAfterRandomDeadline()); + ISignatureGateway.Withdraw memory p = _withdrawData(spoke1, alice, _warpAfterRandomDeadline()); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.withdrawWithSig(p, signature); } function test_borrowWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { - EIP712Types.Borrow memory p = _borrowData(spoke1, alice, _warpAfterRandomDeadline()); + ISignatureGateway.Borrow memory p = _borrowData(spoke1, alice, _warpAfterRandomDeadline()); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.borrowWithSig(p, signature); } function test_repayWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { - EIP712Types.Repay memory p = _repayData(spoke1, alice, _warpAfterRandomDeadline()); + ISignatureGateway.Repay memory p = _repayData(spoke1, alice, _warpAfterRandomDeadline()); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.repayWithSig(p, signature); } @@ -45,10 +45,10 @@ contract SignatureGatewayInvalidSignatureTest is SignatureGatewayBaseTest { public { uint256 deadline = _warpAfterRandomDeadline(); - EIP712Types.SetUsingAsCollateral memory p = _setAsCollateralData(spoke1, alice, deadline); + ISignatureGateway.SetUsingAsCollateral memory p = _setAsCollateralData(spoke1, alice, deadline); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.setUsingAsCollateralWithSig(p, signature); } @@ -57,10 +57,14 @@ contract SignatureGatewayInvalidSignatureTest is SignatureGatewayBaseTest { public { uint256 deadline = _warpAfterRandomDeadline(); - EIP712Types.UpdateUserRiskPremium memory p = _updateRiskPremiumData(spoke1, alice, deadline); + ISignatureGateway.UpdateUserRiskPremium memory p = _updateRiskPremiumData( + spoke1, + alice, + deadline + ); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.updateUserRiskPremiumWithSig(p, signature); } @@ -68,66 +72,66 @@ contract SignatureGatewayInvalidSignatureTest is SignatureGatewayBaseTest { function test_updateUserDynamicConfigWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { - EIP712Types.UpdateUserDynamicConfig memory p = _updateDynamicConfigData( + ISignatureGateway.UpdateUserDynamicConfig memory p = _updateDynamicConfigData( spoke1, alice, _warpAfterRandomDeadline() ); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.updateUserDynamicConfigWithSig(p, signature); } function test_supplyWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); - address onBehalfOf = vm.randomAddress(); - while (onBehalfOf == randomUser) onBehalfOf = vm.randomAddress(); + address onBehalfOf = _randomAddressOmit(randomUser); - EIP712Types.Supply memory p = _supplyData(spoke1, onBehalfOf, _warpAfterRandomDeadline()); + ISignatureGateway.Supply memory p = _supplyData(spoke1, onBehalfOf, _warpAfterRandomDeadline()); bytes memory signature = _sign(randomUserPk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.supplyWithSig(p, signature); } function test_withdrawWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); - address onBehalfOf = vm.randomAddress(); - while (onBehalfOf == randomUser) onBehalfOf = vm.randomAddress(); + address onBehalfOf = _randomAddressOmit(randomUser); - EIP712Types.Withdraw memory p = _withdrawData(spoke1, onBehalfOf, _warpAfterRandomDeadline()); + ISignatureGateway.Withdraw memory p = _withdrawData( + spoke1, + onBehalfOf, + _warpAfterRandomDeadline() + ); bytes memory signature = _sign(randomUserPk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.withdrawWithSig(p, signature); } function test_borrowWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); - address onBehalfOf = vm.randomAddress(); - while (onBehalfOf == randomUser) onBehalfOf = vm.randomAddress(); + address onBehalfOf = _randomAddressOmit(randomUser); - EIP712Types.Borrow memory p = _borrowData(spoke1, onBehalfOf, _warpAfterRandomDeadline()); + ISignatureGateway.Borrow memory p = _borrowData(spoke1, onBehalfOf, _warpAfterRandomDeadline()); bytes memory signature = _sign(randomUserPk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.borrowWithSig(p, signature); } function test_repayWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); - address onBehalfOf = vm.randomAddress(); - while (onBehalfOf == randomUser) onBehalfOf = vm.randomAddress(); + address onBehalfOf = _randomAddressOmit(randomUser); - EIP712Types.Repay memory p = _repayData(spoke1, onBehalfOf, _warpAfterRandomDeadline()); + ISignatureGateway.Repay memory p = _repayData(spoke1, onBehalfOf, _warpAfterRandomDeadline()); bytes memory signature = _sign(randomUserPk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.repayWithSig(p, signature); } @@ -136,14 +140,17 @@ contract SignatureGatewayInvalidSignatureTest is SignatureGatewayBaseTest { public { (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); - address onBehalfOf = vm.randomAddress(); - while (onBehalfOf == randomUser) onBehalfOf = vm.randomAddress(); + address onBehalfOf = _randomAddressOmit(randomUser); uint256 deadline = _warpAfterRandomDeadline(); - EIP712Types.SetUsingAsCollateral memory p = _setAsCollateralData(spoke1, onBehalfOf, deadline); + ISignatureGateway.SetUsingAsCollateral memory p = _setAsCollateralData( + spoke1, + onBehalfOf, + deadline + ); bytes memory signature = _sign(randomUserPk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.setUsingAsCollateralWithSig(p, signature); } @@ -152,14 +159,17 @@ contract SignatureGatewayInvalidSignatureTest is SignatureGatewayBaseTest { public { (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); - address user = vm.randomAddress(); - while (user == randomUser) user = vm.randomAddress(); + address user = _randomAddressOmit(randomUser); uint256 deadline = _warpAfterRandomDeadline(); - EIP712Types.UpdateUserRiskPremium memory p = _updateRiskPremiumData(spoke1, user, deadline); + ISignatureGateway.UpdateUserRiskPremium memory p = _updateRiskPremiumData( + spoke1, + user, + deadline + ); bytes memory signature = _sign(randomUserPk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.updateUserRiskPremiumWithSig(p, signature); } @@ -168,20 +178,23 @@ contract SignatureGatewayInvalidSignatureTest is SignatureGatewayBaseTest { public { (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); - address user = vm.randomAddress(); - while (user == randomUser) user = vm.randomAddress(); + address user = _randomAddressOmit(randomUser); uint256 deadline = _warpAfterRandomDeadline(); - EIP712Types.UpdateUserDynamicConfig memory p = _updateDynamicConfigData(spoke1, user, deadline); + ISignatureGateway.UpdateUserDynamicConfig memory p = _updateDynamicConfigData( + spoke1, + user, + deadline + ); bytes memory signature = _sign(randomUserPk, _getTypedDataHash(gateway, p)); - vm.expectRevert(ISpoke.InvalidSignature.selector); + vm.expectRevert(IIntentConsumer.InvalidSignature.selector); vm.prank(vm.randomAddress()); gateway.updateUserDynamicConfigWithSig(p, signature); } function test_supplyWithSig_revertsWith_InvalidAccountNonce(bytes32) public { - EIP712Types.Supply memory p = _supplyData(spoke1, alice, _warpBeforeRandomDeadline()); + ISignatureGateway.Supply memory p = _supplyData(spoke1, alice, _warpBeforeRandomDeadline()); uint192 nonceKey = _randomNonceKey(); uint256 currentNonce = _burnRandomNoncesAtKey(gateway, p.onBehalfOf, nonceKey); p.nonce = _getRandomInvalidNonceAtKey(gateway, p.onBehalfOf, nonceKey); @@ -196,7 +209,7 @@ contract SignatureGatewayInvalidSignatureTest is SignatureGatewayBaseTest { } function test_withdrawWithSig_revertsWith_InvalidAccountNonce(bytes32) public { - EIP712Types.Withdraw memory p = _withdrawData(spoke1, alice, _warpBeforeRandomDeadline()); + ISignatureGateway.Withdraw memory p = _withdrawData(spoke1, alice, _warpBeforeRandomDeadline()); uint192 nonceKey = _randomNonceKey(); uint256 currentNonce = _burnRandomNoncesAtKey(gateway, p.onBehalfOf, nonceKey); p.nonce = _getRandomInvalidNonceAtKey(gateway, p.onBehalfOf, nonceKey); @@ -211,7 +224,7 @@ contract SignatureGatewayInvalidSignatureTest is SignatureGatewayBaseTest { } function test_borrowWithSig_revertsWith_InvalidAccountNonce(bytes32) public { - EIP712Types.Borrow memory p = _borrowData(spoke1, alice, _warpBeforeRandomDeadline()); + ISignatureGateway.Borrow memory p = _borrowData(spoke1, alice, _warpBeforeRandomDeadline()); uint192 nonceKey = _randomNonceKey(); uint256 currentNonce = _burnRandomNoncesAtKey(gateway, p.onBehalfOf, nonceKey); p.nonce = _getRandomInvalidNonceAtKey(gateway, p.onBehalfOf, nonceKey); @@ -226,7 +239,7 @@ contract SignatureGatewayInvalidSignatureTest is SignatureGatewayBaseTest { } function test_repayWithSig_revertsWith_InvalidAccountNonce(bytes32) public { - EIP712Types.Repay memory p = _repayData(spoke1, alice, _warpBeforeRandomDeadline()); + ISignatureGateway.Repay memory p = _repayData(spoke1, alice, _warpBeforeRandomDeadline()); uint192 nonceKey = _randomNonceKey(); uint256 currentNonce = _burnRandomNoncesAtKey(gateway, p.onBehalfOf, nonceKey); p.nonce = _getRandomInvalidNonceAtKey(gateway, p.onBehalfOf, nonceKey); @@ -242,7 +255,7 @@ contract SignatureGatewayInvalidSignatureTest is SignatureGatewayBaseTest { function test_setUsingAsCollateralWithSig_revertsWith_InvalidAccountNonce(bytes32) public { uint256 deadline = _warpBeforeRandomDeadline(); - EIP712Types.SetUsingAsCollateral memory p = _setAsCollateralData(spoke1, alice, deadline); + ISignatureGateway.SetUsingAsCollateral memory p = _setAsCollateralData(spoke1, alice, deadline); uint192 nonceKey = _randomNonceKey(); uint256 currentNonce = _burnRandomNoncesAtKey(gateway, p.onBehalfOf, nonceKey); p.nonce = _getRandomInvalidNonceAtKey(gateway, p.onBehalfOf, nonceKey); @@ -258,34 +271,38 @@ contract SignatureGatewayInvalidSignatureTest is SignatureGatewayBaseTest { function test_updateUserRiskPremiumWithSig_revertsWith_InvalidAccountNonce(bytes32) public { uint256 deadline = _warpBeforeRandomDeadline(); - EIP712Types.UpdateUserRiskPremium memory p = _updateRiskPremiumData(spoke1, alice, deadline); + ISignatureGateway.UpdateUserRiskPremium memory p = _updateRiskPremiumData( + spoke1, + alice, + deadline + ); uint192 nonceKey = _randomNonceKey(); - uint256 currentNonce = _burnRandomNoncesAtKey(gateway, p.user, nonceKey); - p.nonce = _getRandomInvalidNonceAtKey(gateway, p.user, nonceKey); + uint256 currentNonce = _burnRandomNoncesAtKey(gateway, p.onBehalfOf, nonceKey); + p.nonce = _getRandomInvalidNonceAtKey(gateway, p.onBehalfOf, nonceKey); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); vm.expectRevert( - abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, p.user, currentNonce) + abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, p.onBehalfOf, currentNonce) ); vm.prank(vm.randomAddress()); gateway.updateUserRiskPremiumWithSig(p, signature); } function test_updateUserDynamicConfigWithSig_revertsWith_InvalidAccountNonce(bytes32) public { - EIP712Types.UpdateUserDynamicConfig memory p = _updateDynamicConfigData( + ISignatureGateway.UpdateUserDynamicConfig memory p = _updateDynamicConfigData( spoke1, alice, _warpBeforeRandomDeadline() ); uint192 nonceKey = _randomNonceKey(); - uint256 currentNonce = _burnRandomNoncesAtKey(gateway, p.user, nonceKey); - p.nonce = _getRandomInvalidNonceAtKey(gateway, p.user, nonceKey); + uint256 currentNonce = _burnRandomNoncesAtKey(gateway, p.onBehalfOf, nonceKey); + p.nonce = _getRandomInvalidNonceAtKey(gateway, p.onBehalfOf, nonceKey); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); vm.expectRevert( - abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, p.user, currentNonce) + abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, p.onBehalfOf, currentNonce) ); vm.prank(vm.randomAddress()); gateway.updateUserDynamicConfigWithSig(p, signature); diff --git a/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.SpokeNotRegistered.t.sol b/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.SpokeNotRegistered.t.sol index bb542f4a8..cd9ad6eeb 100644 --- a/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.SpokeNotRegistered.t.sol +++ b/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.SpokeNotRegistered.t.sol @@ -20,7 +20,9 @@ contract SignatureGateway_SpokeNotRegistered_Test is SignatureGatewayBaseTest { assertFalse(gateway.isSpokeRegistered(address(spoke1))); } - function test_supplyWithSig_revertsWith_SpokeNotRegistered(EIP712Types.Supply memory p) public { + function test_supplyWithSig_revertsWith_SpokeNotRegistered( + ISignatureGateway.Supply memory p + ) public { bytes memory signature = vm.randomBytes(32); vm.expectRevert(IGatewayBase.SpokeNotRegistered.selector); @@ -29,7 +31,7 @@ contract SignatureGateway_SpokeNotRegistered_Test is SignatureGatewayBaseTest { } function test_withdrawWithSig_revertsWith_SpokeNotRegistered( - EIP712Types.Withdraw memory p + ISignatureGateway.Withdraw memory p ) public { bytes memory signature = vm.randomBytes(32); @@ -38,7 +40,9 @@ contract SignatureGateway_SpokeNotRegistered_Test is SignatureGatewayBaseTest { gateway.withdrawWithSig(p, signature); } - function test_borrowWithSig_revertsWith_SpokeNotRegistered(EIP712Types.Borrow memory p) public { + function test_borrowWithSig_revertsWith_SpokeNotRegistered( + ISignatureGateway.Borrow memory p + ) public { bytes memory signature = vm.randomBytes(32); vm.expectRevert(IGatewayBase.SpokeNotRegistered.selector); @@ -46,7 +50,9 @@ contract SignatureGateway_SpokeNotRegistered_Test is SignatureGatewayBaseTest { gateway.borrowWithSig(p, signature); } - function test_repayWithSig_revertsWith_SpokeNotRegistered(EIP712Types.Repay memory p) public { + function test_repayWithSig_revertsWith_SpokeNotRegistered( + ISignatureGateway.Repay memory p + ) public { bytes memory signature = vm.randomBytes(32); vm.expectRevert(IGatewayBase.SpokeNotRegistered.selector); @@ -55,7 +61,7 @@ contract SignatureGateway_SpokeNotRegistered_Test is SignatureGatewayBaseTest { } function test_setUsingAsCollateralWithSig_revertsWith_SpokeNotRegistered( - EIP712Types.SetUsingAsCollateral memory p + ISignatureGateway.SetUsingAsCollateral memory p ) public { bytes memory signature = vm.randomBytes(32); @@ -65,7 +71,7 @@ contract SignatureGateway_SpokeNotRegistered_Test is SignatureGatewayBaseTest { } function test_updateUserRiskPremiumWithSig_revertsWith_SpokeNotRegistered( - EIP712Types.UpdateUserRiskPremium memory p + ISignatureGateway.UpdateUserRiskPremium memory p ) public { bytes memory signature = vm.randomBytes(32); @@ -77,7 +83,7 @@ contract SignatureGateway_SpokeNotRegistered_Test is SignatureGatewayBaseTest { } function test_updateUserDynamicConfigWithSig_revertsWith_SpokeNotRegistered( - EIP712Types.UpdateUserDynamicConfig memory p + ISignatureGateway.UpdateUserDynamicConfig memory p ) public { bytes memory signature = vm.randomBytes(32); diff --git a/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.Unauthorized.t.sol b/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.Unauthorized.t.sol index d3f79506c..f9343ac35 100644 --- a/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.Unauthorized.t.sol +++ b/tests/unit/misc/SignatureGateway/SignatureGateway.Reverts.Unauthorized.t.sol @@ -14,7 +14,7 @@ contract SignatureGateway_Unauthorized_PositionManagerNotActive_Test is Signatur } function test_supplyWithSig_revertsWith_Unauthorized() public { - EIP712Types.Supply memory p = _supplyData(spoke1, alice, _warpBeforeRandomDeadline()); + ISignatureGateway.Supply memory p = _supplyData(spoke1, alice, _warpBeforeRandomDeadline()); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); vm.expectRevert(ISpoke.Unauthorized.selector); @@ -23,7 +23,7 @@ contract SignatureGateway_Unauthorized_PositionManagerNotActive_Test is Signatur } function test_withdrawWithSig_revertsWith_Unauthorized() public { - EIP712Types.Withdraw memory p = _withdrawData(spoke1, alice, _warpBeforeRandomDeadline()); + ISignatureGateway.Withdraw memory p = _withdrawData(spoke1, alice, _warpBeforeRandomDeadline()); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); vm.expectRevert(ISpoke.Unauthorized.selector); @@ -32,7 +32,7 @@ contract SignatureGateway_Unauthorized_PositionManagerNotActive_Test is Signatur } function test_borrowWithSig_revertsWith_Unauthorized() public { - EIP712Types.Borrow memory p = _borrowData(spoke1, alice, _warpBeforeRandomDeadline()); + ISignatureGateway.Borrow memory p = _borrowData(spoke1, alice, _warpBeforeRandomDeadline()); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); vm.expectRevert(ISpoke.Unauthorized.selector); @@ -41,7 +41,7 @@ contract SignatureGateway_Unauthorized_PositionManagerNotActive_Test is Signatur } function test_repayWithSig_revertsWith_Unauthorized() public { - EIP712Types.Repay memory p = _repayData(spoke1, alice, _warpBeforeRandomDeadline()); + ISignatureGateway.Repay memory p = _repayData(spoke1, alice, _warpBeforeRandomDeadline()); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); vm.expectRevert(ISpoke.Unauthorized.selector); @@ -51,7 +51,7 @@ contract SignatureGateway_Unauthorized_PositionManagerNotActive_Test is Signatur function test_setUsingAsCollateralWithSig_revertsWith_Unauthorized() public { uint256 deadline = _warpBeforeRandomDeadline(); - EIP712Types.SetUsingAsCollateral memory p = _setAsCollateralData(spoke1, alice, deadline); + ISignatureGateway.SetUsingAsCollateral memory p = _setAsCollateralData(spoke1, alice, deadline); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); vm.expectRevert(ISpoke.Unauthorized.selector); @@ -60,7 +60,7 @@ contract SignatureGateway_Unauthorized_PositionManagerNotActive_Test is Signatur } function test_updateUserRiskPremiumWithSig_revertsWith_Unauthorized() public { - EIP712Types.UpdateUserRiskPremium memory p = _updateRiskPremiumData( + ISignatureGateway.UpdateUserRiskPremium memory p = _updateRiskPremiumData( spoke1, alice, _warpBeforeRandomDeadline() @@ -75,7 +75,7 @@ contract SignatureGateway_Unauthorized_PositionManagerNotActive_Test is Signatur } function test_updateUserDynamicConfigWithSig_revertsWith_Unauthorized() public { - EIP712Types.UpdateUserDynamicConfig memory p = _updateDynamicConfigData( + ISignatureGateway.UpdateUserDynamicConfig memory p = _updateDynamicConfigData( spoke1, alice, _warpBeforeRandomDeadline() diff --git a/tests/unit/misc/SignatureGateway/SignatureGateway.SetSelfAsUserPositionManagerWithSig.t.sol b/tests/unit/misc/SignatureGateway/SignatureGateway.SetSelfAsUserPositionManagerWithSig.t.sol new file mode 100644 index 000000000..da565043a --- /dev/null +++ b/tests/unit/misc/SignatureGateway/SignatureGateway.SetSelfAsUserPositionManagerWithSig.t.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/misc/SignatureGateway/SignatureGateway.Base.t.sol'; + +contract SignatureGatewaySetSelfAsUserPositionManagerTest is SignatureGatewayBaseTest { + function test_setSelfAsUserPositionManagerWithSig_revertsWith_SpokeNotRegistered() public { + vm.expectRevert(IGatewayBase.SpokeNotRegistered.selector); + vm.prank(vm.randomAddress()); + gateway.setSelfAsUserPositionManagerWithSig({ + spoke: address(spoke2), + onBehalfOf: vm.randomAddress(), + approve: vm.randomBool(), + nonce: vm.randomUint(), + deadline: vm.randomUint(), + signature: vm.randomBytes(72) + }); + } + + function test_setSelfAsUserPositionManagerWithSig_forwards_correct_call() public { + ISpoke.PositionManagerUpdate[] memory updates = new ISpoke.PositionManagerUpdate[](1); + updates[0] = ISpoke.PositionManagerUpdate(address(gateway), vm.randomBool()); + ISpoke.SetUserPositionManagers memory p = ISpoke.SetUserPositionManagers({ + onBehalfOf: vm.randomAddress(), + updates: updates, + nonce: vm.randomUint(), + deadline: vm.randomUint() + }); + bytes memory signature = vm.randomBytes(72); + + vm.expectCall( + address(spoke1), + abi.encodeCall(ISpoke.setUserPositionManagersWithSig, (p, signature)), + 1 + ); + vm.prank(vm.randomAddress()); + gateway.setSelfAsUserPositionManagerWithSig({ + spoke: address(spoke1), + onBehalfOf: p.onBehalfOf, + approve: p.updates[0].approve, + nonce: p.nonce, + deadline: p.deadline, + signature: signature + }); + } + + function test_setSelfAsUserPositionManagerWithSig_ignores_underlying_spoke_reverts() public { + vm.mockCallRevert( + address(spoke1), + ISpoke.setUserPositionManagersWithSig.selector, + vm.randomBytes(64) + ); + + vm.prank(vm.randomAddress()); + gateway.setSelfAsUserPositionManagerWithSig({ + spoke: address(spoke1), + onBehalfOf: vm.randomAddress(), + approve: vm.randomBool(), + nonce: vm.randomUint(), + deadline: vm.randomUint(), + signature: vm.randomBytes(72) + }); + + assertFalse(spoke1.isPositionManager(alice, address(gateway))); + } + + function test_setSelfAsUserPositionManagerWithSig() public { + uint192 nonceKey = _randomNonceKey(); + vm.prank(alice); + spoke1.useNonce(nonceKey); + ISpoke.PositionManagerUpdate[] memory updates = new ISpoke.PositionManagerUpdate[](1); + updates[0] = ISpoke.PositionManagerUpdate(address(gateway), true); + ISpoke.SetUserPositionManagers memory p = ISpoke.SetUserPositionManagers({ + onBehalfOf: alice, + updates: updates, + nonce: spoke1.nonces(alice, nonceKey), // note: this typed sig is forwarded to spoke + deadline: _warpBeforeRandomDeadline() + }); + bytes memory signature = _sign(alicePk, _getTypedDataHash(spoke1, p)); + + vm.prank(SPOKE_ADMIN); + spoke1.updatePositionManager(address(gateway), true); + vm.prank(alice); + spoke1.setUserPositionManager(address(gateway), false); + + gateway.setSelfAsUserPositionManagerWithSig({ + spoke: address(spoke1), + onBehalfOf: p.onBehalfOf, + approve: p.updates[0].approve, + nonce: p.nonce, + deadline: p.deadline, + signature: signature + }); + + assertTrue(spoke1.isPositionManager(alice, address(gateway))); + } +} diff --git a/tests/unit/misc/SignatureGateway/SignatureGateway.t.sol b/tests/unit/misc/SignatureGateway/SignatureGateway.t.sol index 577b599d2..7a0d19e52 100644 --- a/tests/unit/misc/SignatureGateway/SignatureGateway.t.sol +++ b/tests/unit/misc/SignatureGateway/SignatureGateway.t.sol @@ -50,13 +50,13 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { } function test_supplyWithSig() public { - EIP712Types.Supply memory p = _supplyData(spoke1, alice, _warpBeforeRandomDeadline()); + ISignatureGateway.Supply memory p = _supplyData(spoke1, alice, _warpBeforeRandomDeadline()); p.nonce = _burnRandomNoncesAtKey(gateway, p.onBehalfOf); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); Utils.approve(spoke1, p.reserveId, alice, address(gateway), p.amount); uint256 shares = _hub(spoke1, p.reserveId).previewAddByAssets( - _spokeAssetId(spoke1, p.reserveId), + _reserveAssetId(spoke1, p.reserveId), p.amount ); @@ -76,14 +76,14 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { } function test_withdrawWithSig() public { - EIP712Types.Withdraw memory p = _withdrawData(spoke1, alice, _warpBeforeRandomDeadline()); + ISignatureGateway.Withdraw memory p = _withdrawData(spoke1, alice, _warpBeforeRandomDeadline()); p.nonce = _burnRandomNoncesAtKey(gateway, p.onBehalfOf); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); Utils.supply(spoke1, p.reserveId, alice, p.amount + 1, alice); uint256 shares = _hub(spoke1, p.reserveId).previewRemoveByAssets( - _spokeAssetId(spoke1, p.reserveId), + _reserveAssetId(spoke1, p.reserveId), p.amount ); TestReturnValues memory returnValues; @@ -102,7 +102,7 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { } function test_borrowWithSig() public { - EIP712Types.Borrow memory p = _borrowData(spoke1, alice, _warpBeforeRandomDeadline()); + ISignatureGateway.Borrow memory p = _borrowData(spoke1, alice, _warpBeforeRandomDeadline()); p.nonce = _burnRandomNoncesAtKey(gateway, p.onBehalfOf); p.reserveId = _daiReserveId(spoke1); p.amount = 1e18; @@ -110,7 +110,7 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); uint256 shares = _hub(spoke1, p.reserveId).previewDrawByAssets( - _spokeAssetId(spoke1, p.reserveId), + _reserveAssetId(spoke1, p.reserveId), p.amount ); TestReturnValues memory returnValues; @@ -129,7 +129,7 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { } function test_repayWithSig() public { - EIP712Types.Repay memory p = _repayData(spoke1, alice, _warpBeforeRandomDeadline()); + ISignatureGateway.Repay memory p = _repayData(spoke1, alice, _warpBeforeRandomDeadline()); p.nonce = _burnRandomNoncesAtKey(gateway, p.onBehalfOf); p.reserveId = _daiReserveId(spoke1); p.amount = 1e18; @@ -145,7 +145,7 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { p.amount ); uint256 shares = _hub(spoke1, p.reserveId).previewRestoreByAssets( - _spokeAssetId(spoke1, p.reserveId), + _reserveAssetId(spoke1, p.reserveId), baseRestored ); TestReturnValues memory returnValues; @@ -172,7 +172,7 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { function test_setUsingAsCollateralWithSig() public { uint256 deadline = _warpBeforeRandomDeadline(); - EIP712Types.SetUsingAsCollateral memory p = _setAsCollateralData(spoke1, alice, deadline); + ISignatureGateway.SetUsingAsCollateral memory p = _setAsCollateralData(spoke1, alice, deadline); p.nonce = _burnRandomNoncesAtKey(gateway, p.onBehalfOf); p.reserveId = _daiReserveId(spoke1); Utils.supplyCollateral(spoke1, p.reserveId, alice, 1e18, alice); @@ -193,7 +193,11 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { function test_updateUserRiskPremiumWithSig() public { uint256 deadline = _warpBeforeRandomDeadline(); - EIP712Types.UpdateUserRiskPremium memory p = _updateRiskPremiumData(spoke1, alice, deadline); + ISignatureGateway.UpdateUserRiskPremium memory p = _updateRiskPremiumData( + spoke1, + alice, + deadline + ); p.nonce = _burnRandomNoncesAtKey(gateway, alice); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); @@ -212,7 +216,7 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { } function test_updateUserDynamicConfigWithSig() public { - EIP712Types.UpdateUserDynamicConfig memory p = _updateDynamicConfigData( + ISignatureGateway.UpdateUserDynamicConfig memory p = _updateDynamicConfigData( spoke1, alice, _warpBeforeRandomDeadline() @@ -232,20 +236,29 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { } function test_setSelfAsUserPositionManagerWithSig() public { - EIP712Types.SetUserPositionManager memory p = EIP712Types.SetUserPositionManager({ - positionManager: address(gateway), - user: alice, - approve: true, + ISpoke.PositionManagerUpdate[] memory updates = new ISpoke.PositionManagerUpdate[](1); + updates[0] = ISpoke.PositionManagerUpdate(address(gateway), true); + + ISpoke.SetUserPositionManagers memory p = ISpoke.SetUserPositionManagers({ + updates: updates, + onBehalfOf: alice, nonce: spoke1.nonces(address(alice), _randomNonceKey()), // note: this typed sig is forwarded to spoke deadline: _warpBeforeRandomDeadline() }); bytes memory signature = _sign(alicePk, _getTypedDataHash(spoke1, p)); vm.expectEmit(address(spoke1)); - emit ISpoke.SetUserPositionManager(alice, address(gateway), p.approve); + emit ISpoke.SetUserPositionManager(alice, address(gateway), p.updates[0].approve); vm.prank(vm.randomAddress()); - gateway.setSelfAsUserPositionManagerWithSig(address(spoke1), p, signature); + gateway.setSelfAsUserPositionManagerWithSig({ + spoke: address(spoke1), + onBehalfOf: p.onBehalfOf, + approve: p.updates[0].approve, + nonce: p.nonce, + deadline: p.deadline, + signature: signature + }); _assertNonceIncrement(ISignatureGateway(address(spoke1)), alice, p.nonce); // note: nonce consumed on spoke _assertGatewayHasNoBalanceOrAllowance(spoke1, gateway, alice); diff --git a/yarn.lock b/yarn.lock index 8a0ea90d4..823c5e7d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,19 +2,19 @@ # yarn lockfile v1 -"@bytecodealliance/preview2-shim@0.17.2": - version "0.17.2" - resolved "https://registry.yarnpkg.com/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.2.tgz#8d0c732cba29169a85aa3e603c767e039378f89b" - integrity sha512-mNm/lblgES8UkVle8rGImXOz4TtL3eU3inHay/7TVchkKrb/lgcVvTK0+VAw8p5zQ0rgQsXm1j5dOlAAd+MeoA== - -"@nomicfoundation/slang@1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@nomicfoundation/slang/-/slang-1.2.0.tgz#38a389729bf2bc882b75e3feada9995bedc50091" - integrity sha512-+04Z1RHbbz0ldDbHKQFOzveCdI9Rd3TZZu7fno5hHy3OsqTo9UK5Jgqo68wMvRovCO99POv6oCEyO7+urGeN8Q== +"@bytecodealliance/preview2-shim@^0.17.2": + version "0.17.6" + resolved "https://registry.yarnpkg.com/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.6.tgz#3d60f3fde44582b022643fafcee38077594069e5" + integrity sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw== + +"@nomicfoundation/slang@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/slang/-/slang-1.3.1.tgz#599cfbbec0d3cf8661833a3887fac258a0c5da94" + integrity sha512-gh0+JDjazmevEYCcwVgtuyfBJcV1209gIORZNRjUxbGzbQN0MOhQO9T0ptkzHKCf854gUy27SMxPbAyAu63fvQ== dependencies: - "@bytecodealliance/preview2-shim" "0.17.2" + "@bytecodealliance/preview2-shim" "^0.17.2" -"@solidity-parser/parser@^0.20.1": +"@solidity-parser/parser@^0.20.2": version "0.20.2" resolved "https://registry.yarnpkg.com/@solidity-parser/parser/-/parser-0.20.2.tgz#e07053488ed60dae1b54f6fe37bb6d2c5fe146a7" integrity sha512-rbu0bzwNvMcwAjH86hiEAcOeRI2EeK8zCkHDrFykh/Al8mvJeFmjy3UrE7GYQjNwOgbGUUtCn5/k8CB8zIu7QA== @@ -183,19 +183,19 @@ pidtree@^0.6.0: resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" integrity sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g== -prettier-plugin-solidity@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/prettier-plugin-solidity/-/prettier-plugin-solidity-2.1.0.tgz#2298aefc92c5ebd3a0323f98151d2a82f38fc7c6" - integrity sha512-O5HX4/PCE5aqiaEiNGbSRLbSBZQ6kLswAav5LBSewwzhT+sZlN6iAaLZlZcJzPEnIAxwLEHP03xKEg92fflT9Q== +prettier-plugin-solidity@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/prettier-plugin-solidity/-/prettier-plugin-solidity-2.2.1.tgz#a564c655e93b6ef7ea8e8128a3668e2e14549004" + integrity sha512-LOHfxECJ/gHsY7qi4D7vanz8cVsCf6yFotBapJ5O0qaX0ZR1sGUzbWfMd4JeQYOItFl+wXW9IcjZOdfr6bmSvQ== dependencies: - "@nomicfoundation/slang" "1.2.0" - "@solidity-parser/parser" "^0.20.1" - semver "^7.7.2" + "@nomicfoundation/slang" "1.3.1" + "@solidity-parser/parser" "^0.20.2" + semver "^7.7.3" -prettier@3.6.2: - version "3.6.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393" - integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ== +prettier@3.7.4: + version "3.7.4" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f" + integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA== restore-cursor@^5.0.0: version "5.1.0" @@ -210,7 +210,7 @@ rfdc@^1.4.1: resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== -semver@^7.7.2: +semver@^7.7.3: version "7.7.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==