Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,17 @@ jobs:
- name: Run Forge build
run: forge build --sizes

- name: Run Forge tests
run: forge test -vvv
# Deterministic, RPC-free layers: unit, adversarial, fuzz, invariant, and
# the non-fork integration lifecycle. These must always pass.
- name: Run Forge tests (local)
run: forge test -vvv --no-match-path 'test/integration/*.fork.t.sol'

# Fork layer (Aave / Morpho / Compound / Pendle on Arbitrum). Each test
# self-skips when ARBITRUM_RPC_URL is unset, so this step stays green on
# forks/PRs without the secret and exercises the real protocols when it is
# provided. Set the ARBITRUM_RPC_URL repository secret to enable.
- name: Run Forge fork tests
env:
ARBITRUM_RPC_URL: ${{ secrets.ARBITRUM_RPC_URL }}
ARBITRUM_FORK_BLOCK: ${{ vars.ARBITRUM_FORK_BLOCK }}
run: forge test -vvv --match-path 'test/integration/*.fork.t.sol'
2 changes: 2 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ via_ir = false
ast = true
extra_output = ["storageLayout"]
fuzz = { runs = 1_000 }
invariant = { runs = 128, depth = 64, fail_on_revert = false }

[profile.ci]
fuzz = { runs = 10_000 }
invariant = { runs = 256, depth = 128, fail_on_revert = false }
verbosity = 4

[fmt]
Expand Down
147 changes: 147 additions & 0 deletions test/fuzz/VaultFuzz.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { VaultHarness } from "../helpers/VaultHarness.sol";
import { AllocatorFacet } from "../../src/facets/AllocatorFacet.sol";
import { FeeFacet } from "../../src/facets/FeeFacet.sol";
import { LibGuard } from "../../src/libraries/LibGuard.sol";

/// @dev External wrapper so the fuzzer can exercise LibGuard's internal pure
/// math (deviation + share-price) directly, in isolation from vault state.
contract LibGuardWrapper {
function deviationExceeded(uint256 last, uint256 current, uint16 bps) external pure returns (bool) {
return LibGuard.deviationExceeded(last, current, bps);
}

function sharePrice(uint256 totalAssets_, uint256 supply) external pure returns (uint256) {
return LibGuard.sharePrice(totalAssets_, supply);
}
}

/// @title VaultFuzzTest
/// @notice Property-based fuzzing of the value-conservation and breaker-math
/// guarantees the unit tests can only spot-check. The recurring theme:
/// no input should let a user extract value the vault did not earn, and
/// fee/rounding must always break in the vault's favour.
contract VaultFuzzTest is VaultHarness {
LibGuardWrapper internal guardMath;

address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
address internal feeRec = makeAddr("feeRec");

function setUp() public {
_deployHarness();
guardMath = new LibGuardWrapper();
}

// -----------------------------------------------------------------------
// Value conservation
// -----------------------------------------------------------------------

/// @notice A deposit immediately redeemed can never return more than was put
/// in. Rounding must always favour the vault (no free wei).
function testFuzz_DepositRedeemRoundTripNeverProfits(uint256 amount) public {
amount = bound(amount, 1, 1e15);
uint256 shares = _deposit(alice, amount);

vm.prank(alice);
uint256 out = vault.redeem(shares, alice, alice);

assertLe(out, amount, "round-trip returned more than deposited");
}

/// @notice convertToAssets(convertToShares(x)) <= x for any seed state: the
/// double conversion can never manufacture value.
function testFuzz_ConvertRoundTripNeverProfits(uint256 seed, uint256 assets) public {
seed = bound(seed, 1e6, 1e15);
assets = bound(assets, 1, 1e15);
_deposit(alice, seed); // establish a non-trivial supply/NAV

uint256 back = vault.convertToAssets(vault.convertToShares(assets));
assertLe(back, assets, "convert round-trip created value");
}

/// @notice A NAV-neutral rebalance (the mock backend is 1:1) must leave
/// totalAssets unchanged for any target split within budget.
function testFuzz_RebalancePreservesNav(uint256 amount, uint16 bps) public {
amount = bound(amount, 1e6, 1e15);
bps = uint16(bound(bps, 0, 10_000));
_registerMock();
_deposit(alice, amount);

_setAlloc(MOCK_ID, bps);
_rebalance();

assertEq(vault.totalAssets(), amount, "NAV drifted across a 1:1 rebalance");
}

// -----------------------------------------------------------------------
// Fees never make the vault insolvent
// -----------------------------------------------------------------------

/// @notice Under any fee config, donation, and elapsed time, the redeemable
/// value of all shares must stay <= totalAssets. Fee minting dilutes
/// holders but must never let the share base out-value the vault.
function testFuzz_FeeAccrualPreservesSolvency(
uint256 amount,
uint256 donation,
uint16 perfBps,
uint16 mgmtBps,
uint256 elapsed
)
public
{
amount = bound(amount, 1e6, 1e15);
donation = bound(donation, 0, 1e15);
perfBps = uint16(bound(perfBps, 0, 5000));
mgmtBps = uint16(bound(mgmtBps, 0, 1000));
elapsed = bound(elapsed, 0, 730 days);

vm.startPrank(owner);
FeeFacet(address(vault)).setFeeRecipient(feeRec);
FeeFacet(address(vault)).setPerformanceFee(perfBps);
FeeFacet(address(vault)).setManagementFee(mgmtBps);
vm.stopPrank();

_deposit(alice, amount);
if (donation > 0) {
usdc.mint(address(vault), donation); // simulate yield as idle growth
}
vm.warp(block.timestamp + elapsed);

// Trigger accrual through a fresh deposit.
_deposit(bob, 1e6);

assertLe(
vault.convertToAssets(vault.totalSupply()),
vault.totalAssets(),
"fee accrual made the share base out-value the vault"
);
}

// -----------------------------------------------------------------------
// Circuit-breaker math (pure)
// -----------------------------------------------------------------------

function testFuzz_GuardDeviationMatchesFormula(uint256 last, uint256 current, uint16 bps) public view {
last = bound(last, 1, 1e30);
current = bound(current, 0, 1e30);
bps = uint16(bound(bps, 0, 10_000));

bool got = guardMath.deviationExceeded(last, current, bps);
uint256 diff = current > last ? current - last : last - current;
bool expected = bps != 0 && diff * 10_000 > uint256(bps) * last;
assertEq(got, expected, "deviationExceeded diverged from its formula");
}

function testFuzz_GuardSharePriceMonotonicInAssets(uint256 ta, uint256 delta, uint256 supply) public view {
ta = bound(ta, 0, 1e30);
delta = bound(delta, 1, 1e30);
supply = bound(supply, 0, 1e30);

uint256 p1 = guardMath.sharePrice(ta, supply);
uint256 p2 = guardMath.sharePrice(ta + delta, supply);
assertGe(p2, p1, "share price must not fall when assets rise");
}
}
Loading