diff --git a/.github/workflows/foundry.yaml b/.github/workflows/foundry.yaml new file mode 100644 index 00000000..607d7a75 --- /dev/null +++ b/.github/workflows/foundry.yaml @@ -0,0 +1,65 @@ +name: Foundry tests + +on: + push: + branches: + - master + pull_request: + +concurrency: + group: ${{github.workflow}}-${{github.ref}} + cancel-in-progress: true + +jobs: + unit: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - ubuntu-latest + architecture: + - "x64" + python-version: + - "3.10" + node_version: + - 16 + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + architecture: ${{ matrix.architecture }} + + - name: Install Ape + uses: ApeWorX/github-action@v2.0 + with: + python-version: '3.10' + + - name: install vyper + run: pip install git+https://github.com/vyperlang/vyper + + - name: Compile contracts + # Compile Ape contracts to get dependencies + run: ape compile --force --size + + - name: Install Vyper + run: pip install vyper==0.3.7 + + - name: Use Node.js ${{ matrix.node_version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node_version }} + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Foundry tests + run: forge test -vvv \ No newline at end of file diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index ed6eb50a..d513b6ab 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -51,4 +51,4 @@ jobs: run: pip install -r requirements.txt - name: Run black - run: black --check --include "(tests|scripts)" . \ No newline at end of file + run: black --check . \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 09c85dc7..6de42511 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -23,7 +23,6 @@ jobs: - uses: ApeWorX/github-action@v2.0 with: python-version: '3.10' - ape-version-pin: "==0.7.0" - name: install vyper run: pip install git+https://github.com/vyperlang/vyper diff --git a/.gitignore b/.gitignore index 2d209c96..57b504f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ pyenv.cfg vyper_git_commithash.txt bin/ -lib/ +cache/ +out/ share/ build/ include/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..665e0dd7 --- /dev/null +++ b/.gitmodules @@ -0,0 +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/.solhint.json b/.solhint.json index c35b3e40..c1ae7de9 100644 --- a/.solhint.json +++ b/.solhint.json @@ -15,6 +15,6 @@ "not-rely-on-time": "off", "private-vars-leading-underscore": "warn", "reason-string": ["warn", { "maxLength": 64 }], - "yearn/underscore-function-args": "error" + "yearn/underscore-function-args": "off" } } diff --git a/README.md b/README.md index 3bfa0e22..ecaa90c3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ This repository runs on [ApeWorx](https://www.apeworx.io/). A python based devel You will need: - Python 3.8 or later - - Vyper 0.3.7 + - [Vyper 0.3.7](https://docs.vyperlang.org/en/stable/installing-vyper.html) + - [Foundry](https://book.getfoundry.sh/getting-started/installation) - Linux or macOS - Windows: Install Windows Subsystem Linux (WSL) with Python 3.8 or later - [Hardhat](https://hardhat.org/) installed globally @@ -24,7 +25,7 @@ You will need: Fork the repository and clone onto your local device ``` -git clone https://github.com/user/yearn-vaults-v3 +git clone --recursive https://github.com/user/yearn-vaults-v3 cd yearn-vaults-v3 ``` @@ -60,6 +61,14 @@ and test smart contracts with: ape test ``` +To run the Foundry tests + +NOTE: You will need to first compile with Ape before running foundry tests. +``` +forge test +``` + + ### To make a contribution please follow the [guidelines](https://github.com/yearn/yearn-vaults-v3/bloc/master/CONTRIBUTING.md) See the ApeWorx [documentation](https://docs.apeworx.io/ape/stable/) and [github](https://github.com/ApeWorX/ape) for more information. diff --git a/ape-config.yaml b/ape-config.yaml index 9affa89f..a787aade 100644 --- a/ape-config.yaml +++ b/ape-config.yaml @@ -9,7 +9,7 @@ default_ecosystem: ethereum dependencies: - name: openzeppelin github: OpenZeppelin/openzeppelin-contracts - ref: 4.7.3 + ref: 4.9.5 - name: tokenized-strategy github: yearn/tokenized-strategy ref: dev_302 @@ -17,7 +17,7 @@ dependencies: solidity: import_remapping: - - "@openzeppelin/contracts=openzeppelin/v4.7.3" + - "@openzeppelin/contracts=openzeppelin/v4.9.5" - "@tokenized-strategy=tokenized-strategy/dev_302" ethereum: diff --git a/contracts/VaultV3.vy b/contracts/VaultV3.vy index 7b727fec..b9516ddf 100644 --- a/contracts/VaultV3.vy +++ b/contracts/VaultV3.vy @@ -564,8 +564,11 @@ def _max_deposit(receiver: address) -> uint256: return IDepositLimitModule(deposit_limit_module).available_deposit_limit(receiver) # Else use the standard flow. - _total_assets: uint256 = self._total_assets() _deposit_limit: uint256 = self.deposit_limit + if (_deposit_limit == max_value(uint256)): + return _deposit_limit + + _total_assets: uint256 = self._total_assets() if (_total_assets >= _deposit_limit): return 0 diff --git a/contracts/test/ERC4626BaseStrategy.sol b/contracts/test/ERC4626BaseStrategy.sol index c4a75e35..e37f7995 100644 --- a/contracts/test/ERC4626BaseStrategy.sol +++ b/contracts/test/ERC4626BaseStrategy.sol @@ -20,7 +20,7 @@ abstract contract ERC4626BaseStrategy is ERC4626 { constructor( address _vault, address _asset - ) ERC4626(IERC20Metadata(address(_asset))) { + ) ERC4626(IERC20(address(_asset))) { _initialize(_vault, _asset); } @@ -30,13 +30,7 @@ abstract contract ERC4626BaseStrategy is ERC4626 { vault = _vault; } - function decimals() - public - view - virtual - override(ERC20, IERC20Metadata) - returns (uint8) - { + function decimals() public view virtual override returns (uint8) { return _decimals; } diff --git a/contracts/test/mocks/ERC4626/LossyStrategy.sol b/contracts/test/mocks/ERC4626/LossyStrategy.sol index 410efdfb..32dd9476 100644 --- a/contracts/test/mocks/ERC4626/LossyStrategy.sol +++ b/contracts/test/mocks/ERC4626/LossyStrategy.sol @@ -32,12 +32,13 @@ contract ERC4626LossyStrategy is MockTokenizedStrategy { address public yieldSource; constructor( + address _factory, address _asset, string memory _name, address _management, address _keeper, address _vault - ) MockTokenizedStrategy(_asset, _name, _management, _keeper) { + ) MockTokenizedStrategy(_factory, _asset, _name, _management, _keeper) { yieldSource = address(new YieldSource(_asset)); ERC20(_asset).safeApprove(yieldSource, type(uint256).max); // So we can record losses when it happens. diff --git a/contracts/test/mocks/ERC4626/MockTokenizedStrategy.sol b/contracts/test/mocks/ERC4626/MockTokenizedStrategy.sol index 752cd5e6..ec78518b 100644 --- a/contracts/test/mocks/ERC4626/MockTokenizedStrategy.sol +++ b/contracts/test/mocks/ERC4626/MockTokenizedStrategy.sol @@ -8,11 +8,12 @@ contract MockTokenizedStrategy is TokenizedStrategy { uint256 public maxDebt = type(uint256).max; constructor( + address _factory, address _asset, string memory _name, address _management, address _keeper - ) { + ) TokenizedStrategy(_factory) { // Cache storage pointer StrategyData storage S = _strategyStorage(); @@ -24,7 +25,7 @@ contract MockTokenizedStrategy is TokenizedStrategy { S.decimals = ERC20(_asset).decimals(); // Set last report to this block. - S.lastReport = uint128(block.timestamp); + S.lastReport = uint96(block.timestamp); // Set the default management address. Can't be 0. require(_management != address(0), "ZERO ADDRESS"); diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 00000000..2f89b3a4 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,26 @@ +[profile.default] +src = 'contracts' +test = 'foundry_tests' +out = 'out' +libs = ['lib'] + +remappings = [ + 'forge-std/=lib/forge-std/src/', + 'erc4626-tests/=lib/erc4626-tests/', + "@tokenized-strategy=contracts/.cache/tokenized-strategy/dev_302", + '@openzeppelin/contracts=contracts/.cache/openzeppelin/v4.9.5/', +] +fs_permissions = [{ access = "read", path = "./"}] + +match_path = "foundry_tests/tests/*" +ffi = true + +[fuzz] +runs = 250 +max_test_rejects = 1_000_000 + +[invariant] +runs = 100 +depth = 100 + +# See more config options https://github.com/gakonst/foundry/tree/master/config \ No newline at end of file diff --git a/foundry_tests/tests/ERC4626Std.t.sol b/foundry_tests/tests/ERC4626Std.t.sol new file mode 100644 index 00000000..07190729 --- /dev/null +++ b/foundry_tests/tests/ERC4626Std.t.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.18; + +import "erc4626-tests/ERC4626.test.sol"; + +import {Setup} from "../utils/Setup.sol"; + +// SEE https://github.com/a16z/erc4626-tests +contract VaultERC4626StdTest is ERC4626Test, Setup { + function setUp() public override(ERC4626Test, Setup) { + super.setUp(); + _underlying_ = address(asset); + _vault_ = address(vault); + _delta_ = 0; + _vaultMayBeEmpty = true; + _unlimitedAmount = true; + } + + // NOTE: The following tests are relaxed to consider only smaller values (of type uint120), + // since the maxWithdraw(), and maxRedeem() functions fail with large values (due to overflow). + + function test_maxWithdraw(Init memory init) public override { + init = clamp(init, type(uint120).max); + super.test_maxWithdraw(init); + } + + function test_maxRedeem(Init memory init) public override { + init = clamp(init, type(uint120).max); + super.test_maxRedeem(init); + } + + function clamp( + Init memory init, + uint max + ) internal pure returns (Init memory) { + for (uint i = 0; i < N; i++) { + init.share[i] = init.share[i] % max; + init.asset[i] = init.asset[i] % max; + } + init.yield = init.yield % int(max); + return init; + } +} diff --git a/foundry_tests/utils/ExtendedTest.sol b/foundry_tests/utils/ExtendedTest.sol new file mode 100644 index 00000000..e8fcc6ce --- /dev/null +++ b/foundry_tests/utils/ExtendedTest.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {Test} from "forge-std/Test.sol"; + +contract ExtendedTest is Test { + // solhint-disable-next-line + function assertNeq(address a, address b) internal { + if (a == b) { + emit log("Error: a != b not satisfied [address]"); + emit log_named_address(" Expected", b); + emit log_named_address(" Actual", a); + fail(); + } + } + + // @dev checks whether @a is within certain percentage of @b + // @a actual value + // @b expected value + // solhint-disable-next-line + function assertRelApproxEq( + uint256 a, + uint256 b, + uint256 maxPercentDelta + ) internal virtual { + uint256 delta = a > b ? a - b : b - a; + uint256 maxRelDelta = b / maxPercentDelta; + + if (delta > maxRelDelta) { + emit log("Error: a ~= b not satisfied [uint]"); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + emit log_named_uint(" Max Delta", maxRelDelta); + emit log_named_uint(" Delta", delta); + fail(); + } + } + + // Can be removed once https://github.com/dapphub/ds-test/pull/25 is merged and we update submodules, but useful for now + // solhint-disable-next-line + function assertApproxEq( + uint256 a, + uint256 b, + uint256 margin_of_error + ) internal { + if (a > b) { + if (a - b > margin_of_error) { + emit log("Error a not equal to b"); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + fail(); + } + } else { + if (b - a > margin_of_error) { + emit log("Error a not equal to b"); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + fail(); + } + } + } + + // solhint-disable-next-line + function assertApproxEq( + uint256 a, + uint256 b, + uint256 margin_of_error, + string memory err + ) internal { + if (a > b) { + if (a - b > margin_of_error) { + emit log_named_string("Error", err); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + fail(); + } + } else { + if (b - a > margin_of_error) { + emit log_named_string("Error", err); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + fail(); + } + } + } +} diff --git a/foundry_tests/utils/Setup.sol b/foundry_tests/utils/Setup.sol new file mode 100644 index 00000000..0cbd28a9 --- /dev/null +++ b/foundry_tests/utils/Setup.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {ExtendedTest} from "./ExtendedTest.sol"; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {ERC20Mock} from "@openzeppelin/contracts/mocks/ERC20Mock.sol"; + +import {IVault} from "../../contracts/interfaces/IVault.sol"; +import {Roles} from "../../contracts/interfaces/Roles.sol"; +import {IVaultFactory} from "../../contracts/interfaces/IVaultFactory.sol"; + +import {VyperDeployer} from "./VyperDeployer.sol"; + +contract Setup is ExtendedTest { + IVault public vault; + ERC20Mock public asset; + IVaultFactory public vaultFactory; + VyperDeployer public vyperDeployer; + + address public daddy = address(69); + address public vaultManagement = address(2); + + uint256 public maxFuzzAmount = 1e30; + + function setUp() public virtual { + vyperDeployer = new VyperDeployer(); + + vaultFactory = setupFactory(); + + asset = new ERC20Mock(); + + vault = IVault(setUpVault()); + + vm.label(address(vault), "Vault"); + vm.label(address(asset), "Asset"); + vm.label(address(vaultFactory), "Vault Factory"); + vm.label(daddy, "Daddy"); + vm.label(vaultManagement, "Vault management"); + } + + function setupFactory() public returns (IVaultFactory _factory) { + address original = vyperDeployer.deployContract( + "contracts/", + "VaultV3" + ); + + bytes memory args = abi.encode("Test vault Factory", original, daddy); + + _factory = IVaultFactory( + vyperDeployer.deployContract("contracts/", "VaultFactory", args) + ); + } + + function setUpVault() public returns (IVault) { + IVault _vault = IVault( + vaultFactory.deploy_new_vault( + address(asset), + "Test vault", + "tsVault", + daddy, + 10 days + ) + ); + + vm.prank(daddy); + // Give the vault manager all the roles + _vault.set_role(vaultManagement, Roles.ALL); + + vm.prank(vaultManagement); + _vault.set_deposit_limit(type(uint256).max); + + return _vault; + } +} diff --git a/foundry_tests/utils/VyperDeployer.sol b/foundry_tests/utils/VyperDeployer.sol new file mode 100644 index 00000000..b739b6e1 --- /dev/null +++ b/foundry_tests/utils/VyperDeployer.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.13; +import "forge-std/console.sol"; + +///@notice This cheat codes interface is named _CheatCodes so you can use the CheatCodes interface in other testing files without errors +interface _CheatCodes { + function ffi(string[] calldata) external returns (bytes memory); +} + +/** + * @title Vyper Contract Deployer + * @notice Forked and modified from here: + * https://github.com/pcaversaccio/snekmate/blob/main/lib/utils/VyperDeployer.sol + * @dev The Vyper deployer is a pre-built contract that takes a filename + * and deploys the corresponding Vyper contract, returning the address + * that the bytecode was deployed to. + */ + +contract VyperDeployer { + address constant HEVM_ADDRESS = + address(bytes20(uint160(uint256(keccak256("hevm cheat code"))))); + + /// @notice Initializes cheat codes in order to use ffi to compile Vyper contracts + _CheatCodes cheatCodes = _CheatCodes(HEVM_ADDRESS); + + /** + * @dev Compiles a Vyper contract and returns the address that the contract + * was deployed to. If the deployment fails, an error is thrown. + * @param path The directory path of the Vyper contract. + * For example, the path of "test" is "src/test/". + * @param fileName The file name of the Vyper contract. + * For example, the file name for "Token.vy" is "Token". + * @return deployedAddress The address that the contract was deployed to. + */ + function deployContract( + string memory path, + string memory fileName + ) public returns (address) { + ///@notice create a list of strings with the commands necessary to compile Vyper contracts + string[] memory cmds = new string[](2); + cmds[0] = "vyper"; + cmds[1] = string.concat(path, fileName, ".vy"); + + ///@notice compile the Vyper contract and return the bytecode + bytes memory bytecode = cheatCodes.ffi(cmds); + + ///@notice deploy the bytecode with the create instruction + address deployedAddress; + assembly { + deployedAddress := create(0, add(bytecode, 0x20), mload(bytecode)) + } + + ///@notice check that the deployment was successful + require( + deployedAddress != address(0), + "VyperDeployer could not deploy contract" + ); + + ///@notice return the address that the contract was deployed to + return deployedAddress; + } + + /** + * @dev Compiles a Vyper contract and returns the address that the contract + * was deployed to. If the deployment fails, an error is thrown. + * @param path The directory path of the Vyper contract. + * For example, the path of "test" is "src/test/". + * @param fileName The file name of the Vyper contract. + * For example, the file name for "Token.vy" is "Token". + * @return deployedAddress The address that the contract was deployed to. + */ + function deployContract( + string memory path, + string memory fileName, + bytes calldata args + ) public returns (address) { + ///@notice create a list of strings with the commands necessary to compile Vyper contracts + string[] memory cmds = new string[](2); + cmds[0] = "vyper"; + cmds[1] = string.concat(path, fileName, ".vy"); + + ///@notice compile the Vyper contract and return the bytecode + bytes memory _bytecode = cheatCodes.ffi(cmds); + + //add args to the deployment bytecode + bytes memory bytecode = abi.encodePacked(_bytecode, args); + + ///@notice deploy the bytecode with the create instruction + address deployedAddress; + assembly { + deployedAddress := create(0, add(bytecode, 0x20), mload(bytecode)) + } + + ///@notice check that the deployment was successful + require( + deployedAddress != address(0), + "VyperDeployer could not deploy contract" + ); + + ///@notice return the address that the contract was deployed to + return deployedAddress; + } +} diff --git a/lib/erc4626-tests b/lib/erc4626-tests new file mode 160000 index 00000000..8b1d7c2a --- /dev/null +++ b/lib/erc4626-tests @@ -0,0 +1 @@ +Subproject commit 8b1d7c2ac248c33c3506b1bff8321758943c5e11 diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 00000000..2b58ecbc --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 2b58ecbcf3dfde7a75959dc7b4eb3d0670278de6 diff --git a/package.json b/package.json index 8ce40a8a..a042fff4 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "solhint-plugin-yearn": "pandadefi/solhint-plugin-yearn" }, "scripts": { - "format": "prettier --write 'contracts/**/*.(sol|json)' --verbose", - "format:check": "prettier --check 'contracts/**/*.*(sol|json)'", - "lint": "solhint 'contracts/**/*.sol'" + "format": "prettier --write 'contracts/**/*.(sol|json)' 'foundry_tests/**/*.(sol|json)'", + "format:check": "prettier --check 'contracts/**/*.*(sol|json)' 'foundry_tests/**/*.(sol|json)'", + "lint": "solhint 'contracts/**/*.sol' 'foundry_tests/**/*.sol'" } } diff --git a/requirements.txt b/requirements.txt index f37cb764..f0bc9548 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ black==22.3.0 -eth-ape>=0.7.0 \ No newline at end of file +eth-ape>=0.7.0 +vyper==0.3.7 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 0725044a..4586d951 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -221,10 +221,11 @@ def create_vault( # create default liquid strategy with 0 fee @pytest.fixture(scope="session") -def create_strategy(project, strategist, gov): +def create_strategy(project, strategist, gov, vault_factory): def create_strategy(vault): return strategist.deploy( project.MockTokenizedStrategy, + vault_factory.address, vault.asset(), "Mock Tokenized Strategy", strategist, @@ -245,10 +246,11 @@ def create_locked_strategy(vault): # create lossy strategy with 0 fee @pytest.fixture(scope="session") -def create_lossy_strategy(project, strategist, gov): +def create_lossy_strategy(project, strategist, gov, vault_factory): def create_lossy_strategy(vault): return strategist.deploy( project.ERC4626LossyStrategy, + vault_factory.address, vault.asset(), "Mock Tokenized Strategy", strategist, diff --git a/tests/unit/vault/test_strategy_withdraw.py b/tests/unit/vault/test_strategy_withdraw.py index c3d3b3ef..cf5afb75 100644 --- a/tests/unit/vault/test_strategy_withdraw.py +++ b/tests/unit/vault/test_strategy_withdraw.py @@ -1807,7 +1807,7 @@ def test_redeem__half_of_strategy_assets_from_locked_lossy_strategy_with_unreali asset, create_vault, create_strategy, - create_locked_strategy, + create_lossy_strategy, user_deposit, add_strategy_to_vault, add_debt_to_strategy, @@ -1822,7 +1822,7 @@ def test_redeem__half_of_strategy_assets_from_locked_lossy_strategy_with_unreali ) # withdraw a quarter deposit (half of strategy debt) shares = amount liquid_strategy = create_strategy(vault) - lossy_strategy = create_locked_strategy(vault) + lossy_strategy = create_lossy_strategy(vault) strategies = [lossy_strategy, liquid_strategy] max_loss = 10_000 @@ -1840,9 +1840,9 @@ def test_redeem__half_of_strategy_assets_from_locked_lossy_strategy_with_unreali add_debt_to_strategy(gov, strategy, vault, amount_per_strategy) # lose half of assets in lossy strategy - asset.transfer(gov, amount_to_lose, sender=lossy_strategy) + lossy_strategy.setLoss(gov, amount_to_lose, sender=lossy_strategy) # Lock half the remaining funds. - lossy_strategy.setLockedFunds(amount_to_lock, DAY, sender=gov) + lossy_strategy.setLockedFunds(amount_to_lock, sender=gov) tx = vault.redeem( amount_to_withdraw, @@ -1861,7 +1861,7 @@ def test_redeem__half_of_strategy_assets_from_locked_lossy_strategy_with_unreali event = list(tx.decode_logs(vault.Withdraw)) - assert len(event) >= 1 + assert len(event) > 1 n = len(event) - 1 assert event[n].sender == fish assert event[n].receiver == fish @@ -1889,7 +1889,7 @@ def test_redeem__half_of_strategy_assets_from_locked_lossy_strategy_with_unreali assert asset.balanceOf(vault) == 0 assert asset.balanceOf(liquid_strategy) == amount_per_strategy - expected_liquid_out assert ( - asset.balanceOf(lossy_strategy) + asset.balanceOf(lossy_strategy.yieldSource()) == amount_per_strategy - amount_to_lose - expected_locked_out ) # withdrawn from strategy assert (