TW
Tripwire
Find contracts. Test them. Review real vulns.

Analysis Run

019d5666-7189-71df-84df-536a07997697

failed
Chain ID
1
Address
Block Number
Created
Sat, Apr 4, 2026 2:50 AM
Review Summary

What survived the funnel

Open this run to answer one question: is this real attacker-relevant behavior with real impact, or just normal contract plumbing?

Confirmed
0
Validated
0
Source
Verified
Review Backing
Present
Best current signal
Untrusted DELEGATECALL target reachable
crit 0 high 0 proxy context implementation known
Review Checklist
1. Read the findings table first. Ignore raw jobs until the result looks real.
2. Confirm attacker control, not just the existence of a sink.
3. Confirm money or privilege impact.
4. Keep it if the behavior is real. Kill it if this is normal proxy, admin, or upgrade plumbing.

Processing Jobs

Status & attempts
ID Type Status Attempts Heartbeat
42736 artifact.fetch complete 1 / 3 1 week ago
42737 proxy.resolve complete 1 / 3 1 week ago
42738 tool.cast_disasm complete 1 / 3 1 week ago
42739 analysis.bundle complete 1 / 3 1 week ago
42740 capability.graph complete 1 / 3 1 week ago
42741 detector.run complete 1 / 3 1 week ago
42742 validation.fork complete 1 / 3 1 week ago
42918 report.consolidate complete 1 / 6 1 week ago
42919 tool.slither failed 2 / 2 1 week ago

Artifact

Runtime bytecode: available
Creation TX:
Deployer:
Proxy: yes onchain_only
Verified Source: yes
Proxy evidence
{
    "method": "minimal_proxy",
    "status": "onchain_only",
    "implementation": "0xec8902afffb06d0b075ea2d6fb3a45ec8598c39b",
    "onchain_method": "minimal_proxy"
}

Slither

tool.slither
Status
failed 500 Server Error: Internal Server Error for url: https://tripwire.topgun.bot/internal/jobs/42919/complete
Attempts
2 / 2
Findings
0
Solc
View stderr
Traceback (most recent call last):
  File "/var/www/tripwire/worker/worker.py", line 199, in main
    laravel_client.complete_job(job_id, payload if payload else None)
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/var/www/tripwire/worker/laravel_client.py", line 27, in complete_job
    return self._post(f"/internal/jobs/{job_id}/complete", payload=payload)
           ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/var/www/tripwire/worker/laravel_client.py", line 17, in _post
    response.raise_for_status()
    ~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "/var/www/tripwire/.venv/lib/python3.13/site-packages/requests/models.py", line 1026, in raise_for_status
    raise HTTPError(http_error_msg, response=self)
requests.exceptions.HTTPError: 500 Server Error: Internal Server Error for url: https://tripwire.topgun.bot/internal/jobs/42919/complete

Codex

report.consolidate
Job Status
complete
Attempts
1 / 6
Findings
2
No findings?
no
Summary
Found 2 exploitable signature-validation issues affecting the GuruFund implementation: signed payloads can be replayed across different funds, and withdrawal quotes can be executed after fund state/price changes to over-withdraw value.
View stdout
{"no_findings":false,"summary":"Found 2 exploitable signature-validation issues affecting the GuruFund implementation: signed payloads can be replayed across different funds, and withdrawal quotes can be executed after fund state/price changes to over-withdraw value.","findings":[{"severity":"high","category":"auth","title":"Signed payloads are not bound to a specific fund instance","exploit_scenario":"A user who holds shares in two different GuruFund clones can request a legitimate signed withdrawal for Fund A, then submit the exact same payload to Fund B. Because signature verification is centralized in the factory and the signed struct omits the target fund address, Fund B will accept Fund A's quote, burn Fund B shares, and pay out Fund A's signed amounts whenever Fund B has compatible liquidity/assets. The same replay surface exists for owner-only signed actions across multiple funds owned by the same guru.","recommendation":"Bind every signed action to the specific fund address and action type. The simplest fix is to include `address(this)` (and preferably an explicit action discriminator/function selector) in the signed struct, or move EIP-712 verification into each fund so each clone has its own domain separator. Use per-fund/per-action nonces rather than a factory-global user nonce.","evidence":"`GuruFund` forwards signature checks to the shared factory with `fundFactory.verifySignature(msg.sender, _payload)`. The factory's `SIGNED_ACTION_TYPEHASH` only covers `(nonce, account, data, expiresAt)`, and `_verifyEIP712` hashes only those fields. No fund identifier or `address(this)` is ever signed, so the same payload is valid for any fund clone that calls the same factory verifier.","locations":["contracts/GuruFund.sol:258","contracts/GuruFund.sol:338","contracts/GuruFund.sol:385","contracts/GuruFund.sol:471","contracts/GuruFund.sol:506","contracts/GuruFund.sol:542","contracts/GuruFund.sol:571","contracts/GuruFund.sol:655","contracts/FundFactory.sol:23","contracts/FundFactory.sol:151","contracts/helpers/EIP712Helper.sol:66"]},{"severity":"medium","category":"auth","title":"Withdrawal signatures remain valid after rebalances, fee mints, and price moves","exploit_scenario":"A user can obtain a signed withdrawal quote, then wait until the fund state becomes less favorable to remaining LPs before executing it. `withdraw()` does not bind the payload to the fund's current `nonce`, asset set, or a protocol-enforced short validity window; it simply burns shares, executes the signed swaps, and pays the signed `netOutput`. If prices move down, the guru rebalances, or management fees dilute supply before execution, a stale quote can withdraw more value than the shares are currently worth, with the shortfall socialized to other investors.","recommendation":"Treat withdrawals like deposits: bind them to a current fund-state nonce (or another state root) and reject stale payloads after any rebalance/close/management-fee event. Enforce a maximum on-chain validity window instead of relying only on caller-supplied `expiresAt`, and validate post-swap output/value against current balances before paying out.","evidence":"The contract documents `nonce` as stale-state protection and increments it on rebalances/swaps, but only `deposit()` checks `_deposit.nonce == nonce`. `withdraw()` has no comparable freshness check and blindly trusts signed `swaps` and `amountsWei`. The factory also stores `signatureValidityPeriod`, but verification ignores it and only checks `_payload.expiresAt >= block.number`, so quote lifetime is whatever the offchain signer put in the payload.","locations":["contracts/GuruFund.sol:83","contracts/GuruFund.sol:403","contracts/GuruFund.sol:497","contracts/GuruFund.sol:532","contracts/GuruFund.sol:560","contracts/GuruFund.sol:571","contracts/GuruFund.sol:597","contracts/FundFactory.sol:35","contracts/FundFactory.sol:144","contracts/helpers/EIP712Helper.sol:71"]}]}
View stderr
OpenAI Codex v0.118.0 (research preview)
--------
workdir: /tmp/codex-uls25_n0
model: gpt-5.4
provider: openai
approval: never
sandbox: read-only
reasoning effort: high
reasoning summaries: none
session id: 019d5667-27fc-7120-b1b2-ac158a14ba0f
--------
user
Context: The user requested the proxy at 0xba4f097d22f283e619873f8e034755fc2e5a7c90.
Audit the implementation at 0xec8902afffb06d0b075ea2d6fb3a45ec8598c39b.
Findings should refer to the implementation unless explicitly about proxy mechanics.

You are a security auditor. Audit this Solidity codebase for exploitable vulnerabilities.
Focus on: reentrancy, access control, upgradeability/proxy issues, signature validation, oracle manipulation,
accounting/invariant breaks, external call patterns, unchecked return values, and dangerous delegatecall.

Scope rules:
- You MUST fully read `AUDIT_BUNDLE.txt` before concluding. It contains the complete contents of all first-party Solidity sources in this workspace.
- Treat `lib/` and `@openzeppelin/` as third-party dependencies; skip them unless there is a concrete misuse angle in first-party code.
- If there are only dependency files and no first-party sources, set no_findings=true and explain in summary.

Output MUST be valid JSON matching the provided schema.
- If you find nothing meaningful: set no_findings=true and findings=[].
- Otherwise: set no_findings=false and include findings with severity/category/title and file/line locations when possible.

Output constraints (IMPORTANT):
- severity MUST be exactly one of: info, low, medium, high, critical (lowercase).
- category MUST be exactly one of: access_control, reentrancy, math, oracle, upgradeability, auth, other.
Keep summary short and concrete.
warning: Codex could not find system bubblewrap on PATH. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.
exec
/bin/bash -lc pwd in /tmp/codex-uls25_n0
 succeeded in 0ms:
/tmp/codex-uls25_n0

exec
/bin/bash -lc 'rg --files' in /tmp/codex-uls25_n0
 succeeded in 0ms:
codex-output-schema.json
AUDIT_BUNDLE.txt
contracts/GuruFund.sol
contracts/helpers/EIP712Helper.sol
contracts/helpers/SwapHelper.sol
contracts/helpers/TransferHelper.sol
@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol
contracts/interfaces/IWETH.sol
@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol
@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol
@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol
contracts/structs/InitialDeposit.sol
contracts/structs/SignedPayload.sol
contracts/structs/AssetIndex.sol
contracts/structs/DepositAmounts.sol
contracts/structs/Swap.sol
contracts/structs/WithdrawalAmounts.sol
contracts/FundFactory.sol
contracts/lib/Error.sol
contracts/lib/FundAction.sol
@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol
@openzeppelin/contracts/interfaces/IERC1363.sol
@openzeppelin/contracts/interfaces/IERC20.sol
@openzeppelin/contracts/interfaces/IERC1271.sol
@openzeppelin/contracts/interfaces/draft-IERC6093.sol
@openzeppelin/contracts/interfaces/IERC5267.sol
@openzeppelin/contracts/interfaces/IERC165.sol
@openzeppelin/contracts/proxy/Clones.sol
@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol
@openzeppelin/contracts/token/ERC20/IERC20.sol
@openzeppelin/contracts/token/ERC20/ERC20.sol
@openzeppelin/contracts/utils/Panic.sol
@openzeppelin/contracts/utils/Address.sol
@openzeppelin/contracts/utils/Errors.sol
@openzeppelin/contracts/utils/Context.sol
@openzeppelin/contracts/utils/Pausable.sol
@openzeppelin/contracts/access/Ownable.sol
@openzeppelin/contracts/utils/StorageSlot.sol
@openzeppelin/contracts/utils/ShortStrings.sol
@openzeppelin/contracts/utils/Strings.sol
@openzeppelin/contracts/utils/introspection/IERC165.sol
@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol
@openzeppelin/contracts/utils/cryptography/ECDSA.sol
@openzeppelin/contracts/utils/cryptography/EIP712.sol
@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol
@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol
@openzeppelin/contracts/utils/math/SafeCast.sol
@openzeppelin/contracts/utils/math/SignedMath.sol
@openzeppelin/contracts/utils/math/Math.sol

exec
/bin/bash -lc "wc -l AUDIT_BUNDLE.txt && sed -n '1,260p' AUDIT_BUNDLE.txt && printf '\\n---CHUNK---\\n' && sed -n '261,520p' AUDIT_BUNDLE.txt && printf '\\n---CHUNK---\\n' && sed -n '521,780p' AUDIT_BUNDLE.txt && printf '\\n---CHUNK---\\n' && sed -n '781,1040p' AUDIT_BUNDLE.txt && printf '\\n---CHUNK---\\n' && sed -n '1041,1300p' AUDIT_BUNDLE.txt" in /tmp/codex-uls25_n0
 succeeded in 0ms:
1739 AUDIT_BUNDLE.txt
This file concatenates the full contents of first-party Solidity sources for auditing.
Do not treat dependency code under lib/ or @openzeppelin/ as first-party.

================================================================================
FILE: contracts/FundFactory.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

import { Ownable } from '@openzeppelin/contracts/access/Ownable.sol';
import { Pausable } from '@openzeppelin/contracts/utils/Pausable.sol';
import { ERC20 } from '@openzeppelin/contracts/token/ERC20/ERC20.sol';
import { SafeERC20 } from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
import { Clones } from '@openzeppelin/contracts/proxy/Clones.sol';

import { GuruFund } from 'contracts/GuruFund.sol';
import { EIP712Helper } from 'contracts/helpers/EIP712Helper.sol';
import { SignedPayload } from 'contracts/structs/SignedPayload.sol';
import { IWETH } from 'contracts/interfaces/IWETH.sol';
import { InitialDeposit } from 'contracts/structs/InitialDeposit.sol';
import { Error } from 'contracts/lib/Error.sol';

contract FundFactory is EIP712Helper, Pausable {
    // Immutable variables (stored in code, not storage)
    IWETH public immutable weth;
    GuruFund public immutable fundImplementation;

    // Constants (not stored in storage)
    bytes32 public constant SIGNED_ACTION_TYPEHASH =
        keccak256(
            'SignedAction(uint256 nonce,address account,bytes data,uint256 expiresAt)'
        );
    uint24 public constant FEE_DENOMINATOR = 100_000;
    uint16 public constant MAX_PROFIT_FEE = 20_000; // 20%
    uint16 public constant GURU_PROFIT_FEE = 10_000; // 10%
    uint16 public constant MAX_DEPOSIT_FEE = 1_000; // 1%
    uint16 public constant MAX_SWAP_FEE = 200; // 0.2%

    // Storage variables packed into slots
    address public vault; // slot 0 (20 bytes)
    uint32 public signatureValidityPeriod = 10; // slot 0 (4 bytes)
    uint16 public protocolProfitFee = 5_000; // slot 0 (2 bytes)
    uint16 public protocolDepositFee = 1_000; // slot 0 (2 bytes)
    uint16 public protocolSwapFee = 200; // slot 0 (2 bytes)

    address public guruBurner; // slot 1 (20 bytes)
    uint64 public minimumGuruInitialDepositValue = 1000_000000; // slot 1 (8 bytes)
    uint64 public protocolFundCreationFeeValue = 100_000000; // slot 1 (8 bytes)

    address public admin; // slot 2 (20 bytes)

    // events
    event VaultUpdated(address newVault);
    event GuruBurnerUpdated(address newGuruBurner);
    event AdminUpdated(address newAdmin);
    event MinimumGuruInitialDepositValueUpdated(
        uint64 newMinimumInitialDeposit
    );
    event SignatureValidityPeriodUpdated(uint32 newSignatureValidityPeriod);
    event ProtocolProfitFeeUpdated(uint16 newProtocolProfitFee);
    event ProtocolFundCreationFeeUpdated(uint64 newProtocolFundCreationFee);
    event ProtocolDepositFeeUpdated(uint16 newProtocolDepositFee);
    event ProtocolSwapFeeUpdated(uint16 newProtocolSwapFee);
    event FundCreated(address indexed fund, address indexed creator);

    // errors
    error InsufficientFirstDeposit(uint256 received, uint256 required);
    error FeesTooHigh(uint256 fee, uint256 max);

    constructor(
        address _offchainSigner,
        address _vault,
        address _guruBurner,
        address _admin,
        IWETH _wethAddress
    ) Ownable(msg.sender) EIP712Helper('GURU.FUND', 'v0.1.0', _offchainSigner) {
        vault = _vault;
        guruBurner = _guruBurner;
        weth = _wethAddress;
        admin = _admin;
        fundImplementation = new GuruFund();
    }

    function getTotalProfitFee() public view returns (uint256) {
        return GURU_PROFIT_FEE + protocolProfitFee;
    }

    // config
    function setVault(address _newVault) public onlyOwner {
        vault = _newVault;
        emit VaultUpdated(_newVault);
    }

    function setGuruBurner(address _newGuruBurner) public onlyOwner {
        guruBurner = _newGuruBurner;
        emit GuruBurnerUpdated(_newGuruBurner);
    }

    function setAdmin(address _newAdmin) public onlyOwner {
        admin = _newAdmin;
        emit AdminUpdated(_newAdmin);
    }

    function setProtocolFundCreationFee(
        uint64 _newProtocolFundCreationFee
    ) public onlyOwner {
        protocolFundCreationFeeValue = _newProtocolFundCreationFee;
        emit ProtocolFundCreationFeeUpdated(_newProtocolFundCreationFee);
    }

    function setProtocolDepositFee(
        uint16 _newProtocolDepositFee
    ) public onlyOwner {
        require(
            _newProtocolDepositFee <= MAX_DEPOSIT_FEE,
            FeesTooHigh(_newProtocolDepositFee, MAX_DEPOSIT_FEE)
        );
        protocolDepositFee = _newProtocolDepositFee;
        emit ProtocolDepositFeeUpdated(_newProtocolDepositFee);
    }

    function setProtocolProfitFee(
        uint16 _newProtocolFeeOnProfits
    ) public onlyOwner {
        require(
            GURU_PROFIT_FEE + _newProtocolFeeOnProfits <= MAX_PROFIT_FEE,
            FeesTooHigh(
                GURU_PROFIT_FEE + _newProtocolFeeOnProfits,
                MAX_PROFIT_FEE
            )
        );
        protocolProfitFee = _newProtocolFeeOnProfits;
        emit ProtocolProfitFeeUpdated(_newProtocolFeeOnProfits);
    }

    function setProtocolSwapFee(uint16 _newProtocolSwapFee) public onlyOwner {
        require(
            _newProtocolSwapFee <= MAX_SWAP_FEE,
            FeesTooHigh(_newProtocolSwapFee, MAX_SWAP_FEE)
        );
        protocolSwapFee = _newProtocolSwapFee;
        emit ProtocolSwapFeeUpdated(_newProtocolSwapFee);
    }

    function setMinimumFirstDepositValue(uint64 _newMinimum) public onlyOwner {
        minimumGuruInitialDepositValue = _newMinimum;
        emit MinimumGuruInitialDepositValueUpdated(_newMinimum);
    }

    function setSignatureValidityPeriod(
        uint32 _newSignatureValidityPeriod
    ) public onlyOwner {
        signatureValidityPeriod = _newSignatureValidityPeriod;
        emit SignatureValidityPeriodUpdated(_newSignatureValidityPeriod);
    }

    function verifySignature(
        address account,
        SignedPayload calldata _signedPayload
    ) public {
        _verifyEIP712(SIGNED_ACTION_TYPEHASH, account, _signedPayload);
    }

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    /**
     * @notice Creates a new fund with a minimum deposit of 1000 USDT worth of WETH
     * @param _name Name of the fund token
     * @param _symbol Symbol of the fund token
     * @param _signedPayload Signed payload containing expiration, signature, and
     * the encoded price feed with latest WETH/USDT price
     */
    function createFund(
        string calldata _name,
        string calldata _symbol,
        SignedPayload calldata _signedPayload
    ) public payable whenNotPaused {
        verifySignature(msg.sender, _signedPayload);

        InitialDeposit memory initialDeposit = abi.decode(
            _signedPayload.data,
            (InitialDeposit)
        );

        // Any check on the deposit min amount is handled offchain: here we
        // just verify that the amount received is matching the signed payload
        require(
            initialDeposit.amountsWei.input +
                initialDeposit.amountsWei.buybackFee ==
                msg.value,
            Error.MismatchingDepositAmount(initialDeposit.amountsWei, msg.value)
        );

        address fund = Clones.clone(address(fundImplementation));

        GuruFund(payable(fund)).initialize{ value: msg.value }(
            msg.sender,
            _name,
            _symbol,
            initialDeposit
        );

        emit FundCreated(fund, msg.sender);
    }
}

================================================================================
FILE: contracts/GuruFund.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

import { OwnableUpgradeable } from '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol';
import { ReentrancyGuardUpgradeable } from '@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol';
import { ERC20Upgradeable } from '@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol';
import { SafeERC20 } from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
import { ERC20 } from '@openzeppelin/contracts/token/ERC20/ERC20.sol';

import { SignedPayload } from 'contracts/helpers/EIP712Helper.sol';
import { SwapHelper } from 'contracts/helpers/SwapHelper.sol';
import { TransferHelper } from 'contracts/helpers/TransferHelper.sol';
import { Error } from 'contracts/lib/Error.sol';
import { FundAction } from 'contracts/lib/FundAction.sol';
import { InitialDeposit } from 'contracts/structs/InitialDeposit.sol';
import { DepositAmounts } from 'contracts/structs/DepositAmounts.sol';
import { WithdrawalAmounts } from 'contracts/structs/WithdrawalAmounts.sol';
import { FundFactory } from 'contracts/FundFactory.sol';
import { AssetIndex } from 'contracts/structs/AssetIndex.sol';

/**
 * @title GuruFund
 * @author @numa0x
 * @notice This is the contract for a GuruFund, which is a fund handled by a manager (Guru) that invests in a set of digital assets.
 * The fund is represented by a FundToken, which is minted to the Guru when the fund is created as well as to investors when they deposit.
 * - Implements 6-decimals ERC20 for fund tokens, representing the users' shares of the fund
 * - Implements cooldowns for ERC20 transfers to have users wait before withdrawing their funds
 * - Requires signed payloads for every key operations
 * - Funds support up to 8 ERC20 assets
 * - Tracks invested capital per user to accurately compute PnL and fees
 * - Uses nonce system to prevent outdated deposit transactions
 * - Has protocol-wide pause functionality
 */
contract GuruFund is
    ReentrancyGuardUpgradeable,
    OwnableUpgradeable,
    SwapHelper,
    ERC20Upgradeable,
    TransferHelper
{
    using SafeERC20 for ERC20;

    /// Constants ///

    /**

---CHUNK---
     * @notice The duration of the grace period after the fund is closed. The protocol will
     * not allow any withdrawals after this period.
     */
    uint256 public constant GRACE_PERIOD_DURATION = 180 days;
    uint256 public constant MAX_DEPOSIT_COOLDOWN = 90 days;
    uint256 public constant MANAGEMENT_FEE_PERIOD = 30 days;
    /**
     * @notice The denominator for the management fee mint amount.
     * Monthly rate denominator:
     * ------------------------------------------------------------
     *   Two percent yearly rate: 2/100
     * * Pro-rated monthly: 1/12
     * = 2/100 * 1/12 = 2/1200 = 1/600
     * ------------------------------------------------------------
     * So: if the minted amount needs to be 1/600 of the total supply AFTER the mint,
     * then we need to mint 1/599 of the total supply BEFORE the mint.
     */
    uint256 public constant MANAGEMENT_FEE_DENOMINATOR = 599;

    /// States ///

    /**
     * @notice The factory that created this fund.
     */
    FundFactory public immutable fundFactory;

    /**
     * @notice Whether the fund is open for deposits and withdrawals.
     */
    bool public isOpen;

    /**
     * @notice The fund's assets
     */
    ERC20[8] public assets;

    /**
     * @notice A nonce updated on every rebalance, to prevent users from
     * submitting deposits with outdated swaps.
     */
    uint256 public nonce;

    /**
     * @notice The minimum deposit value for a user to deposit into the fund.
     */
    uint256 public minUserDepositValue;

    /**
     * @notice The minimum time for a user to wait before withdrawing their deposit
     */
    uint256 public minUserDepositCooldown;

    /**
     * @notice The timestamp of the last management fee mint
     */
    uint256 public latestManagementFeeMint;

    /**
     * @notice The end of the grace period after the Guru closed the fund, users can
     * withdraw their funds up to this timestamp
     */
    uint256 public gracePeriodEnd;

    /**
     * @notice The invested capital for each user.
     * @dev This is the sum of the positive TVL deltas during deposits, subtracted
     * of all capital removed through withdrawals.
     */
    mapping(address => uint256) public investedCapital;

    /**
     * @notice The cooldowns of the users
     */
    mapping(address => CooldownsByUser) private _cooldownsByUser;

    /**
     * @notice User deposit cooldown entry
     * @dev The offset is used to skip the cooldowns that have already been processed
     */
    struct CooldownsByUser {
        uint256 offset;
        Cooldown[] cooldowns;
    }

    /**
     * @param timestamp The timestamp of the cooldown end
     * @param amount The amount of tokens that are still locked
     */
    struct Cooldown {
        uint256 timestamp;
        uint256 amount;
    }

    /// Events ///

    /**
     * @notice Emitted when a deposit is made to the fund from a Disciple.
     * @param from The address of the Disciple
     * @param tvlDelta The TVL difference between the initial TVL and the final TVL
     * @param amountsWei The deposit amounts in wei units
     * @param amountsValue The deposit amounts in USDT units
     */
    event Deposited(
        address indexed from,
        int256 tvlDelta,
        uint256 fundTokensMinted,
        DepositAmounts amountsWei,
        DepositAmounts amountsValue
    );

    /**
     * @notice Emitted when an ERC20 asset is added into the fund by the Guru.
     * @param asset The asset deposited
     * @param amount The amount of the asset deposited
     * @param tvlDelta The TVL difference between the initial TVL and the final TVL
     */
    event DepositedAsset(ERC20 asset, uint256 amount, int256 tvlDelta);

    /**
     * @notice Emitted when the assets of the fund are updated.
     * @param assets The updated list of assets
     */
    event AssetsUpdated(ERC20[8] assets);

    /**
     * @notice Emitted when the fund is rebalanced.
     */
    event Rebalanced();

    /**
     * @notice Emitted when a user withdraws their share of the fund.
     * This will swap the assets back to ETH and return it to the user.
     * @param from The address of the user liquidating
     * @param burnAmount The amount of Fund tokens burned
     * @param amountsWei The withdrawal amounts in wei units
     * @param amountsValue The withdrawal amounts in USDT units
     * @param tvlDelta The TVL difference between the initial TVL and the final TVL
     */
    event Withdrawn(
        address indexed from,
        uint256 burnAmount,
        WithdrawalAmounts amountsWei,
        WithdrawalAmounts amountsValue,
        int256 tvlDelta
    );

    /**
     * @notice Emitted when the fund is closed by the Guru
     */
    event Closed();

    /**
     * @notice Emitted when the minimum deposit value for a user is updated.
     * @param newMinimum The new minimum deposit value
     */
    event MinUserDepositValueUpdated(uint256 newMinimum);

    /**
     * @notice Emitted when the minimum deposit cooldown for a user is updated.
     * @param newMinimum The new minimum deposit cooldown
     */
    event MinUserDepositCooldownUpdated(uint256 newMinimum);

    /**
     * @notice Emitted when the management fee is minted.
     * @param amount The amount of management fee minted
     */
    event ManagementFeeMinted(uint256 amount);

    /**
     * @notice Emitted when the grace period is extended.
     * @param newGracePeriodEnd The new grace period end
     */
    event GracePeriodExtended(uint256 newGracePeriodEnd);

    /**
     * @notice Emitted when the protocol owner claims remaining funds for buyback and burn
     */
    event AbandonedFundsClaimed();

    // errors
    error FundClosed();
    error ProtocolHalted();
    error UnexpectedFeeData(
        uint256 fees,
        uint256 maxExpectedFees,
        address feeRecipient
    );
    error MaxCooldownExceeded(uint256 cooldown);
    error DepositMustIncreaseTvl(int256 tvlDelta);
    error InvalidDepositNonce(uint256 depositNonce, uint256 currentNonce);
    error InvalidSwapDirection(address tokenFrom, address tokenTo);
    error AssetIndexAlreadyOccupied(uint8 index, ERC20 assetAtIndex);
    error CooldownNotExpired(uint256 availableBalance, uint256 transferAmount);
    error InvalidTransferAmount(
        uint256 availableBalance,
        uint256 transferAmount
    );
    error ManagementFeePeriodNotElapsed();
    error GracePeriodEnded();

    // modifiers
    modifier onlyOpen() {
        require(isOpen, FundClosed());
        _;
    }

    modifier onlyNotPaused() {
        require(!fundFactory.paused(), ProtocolHalted());
        _;
    }

    modifier verifyingSignature(SignedPayload calldata _payload) {
        fundFactory.verifySignature(msg.sender, _payload);
        _;
    }

    /**
     * @dev This will only be called once when deploying the Fund Factory.
     * Clones initializers will be called by the FundFactory.
     */
    constructor() {
        fundFactory = FundFactory(msg.sender);
        _disableInitializers();
    }

    /// External Functions ///

    /**
     * @notice Initializes the fund with a deposit of ETH, which is wrapped.
     * @dev Only the fund factory can call this function, after verifying the signature of the payload.
     * @param _guru The address of the Guru (owner) of the fund
     * @param _initialDeposit The initial deposit of the fund
     */
    function initialize(
        address _guru,
        string calldata _name,
        string calldata _symbol,
        InitialDeposit calldata _initialDeposit
    ) external payable initializer {
        require(msg.sender == address(fundFactory), Error.Unauthorized());
        require(
            _initialDeposit.minUserDepositCooldown <= MAX_DEPOSIT_COOLDOWN,
            MaxCooldownExceeded(_initialDeposit.minUserDepositCooldown)
        );

        __SwapHelper_init_unchained(
            address(fundFactory.weth()),
            address(fundFactory.vault())
        );
        __Ownable_init_unchained(_guru);
        __ERC20_init_unchained(_name, _symbol);

        // Open the fund
        minUserDepositValue = _initialDeposit.minUserDepositValue;
        minUserDepositCooldown = _initialDeposit.minUserDepositCooldown;
        latestManagementFeeMint = block.timestamp; // Allows first mint in the next period
        isOpen = true;

        // Wrap the initial deposit net amount

---CHUNK---
        _wrapETH(msg.value - _initialDeposit.amountsWei.buybackFee);

        // Initialize the assets array with WETH
        assets[0] = ERC20(address(fundFactory.weth()));

        emit AssetsUpdated(assets);

        // Mint the fund tokens to the Guru
        _mint(_guru, _initialDeposit.amountsValue.input);

        // Update the invested capital
        investedCapital[_guru] = uint256(_initialDeposit.amountsValue.input);

        // Handle buyback and burn
        _safeTransferETH(
            fundFactory.guruBurner(),
            _initialDeposit.amountsWei.buybackFee
        );

        emit Deposited(
            _guru,
            int256(_initialDeposit.amountsValue.input), // first ∆ TVL is the initial deposit value
            _initialDeposit.amountsValue.input, // first deposit mint amount matches its USDT value
            _initialDeposit.amountsWei,
            _initialDeposit.amountsValue
        );
    }

    /**
     * @notice Gurus can call this function to directly deposit an asset into the fund.
     * @param _signedAssetDeposit The signed payload containing the asset to deposit
     */
    function depositAsset(
        SignedPayload calldata _signedAssetDeposit
    ) external nonReentrant onlyOwner verifyingSignature(_signedAssetDeposit) {
        FundAction.AssetDeposit memory _deposit = abi.decode(
            _signedAssetDeposit.data,
            (FundAction.AssetDeposit)
        );

        require(
            _deposit.tvlDelta >= 0,
            DepositMustIncreaseTvl(_deposit.tvlDelta)
        );

        /// 1. Update asset index
        require(
            assets[_deposit.assetIndex] == ERC20(address(0)) ||
                assets[_deposit.assetIndex] == _deposit.asset,
            AssetIndexAlreadyOccupied(
                _deposit.assetIndex,
                assets[_deposit.assetIndex]
            )
        );

        assets[_deposit.assetIndex] = _deposit.asset;

        /// 2. Transfer deposit in
        _deposit.asset.safeTransferFrom(
            msg.sender,
            address(this),
            _deposit.amount
        );

        /// 3. Mint fund tokens
        _mint(msg.sender, _deposit.mintAmount);

        /// 4. Update invested capital
        investedCapital[msg.sender] += uint256(_deposit.tvlDelta);

        emit DepositedAsset(_deposit.asset, _deposit.amount, _deposit.tvlDelta);
    }

    /**
     * @notice Deposits ETH into the fund, which will get swapped and rebalanced
     * accordingly to the current fund composition.
     * @param _signedDepositPayload The signed payload containing the deposit data,
     * including the amount of ETH to deposit and the swaps to execute.
     */
    function deposit(
        SignedPayload calldata _signedDepositPayload
    )
        external
        payable
        nonReentrant
        onlyOpen
        onlyNotPaused
        verifyingSignature(_signedDepositPayload)
    {
        // Prevents management from accidentally depositing into any fund
        require(msg.sender != fundFactory.admin(), Error.Unauthorized());

        FundAction.Deposit memory _deposit = abi.decode(
            _signedDepositPayload.data,
            (FundAction.Deposit)
        );

        require(
            _deposit.nonce == nonce,
            InvalidDepositNonce(_deposit.nonce, nonce)
        );

        require(
            _deposit.tvlDelta >= 0,
            DepositMustIncreaseTvl(_deposit.tvlDelta)
        );

        /// 1. Validate fees and deposit amounts

        uint256 fees = _deposit.amountsWei.fee + _deposit.amountsWei.buybackFee;

        require(
            fees <= (msg.value * fundFactory.protocolDepositFee()) / 100_000 &&
                _deposit.feeRecipient != address(0),
            UnexpectedFeeData(
                fees,
                fundFactory.protocolDepositFee(),
                _deposit.feeRecipient
            )
        );

        uint256 netDeposit = msg.value - fees;

        require(
            _deposit.amountsWei.input == netDeposit,
            Error.MismatchingDepositAmount(_deposit.amountsWei, netDeposit)
        );

        /// 2. Wrap ETH
        _wrapETH(netDeposit);

        /// 3. Loop and swap
        _executeSwaps(_deposit.swaps);

        /// 4. Mint fund tokens
        _mint(msg.sender, _deposit.mintAmount);

        /// 5. Update invested capital
        investedCapital[msg.sender] += uint256(_deposit.tvlDelta);

        /// 6. Collect fees
        if (_deposit.amountsWei.fee > 0) {
            _safeTransferETH(_deposit.feeRecipient, _deposit.amountsWei.fee);
        }

        if (_deposit.amountsWei.buybackFee > 0) {
            _safeTransferETH(
                fundFactory.guruBurner(),
                _deposit.amountsWei.buybackFee
            );
        }

        emit Deposited(
            msg.sender,
            _deposit.tvlDelta,
            _deposit.mintAmount,
            _deposit.amountsWei,
            _deposit.amountsValue
        );
    }

    /**
     * @notice Swaps tokens for ETH.
     * @param _signedSwapPayload The signed payload containing the swap data
     */
    function swapTokensForETH(
        SignedPayload calldata _signedSwapPayload
    )
        external
        nonReentrant
        onlyOpen
        onlyNotPaused
        onlyOwner
        verifyingSignature(_signedSwapPayload)
    {
        FundAction.SingleSwap memory swapAction = abi.decode(
            _signedSwapPayload.data,
            (FundAction.SingleSwap)
        );

        require(
            address(swapAction.swap.tokenOut) == address(fundFactory.weth()),
            InvalidSwapDirection(
                address(swapAction.swap.tokenIn),
                address(swapAction.swap.tokenOut)
            )
        );

        _executeSingleSwap(swapAction.swap);
        _updateAssets(swapAction.assetIndexes);

        unchecked {
            nonce++;
        }
    }

    /**
     * @notice Swaps ETH for tokens.
     * @param _signedSwapPayload The signed payload containing the swap data
     */
    function swapETHForTokens(
        SignedPayload calldata _signedSwapPayload
    )
        external
        nonReentrant
        onlyOpen
        onlyNotPaused
        onlyOwner
        verifyingSignature(_signedSwapPayload)
    {
        FundAction.SingleSwap memory swapAction = abi.decode(
            _signedSwapPayload.data,
            (FundAction.SingleSwap)
        );

        require(
            address(swapAction.swap.tokenIn) == address(fundFactory.weth()),
            InvalidSwapDirection(
                address(swapAction.swap.tokenIn),
                address(swapAction.swap.tokenOut)
            )
        );

        _executeSingleSwap(swapAction.swap);
        _updateAssets(swapAction.assetIndexes);

        unchecked {
            nonce++;
        }
    }

    /**
     * @notice Rebalances the fund by changing the allocations of the assets.
     * @param _signedRebalancePayload The signed payload containing the rebalancing data,
     * including the changes to apply to the asset lists and the swaps to execute.
     */
    function rebalance(
        SignedPayload calldata _signedRebalancePayload
    )
        external
        nonReentrant
        onlyOpen
        onlyNotPaused
        onlyOwner
        verifyingSignature(_signedRebalancePayload)
    {
        FundAction.Rebalance memory _rebalance = abi.decode(
            _signedRebalancePayload.data,
            (FundAction.Rebalance)
        );

        _updateAssets(_rebalance.assetIndexes);
        _executeSwaps(_rebalance.swaps);

        unchecked {
            nonce++;
        }

        emit Rebalanced();
    }

---CHUNK---

    /**
     * @notice Withdraws the user's share of the fund, swapping the assets back to ETH.
     * @param _signedWithdrawPayload The signed payload containing the withdrawal data,
     */
    function withdraw(
        SignedPayload calldata _signedWithdrawPayload
    ) external nonReentrant verifyingSignature(_signedWithdrawPayload) {
        if (!isOpen) {
            // Investors can withdraw only until the grace period ends
            require(block.timestamp <= gracePeriodEnd, GracePeriodEnded());
        }

        FundAction.Withdraw memory _userWithdrawal = abi.decode(
            _signedWithdrawPayload.data,
            (FundAction.Withdraw)
        );

        // 1. Burn tokens
        _burn(msg.sender, _userWithdrawal.burnAmount);

        // 2. Update invested capital
        unchecked {
            investedCapital[msg.sender] -= _userWithdrawal
                .amountsValue
                .investedCapital;
        }

        // 3. Execute swaps
        _executeSwaps(_userWithdrawal.swaps);

        // 4. Handle ETH transfers and fees
        _executeWithdrawalTransfers(_userWithdrawal.amountsWei);

        emit Withdrawn(
            msg.sender,
            _userWithdrawal.burnAmount,
            _userWithdrawal.amountsWei,
            _userWithdrawal.amountsValue,
            _userWithdrawal.tvlDelta
        );
    }

    /**
     * @notice Executes the withdrawal transfers, including fees.
     * @param amountsWei The withdrawal amounts in wei units
     */
    function _executeWithdrawalTransfers(
        WithdrawalAmounts memory amountsWei
    ) internal {
        if (amountsWei.grossPnl <= 0) {
            _unwrapETH(amountsWei.netOutput);
        } else {
            unchecked {
                _unwrapETH(
                    amountsWei.netOutput +
                        amountsWei.protocolFee +
                        amountsWei.guruFee
                );
            }

            _safeTransferETH(fundFactory.vault(), amountsWei.protocolFee);
            _safeTransferETH(owner(), amountsWei.guruFee);
        }
        _safeTransferETH(msg.sender, amountsWei.netOutput);
    }

    /**
     * @notice Mints the management fee to the admin.
     * @dev Only the management admin can call this function.
     */
    function mintManagementFee() external onlyOpen onlyNotPaused {
        require(msg.sender == fundFactory.admin(), Error.Unauthorized());
        require(
            block.timestamp - latestManagementFeeMint > MANAGEMENT_FEE_PERIOD,
            ManagementFeePeriodNotElapsed()
        );

        uint256 amount = totalSupply() / MANAGEMENT_FEE_DENOMINATOR;
        _mint(fundFactory.admin(), amount);
        latestManagementFeeMint = block.timestamp;

        emit ManagementFeeMinted(amount);
    }

    /**
     * @notice Closes the fund, liquidating all assets. Users will be able to withdraw their capital.
     * @param _signedClosePayload The signed payload containing the close data
     */
    function close(
        SignedPayload calldata _signedClosePayload
    )
        external
        nonReentrant
        onlyOpen
        onlyOwner
        verifyingSignature(_signedClosePayload)
    {
        isOpen = false;
        gracePeriodEnd = block.timestamp + GRACE_PERIOD_DURATION;

        // Liquidate all assets
        FundAction.Close memory _liquidation = abi.decode(
            _signedClosePayload.data,
            (FundAction.Close)
        );

        _executeSwaps(_liquidation.swaps);

        emit Closed();
    }

    /**
     * @notice Extends the grace period.
     * @param _newGracePeriodEnd The new grace period end
     */
    function extendGracePeriod(uint256 _newGracePeriodEnd) external {
        // Only protocol owner can extend the grace period
        require(
            msg.sender == fundFactory.owner() &&
                _newGracePeriodEnd > gracePeriodEnd,
            Error.Unauthorized()
        );
        gracePeriodEnd = _newGracePeriodEnd;
        emit GracePeriodExtended(_newGracePeriodEnd);
    }

    /**
     * @notice After the grace period ends, the protocol owner can claim any
     * remaining funds to buyback and burn $GURU.
     */
    function claimAbandonedFundsForBuybackAndBurn() external {
        require(
            !isOpen &&
                msg.sender == fundFactory.owner() &&
                block.timestamp > gracePeriodEnd,
            Error.Unauthorized()
        );
        _unwrapETH(fundFactory.weth().balanceOf(address(this)));
        _safeTransferETH(fundFactory.guruBurner(), address(this).balance);
        emit AbandonedFundsClaimed();
    }

    /**
     * @notice Updates the minimum deposit value for a user.
     * @param _newMinValue The new minimum deposit value
     */
    function updateMinUserDepositValue(
        uint256 _newMinValue
    ) external onlyOpen onlyOwner {
        minUserDepositValue = _newMinValue;
        emit MinUserDepositValueUpdated(_newMinValue);
    }

    /**
     * @notice Updates the minimum deposit cooldown for a user.
     * @param _newMinCooldown The new minimum deposit cooldown
     */
    function updateMinDepositCooldown(
        uint256 _newMinCooldown
    ) external onlyOpen onlyOwner {
        require(
            _newMinCooldown <= MAX_DEPOSIT_COOLDOWN,
            MaxCooldownExceeded(_newMinCooldown)
        );
        minUserDepositCooldown = _newMinCooldown;
        emit MinUserDepositCooldownUpdated(_newMinCooldown);
    }

    /**
     * @notice Returns the available balance for a user, i.e. the balance that is not cooling down.
     * @param _account The address of the user
     * @return availableBalance The available balance for the user
     */
    function availableBalanceOf(
        address _account
    ) external view returns (uint256 availableBalance) {
        availableBalance = balanceOf(_account);

        if (hasCooldown(_account)) {
            (uint256 lockedBalance, ) = _getCooldownDetails(_account);
            availableBalance -= lockedBalance;
        }
    }

    /**
     * @notice Returns the assets of the fund.
     */
    function getAssets() external view returns (ERC20[8] memory) {
        if (isOpen) {
            return assets;
        } else {
            ERC20[8] memory _assets;
            _assets[0] = ERC20(address(fundFactory.weth()));
            return _assets;
        }
    }

    /**
     * @notice Returns the cooldown details for a user.
     * @param _account The address of the user
     * @return cooldownDetails The cooldown details for the user
     */
    function getCooldownByUser(
        address _account
    ) public view returns (CooldownsByUser memory) {
        return _cooldownsByUser[_account];
    }

    /// Public Functions ///

    /**
     * @dev [ERC20] Using 6 decimals to match USDT precision
     */
    function decimals() public pure override returns (uint8) {
        return 6;
    }

    /**
     * @notice Returns whether a user has a cooldown.
     * @param _account The address of the user
     * @return Whether the user has a cooldown
     */
    function hasCooldown(address _account) public view returns (bool) {
        return _cooldownsByUser[_account].cooldowns.length > 0;
    }

    /**
     * @notice Disable direct token transfers
     */
    function transfer(address, uint256) public pure override returns (bool) {
        revert Error.Unauthorized();
    }

    /**
     * @notice Disable direct token transfers
     */
    function transferFrom(
        address,
        address,
        uint256
    ) public pure override returns (bool) {
        revert Error.Unauthorized();
    }

    /**
     * @notice Transfers shares to another account updating the invested capital.
     * @param to The address of the recipient
     * @param amount The amount of shares to transfer
     * @return Whether the transfer was successful
     */
    function transferShares(address to, uint256 amount) public returns (bool) {
        uint256 senderBalance = balanceOf(msg.sender);
        // Validate transfer amount
        require(
            senderBalance >= amount && amount != 0,
            InvalidTransferAmount(senderBalance, amount)
        );

        // Update invested capital for both sender and recipient when transferring between accounts

---CHUNK---
        unchecked {
            // Transfer amount validation ensures arithmetic safety: cannot divide by zero
            uint256 capitalTransferred = (amount *
                investedCapital[msg.sender]) / senderBalance;
            // `capitalTransferred` is proportional to `amount`, which is capped to sender balance,
            // so it cannot exceed sender's invested capital
            investedCapital[msg.sender] -= capitalTransferred;
            investedCapital[to] += capitalTransferred;
        }

        _transfer(msg.sender, to, amount);

        return true;
    }

    /**
     * @notice Disable ownership renouncement
     */
    function renounceOwnership() public pure override {
        revert Error.Unauthorized();
    }

    /**
     * @notice Transfer ownership to a new address
     * @param newOwner The address of the new owner
     */
    function transferOwnership(address newOwner) public override onlyOwner {
        require(newOwner != address(0), Error.Unauthorized());

        uint256 ownerBalance = balanceOf(owner());
        if (ownerBalance > 0) {
            transferShares(newOwner, ownerBalance);
        }

        _transferOwnership(newOwner);
    }

    /// Internal Functions ///

    /**
     * @dev Wraps ETH into WETH
     */
    function _wrapETH(uint256 amount) internal {
        fundFactory.weth().deposit{ value: amount }();
    }

    /**
     * @dev Unwraps WETH into ETH
     */
    function _unwrapETH(uint256 amount) internal {
        fundFactory.weth().withdraw(amount);
    }

    /**
     * @dev Updates the fund asset list.
     * NOTE: Validation of these asset list updates is done off-chain
     * @param _updates The updates to apply
     */
    function _updateAssets(AssetIndex[] memory _updates) internal {
        for (uint8 i = 0; i < _updates.length; i++) {
            assets[_updates[i].index] = _updates[i].asset;
        }

        emit AssetsUpdated(assets);
    }

    /**
     * @notice Returns the cooldown details for a user.
     * NOTE: this assumes that the user does have a cooldown, meaning:
     * `_cooldownsByUser[_user].cooldowns.length > 0`
     * @dev The offset represents the index before which all cooldowns have expired.
     * The loop is checking backwards, from the end of the cooldowns array (most
     * recent) to the beginning (earliest) and stopping when it finds a cooldown that
     * has eventually expired. This means that any previous cooldowns are also expired,
     * and we can update the offset with the current index.
     * @param account The address of the user
     * @return coolingDownBalance User balance that is still locked due to cooldown
     * @return offset The index offset of the cooldowns array, possibly to be updated in
     * the _cooldownsByUser struct: all cooldowns before this offset are expired.
     */
    function _getCooldownDetails(
        address account
    ) internal view returns (uint256 coolingDownBalance, uint256 offset) {
        uint256 newOffset = _cooldownsByUser[account].cooldowns.length;
        offset = _cooldownsByUser[account].offset;

        while (newOffset > offset) {
            Cooldown memory _cooldown = _cooldownsByUser[account].cooldowns[
                newOffset - 1
            ];
            if (block.timestamp <= _cooldown.timestamp) {
                unchecked {
                    coolingDownBalance += _cooldown.amount;
                    newOffset--;
                }
            } else {
                offset = newOffset;
            }
        }
    }

    /**
     * @dev [ERC20] Overrides the default ERC20 _update function to implement cooldown logic.
     * @param from The address of the user
     * @param to The address of the recipient
     * @param amount The amount of tokens to transfer
     */
    function _update(
        address from,
        address to,
        uint256 amount
    ) internal override {
        if (isOpen) {
            if (from == address(0) && to != fundFactory.admin()) {
                // When minting tokens (except for management fee), apply cooldown:
                _cooldownsByUser[to].cooldowns.push(
                    Cooldown({
                        timestamp: block.timestamp + minUserDepositCooldown,
                        amount: amount
                    })
                );
            } else if (hasCooldown(from)) {
                // Otherwise check cooldown for user:
                (uint256 lockedBalance, uint256 offset) = _getCooldownDetails(
                    from
                );

                // NOTE: lockedBalance is the amount of tokens that are still locked due to cooldown.
                // Therefore, the available balance for the user is:
                uint256 availableBalance = balanceOf(from) - lockedBalance;
                require(
                    availableBalance >= amount,
                    CooldownNotExpired(availableBalance, amount)
                );

                if (offset == _cooldownsByUser[from].cooldowns.length) {
                    delete _cooldownsByUser[from];
                } else {
                    _cooldownsByUser[from].offset = offset;
                }
            }
        }

        super._update(from, to, amount);
    }

    /**
     * @dev Allows contract to unwrap WETH
     */
    receive() external payable {}
}

================================================================================
FILE: contracts/helpers/EIP712Helper.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

import '@openzeppelin/contracts/access/Ownable.sol';
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
import '@openzeppelin/contracts/utils/cryptography/EIP712.sol';
import '@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol';

import 'contracts/lib/Error.sol';
import 'contracts/structs/SignedPayload.sol';

/// @title EIP712Helper: Verifies EIP712 signatures
abstract contract EIP712Helper is EIP712, Ownable {
    address private signer;
    mapping(address => uint256) public noncesByUser;

    error InvalidSignature();
    error ExpiredSignature();
    event SignerUpdated(address signer);

    /**
     * @param _name Name of the signing domain.
     * @param _version Version of the signing domain.
     * @param _signer Signer
     */
    constructor(
        string memory _name,
        string memory _version,
        address _signer
    ) EIP712(_name, _version) {
        _setOffchainSigner(_signer);
    }

    /**
     * @notice Get the off-chain signer
     * @return Signer address
     */
    function getOffchainSigner() external view returns (address) {
        return signer;
    }

    /**
     * @notice Set the off-chain signer (only Owner)
     * @param _signer New signer
     */
    function setOffchainSigner(address _signer) external onlyOwner {
        _setOffchainSigner(_signer);
    }

    /// @param _signer Signer
    function _setOffchainSigner(address _signer) internal {
        require(
            _signer != address(0) && signer != _signer,
            Error.InvalidAddress()
        );
        signer = _signer;
        emit SignerUpdated(_signer);
    }

    /**
     * @dev Verifies the signature
     * @param _typeHash Type hash
     * @param _account Address of the user the signature was signed for
     * @param _payload Signed payload containing the data, signature and expiration
     */
    function _verifyEIP712(
        bytes32 _typeHash,
        address _account,
        SignedPayload calldata _payload
    ) internal {
        require(_payload.expiresAt >= block.number, ExpiredSignature());

        unchecked {
            require(
                SignatureChecker.isValidSignatureNow(
                    signer,
                    _hashTypedDataV4(
                        keccak256(
                            abi.encode(
                                _typeHash,
                                noncesByUser[_account]++,
                                _account,
                                keccak256(_payload.data),
                                _payload.expiresAt
                            )
                        )
                    ),
                    _payload.signature
                ),
                InvalidSignature()
            );
        }
    }
}

================================================================================
FILE: contracts/helpers/SwapHelper.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol';


exec
/bin/bash -lc "sed -n '1301,1739p' AUDIT_BUNDLE.txt" in /tmp/codex-uls25_n0
 succeeded in 0ms:
import 'contracts/structs/Swap.sol';

contract SwapHelper is Initializable {
    using SafeERC20 for ERC20;

    address public weth;
    address public feeCollector;

    function __SwapHelper_init_unchained(
        address _weth,
        address _feeCollector
    ) internal onlyInitializing {
        weth = _weth;
        feeCollector = _feeCollector;
    }

    event SwapExecuted(
        address indexed sender,
        address indexed tokenIn,
        address indexed tokenOut,
        uint256 amountSent,
        uint256 amountReceived,
        address router
    );

    /**
     * @notice Execute the swaps in the provided order, based on the swap type and fee tier
     * @param _swaps The swaps to execute
     */
    function _executeSwaps(Swap[] memory _swaps) internal {
        for (uint8 i = 0; i < _swaps.length; i++) {
            _executeSingleSwap(_swaps[i]);
        }
    }

    /**
     * @notice Executes a single swap.
     * @param _swap The swap to execute
     */
    function _executeSingleSwap(Swap memory _swap) internal {
        uint256 tokenInBalanceBefore = _swap.tokenIn.balanceOf(address(this));
        uint256 tokenOutBalanceBefore = _swap.tokenOut.balanceOf(address(this));

        // Approve the router to spend the tokenIn
        _swap.tokenIn.forceApprove(address(_swap.router), _swap.amountToSend);

        // Forward the call to the router
        (bool success, bytes memory returnData) = _swap.router.call(
            _swap.callData
        );
        require(success, string(returnData));

        ERC20(weth).safeTransfer(feeCollector, _swap.swapFee);

        uint256 tokenInBalanceAfter = _swap.tokenIn.balanceOf(address(this));
        uint256 tokenOutBalanceAfter = _swap.tokenOut.balanceOf(address(this));

        emit SwapExecuted(
            msg.sender,
            address(_swap.tokenIn),
            address(_swap.tokenOut),
            tokenInBalanceBefore - tokenInBalanceAfter,
            tokenOutBalanceAfter - tokenOutBalanceBefore,
            _swap.router
        );
    }
}

================================================================================
FILE: contracts/helpers/TransferHelper.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

contract TransferHelper {
    mapping(address => uint256) creditByAddress;

    event CreditAdded(address indexed creditor, uint256 value);
    event CreditWithdrawn(address indexed recipient, uint256 value);

    error NativeTransferFailed();

    /**
     * @notice Safe transfer of ETH to an address. If the transfer fails, the value is added to the credit of the address.
     * @param recipient The address to transfer ETH to
     * @param value The amount of ETH to transfer
     */
    function _safeTransferETH(address recipient, uint256 value) internal {
        (bool success, ) = recipient.call{ value: value }('');

        if (!success) {
            creditByAddress[recipient] += value;
            emit CreditAdded(recipient, value);
        }
    }

    /**
     * @notice Withdraws the caller's credit to the specified recipient. This transfer will either succeed or revert.
     * @param recipient The address to transfer the ETH to
     */
    function withdrawCredit(address recipient) external {
        uint256 value = creditByAddress[msg.sender];
        creditByAddress[msg.sender] = 0;
        (bool success, ) = recipient.call{ value: value }('');
        require(success, NativeTransferFailed());
        emit CreditWithdrawn(recipient, value);
    }
}

================================================================================
FILE: contracts/interfaces/IWETH.sol
================================================================================
/// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

interface IWETH {
    function name() external view returns (string memory);

    function symbol() external view returns (string memory);

    function decimals() external view returns (uint8);

    function balanceOf(address owner) external view returns (uint);

    function allowance(address owner, address spender) external view returns (uint);

    function totalSupply() external view returns (uint);

    function approve(address spender, uint wad) external returns (bool);

    function transfer(address to, uint wad) external returns (bool);

    function transferFrom(address from, address to, uint wad) external returns (bool);

    function deposit() external payable;

    function withdraw(uint wad) external;

    event Approval(address indexed owner, address indexed spender, uint wad);
    event Transfer(address indexed from, address indexed to, uint wad);
    event Deposit(address indexed to, uint wad);
    event Withdrawal(address indexed from, uint wad);
}

================================================================================
FILE: contracts/lib/Error.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

import 'contracts/structs/DepositAmounts.sol';

library Error {
    error InvalidAddress();
    error Unauthorized();
    error MismatchingDepositAmount(
        DepositAmounts depositAmounts,
        uint256 msgValue
    );
}

================================================================================
FILE: contracts/lib/FundAction.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

import { Swap } from 'contracts/structs/Swap.sol';
import { AssetIndex } from 'contracts/structs/AssetIndex.sol';
import { DepositAmounts } from 'contracts/structs/DepositAmounts.sol';
import { WithdrawalAmounts } from 'contracts/structs/WithdrawalAmounts.sol';
import { ERC20 } from '@openzeppelin/contracts/token/ERC20/ERC20.sol';

library FundAction {
    struct Deposit {
        /**
         * @notice The nonce at the time of the deposit
         */
        uint256 nonce;
        /**
         * @notice Deposit amounts in wei units
         */
        DepositAmounts amountsWei;
        /**
         * @notice Deposit amounts in USDT units
         */
        DepositAmounts amountsValue;
        /**
         * @notice Swaps to be executed to maintain the fund allocations after deposit
         */
        Swap[] swaps;
        /**
         * @notice The amount of Fund tokens to mint
         */
        uint256 mintAmount;
        /**
         * @notice Either the vault or a referral address
         */
        address feeRecipient;
        /**
         * @notice The difference in TVL between the initial TVL and the final TVL
         */
        int256 tvlDelta;
    }

    struct AssetDeposit {
        /**
         * @notice The index of the asset to deposit
         */
        uint8 assetIndex;
        /**
         * @notice The asset to deposit
         */
        ERC20 asset;
        /**
         * @notice The amount of asset to deposit
         */
        uint256 amount;
        /**
         * @notice The amount of Fund tokens to mint
         */
        uint256 mintAmount;
        /**
         * @notice The difference in TVL between the initial TVL and the final TVL
         */
        int256 tvlDelta;
    }

    struct Rebalance {
        /**
         * @notice Updates to the fund assets
         */
        AssetIndex[] assetIndexes;
        /**
         * @notice Swaps to be executed to rebalance the fund allocations
         */
        Swap[] swaps;
    }

    struct SingleSwap {
        /**
         * @notice The indexes of the assets to swap
         */
        AssetIndex[] assetIndexes;
        /**
         * @notice The swap to execute
         */
        Swap swap;
    }

    struct Withdraw {
        /**
         * @notice The amount of Fund tokens to burn
         */
        uint256 burnAmount;
        /**
         * @notice Swaps to be executed to withdraw the position
         */
        Swap[] swaps;
        /**
         * @notice The amounts in wei units
         */
        WithdrawalAmounts amountsWei;
        /**
         * @notice The amounts in USDT units
         */
        WithdrawalAmounts amountsValue;
        /**
         * @notice The difference in TVL between the initial TVL and the final TVL
         */
        int256 tvlDelta;
    }

    struct Close {
        /**
         * @notice Swaps to be executed to liquidate the fund
         */
        Swap[] swaps;
    }
}

================================================================================
FILE: contracts/structs/AssetIndex.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

import { ERC20 } from '@openzeppelin/contracts/token/ERC20/ERC20.sol';

/**
 * @notice Data structure including the index of the asset in the asset list and the new asset address
 */
struct AssetIndex {
    /**
     * @notice Index of the asset in the asset list
     */
    uint8 index;
    /**
     * @notice Address of the new asset (0x0 to remove the asset at the index)
     */
    ERC20 asset;
}

================================================================================
FILE: contracts/structs/DepositAmounts.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

struct DepositAmounts {
    /**
     * @notice The raw amount input to the contract by the user
     */
    uint256 input;
    /**
     * @notice Protocol fees either for project vault or referral
     */
    uint256 fee;
    /**
     * @notice Fees to be used for buybacks and burn
     */
    uint256 buybackFee;
}

================================================================================
FILE: contracts/structs/InitialDeposit.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

import 'contracts/structs/DepositAmounts.sol';
struct InitialDeposit {
    /**
     * @notice Wei amounts of the deposit
     */
    DepositAmounts amountsWei;
    /**
     * @notice Values in USDT units
     */
    DepositAmounts amountsValue;
    /**
     * @notice Value of the min deposit in USDT units by the users
     */
    uint256 minUserDepositValue;
    /**
     * @notice Minimum time for a user to wait before withdrawing their deposit
     */
    uint256 minUserDepositCooldown;
}

================================================================================
FILE: contracts/structs/SignedPayload.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

struct SignedPayload {
    /**
     * @notice Encoded payload
     */
    bytes data;
    /**
     * @notice Signature of the payload
     */
    bytes signature;
    /**
     * @notice Expiration block number of the payload
     */
    uint256 expiresAt;
}

================================================================================
FILE: contracts/structs/Swap.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

import '@openzeppelin/contracts/token/ERC20/ERC20.sol';

struct Swap {
    /**
     * @notice The address of the router to use for the swap
     */
    address router;
    /**
     * @notice The encoded function call data for the swap
     */
    bytes callData;
    /**
     * @notice The token to send
     */
    ERC20 tokenIn;
    /**
     * @notice The token to receive
     */
    ERC20 tokenOut;
    /**
     * @notice The amount of tokenIn, decoded for token approval
     */
    uint256 amountToSend;
    /**
     * @notice The swap fee amount we apply to this swap
     */
    uint256 swapFee;
}

================================================================================
FILE: contracts/structs/WithdrawalAmounts.sol
================================================================================
// SPDX-License-Identifier: MIT
pragma solidity =0.8.27;

struct WithdrawalAmounts {
    /**
     * @notice The withdrawn portion of the user's capital
     * that was invested in the fund
     */
    uint256 investedCapital;
    /**
     * @notice The gross PNL: positive for profit, negative for loss
     */
    int256 grossPnl;
    /**
     * @notice Guru's fee (0 if at loss)
     */
    uint256 guruFee;
    /**
     * @notice The protocol fee (0 if at loss)
     */
    uint256 protocolFee;
    /**
     * @notice The net amount of ETH received by the user.
     * In case of a profit: user will receive more than their invested capital, deducted of fee.
     * In case of a loss: fees will be zero but the user will receive less than their invested capital.
     * @dev netOutput = investedCapital + grossPnl - guruFee - protocolFee
     */
    uint256 netOutput;
}


exec
/bin/bash -lc "nl -ba contracts/GuruFund.sol | sed -n '1,420p'" in /tmp/codex-uls25_n0
 succeeded in 0ms:
     1	// SPDX-License-Identifier: MIT
     2	pragma solidity =0.8.27;
     3	
     4	import { OwnableUpgradeable } from '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol';
     5	import { ReentrancyGuardUpgradeable } from '@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol';
     6	import { ERC20Upgradeable } from '@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol';
     7	import { SafeERC20 } from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
     8	import { ERC20 } from '@openzeppelin/contracts/token/ERC20/ERC20.sol';
     9	
    10	import { SignedPayload } from 'contracts/helpers/EIP712Helper.sol';
    11	import { SwapHelper } from 'contracts/helpers/SwapHelper.sol';
    12	import { TransferHelper } from 'contracts/helpers/TransferHelper.sol';
    13	import { Error } from 'contracts/lib/Error.sol';
    14	import { FundAction } from 'contracts/lib/FundAction.sol';
    15	import { InitialDeposit } from 'contracts/structs/InitialDeposit.sol';
    16	import { DepositAmounts } from 'contracts/structs/DepositAmounts.sol';
    17	import { WithdrawalAmounts } from 'contracts/structs/WithdrawalAmounts.sol';
    18	import { FundFactory } from 'contracts/FundFactory.sol';
    19	import { AssetIndex } from 'contracts/structs/AssetIndex.sol';
    20	
    21	/**
    22	 * @title GuruFund
    23	 * @author @numa0x
    24	 * @notice This is the contract for a GuruFund, which is a fund handled by a manager (Guru) that invests in a set of digital assets.
    25	 * The fund is represented by a FundToken, which is minted to the Guru when the fund is created as well as to investors when they deposit.
    26	 * - Implements 6-decimals ERC20 for fund tokens, representing the users' shares of the fund
    27	 * - Implements cooldowns for ERC20 transfers to have users wait before withdrawing their funds
    28	 * - Requires signed payloads for every key operations
    29	 * - Funds support up to 8 ERC20 assets
    30	 * - Tracks invested capital per user to accurately compute PnL and fees
    31	 * - Uses nonce system to prevent outdated deposit transactions
    32	 * - Has protocol-wide pause functionality
    33	 */
    34	contract GuruFund is
    35	    ReentrancyGuardUpgradeable,
    36	    OwnableUpgradeable,
    37	    SwapHelper,
    38	    ERC20Upgradeable,
    39	    TransferHelper
    40	{
    41	    using SafeERC20 for ERC20;
    42	
    43	    /// Constants ///
    44	
    45	    /**
    46	     * @notice The duration of the grace period after the fund is closed. The protocol will
    47	     * not allow any withdrawals after this period.
    48	     */
    49	    uint256 public constant GRACE_PERIOD_DURATION = 180 days;
    50	    uint256 public constant MAX_DEPOSIT_COOLDOWN = 90 days;
    51	    uint256 public constant MANAGEMENT_FEE_PERIOD = 30 days;
    52	    /**
    53	     * @notice The denominator for the management fee mint amount.
    54	     * Monthly rate denominator:
    55	     * ------------------------------------------------------------
    56	     *   Two percent yearly rate: 2/100
    57	     * * Pro-rated monthly: 1/12
    58	     * = 2/100 * 1/12 = 2/1200 = 1/600
    59	     * ------------------------------------------------------------
    60	     * So: if the minted amount needs to be 1/600 of the total supply AFTER the mint,
    61	     * then we need to mint 1/599 of the total supply BEFORE the mint.
    62	     */
    63	    uint256 public constant MANAGEMENT_FEE_DENOMINATOR = 599;
    64	
    65	    /// States ///
    66	
    67	    /**
    68	     * @notice The factory that created this fund.
    69	     */
    70	    FundFactory public immutable fundFactory;
    71	
    72	    /**
    73	     * @notice Whether the fund is open for deposits and withdrawals.
    74	     */
    75	    bool public isOpen;
    76	
    77	    /**
    78	     * @notice The fund's assets
    79	     */
    80	    ERC20[8] public assets;
    81	
    82	    /**
    83	     * @notice A nonce updated on every rebalance, to prevent users from
    84	     * submitting deposits with outdated swaps.
    85	     */
    86	    uint256 public nonce;
    87	
    88	    /**
    89	     * @notice The minimum deposit value for a user to deposit into the fund.
    90	     */
    91	    uint256 public minUserDepositValue;
    92	
    93	    /**
    94	     * @notice The minimum time for a user to wait before withdrawing their deposit
    95	     */
    96	    uint256 public minUserDepositCooldown;
    97	
    98	    /**
    99	     * @notice The timestamp of the last management fee mint
   100	     */
   101	    uint256 public latestManagementFeeMint;
   102	
   103	    /**
   104	     * @notice The end of the grace period after the Guru closed the fund, users can
   105	     * withdraw their funds up to this timestamp
   106	     */
   107	    uint256 public gracePeriodEnd;
   108	
   109	    /**
   110	     * @notice The invested capital for each user.
   111	     * @dev This is the sum of the positive TVL deltas during deposits, subtracted
   112	     * of all capital removed through withdrawals.
   113	     */
   114	    mapping(address => uint256) public investedCapital;
   115	
   116	    /**
   117	     * @notice The cooldowns of the users
   118	     */
   119	    mapping(address => CooldownsByUser) private _cooldownsByUser;
   120	
   121	    /**
   122	     * @notice User deposit cooldown entry
   123	     * @dev The offset is used to skip the cooldowns that have already been processed
   124	     */
   125	    struct CooldownsByUser {
   126	        uint256 offset;
   127	        Cooldown[] cooldowns;
   128	    }
   129	
   130	    /**
   131	     * @param timestamp The timestamp of the cooldown end
   132	     * @param amount The amount of tokens that are still locked
   133	     */
   134	    struct Cooldown {
   135	        uint256 timestamp;
   136	        uint256 amount;
   137	    }
   138	
   139	    /// Events ///
   140	
   141	    /**
   142	     * @notice Emitted when a deposit is made to the fund from a Disciple.
   143	     * @param from The address of the Disciple
   144	     * @param tvlDelta The TVL difference between the initial TVL and the final TVL
   145	     * @param amountsWei The deposit amounts in wei units
   146	     * @param amountsValue The deposit amounts in USDT units
   147	     */
   148	    event Deposited(
   149	        address indexed from,
   150	        int256 tvlDelta,
   151	        uint256 fundTokensMinted,
   152	        DepositAmounts amountsWei,
   153	        DepositAmounts amountsValue
   154	    );
   155	
   156	    /**
   157	     * @notice Emitted when an ERC20 asset is added into the fund by the Guru.
   158	     * @param asset The asset deposited
   159	     * @param amount The amount of the asset deposited
   160	     * @param tvlDelta The TVL difference between the initial TVL and the final TVL
   161	     */
   162	    event DepositedAsset(ERC20 asset, uint256 amount, int256 tvlDelta);
   163	
   164	    /**
   165	     * @notice Emitted when the assets of the fund are updated.
   166	     * @param assets The updated list of assets
   167	     */
   168	    event AssetsUpdated(ERC20[8] assets);
   169	
   170	    /**
   171	     * @notice Emitted when the fund is rebalanced.
   172	     */
   173	    event Rebalanced();
   174	
   175	    /**
   176	     * @notice Emitted when a user withdraws their share of the fund.
   177	     * This will swap the assets back to ETH and return it to the user.
   178	     * @param from The address of the user liquidating
   179	     * @param burnAmount The amount of Fund tokens burned
   180	     * @param amountsWei The withdrawal amounts in wei units
   181	     * @param amountsValue The withdrawal amounts in USDT units
   182	     * @param tvlDelta The TVL difference between the initial TVL and the final TVL
   183	     */
   184	    event Withdrawn(
   185	        address indexed from,
   186	        uint256 burnAmount,
   187	        WithdrawalAmounts amountsWei,
   188	        WithdrawalAmounts amountsValue,
   189	        int256 tvlDelta
   190	    );
   191	
   192	    /**
   193	     * @notice Emitted when the fund is closed by the Guru
   194	     */
   195	    event Closed();
   196	
   197	    /**
   198	     * @notice Emitted when the minimum deposit value for a user is updated.
   199	     * @param newMinimum The new minimum deposit value
   200	     */
   201	    event MinUserDepositValueUpdated(uint256 newMinimum);
   202	
   203	    /**
   204	     * @notice Emitted when the minimum deposit cooldown for a user is updated.
   205	     * @param newMinimum The new minimum deposit cooldown
   206	     */
   207	    event MinUserDepositCooldownUpdated(uint256 newMinimum);
   208	
   209	    /**
   210	     * @notice Emitted when the management fee is minted.
   211	     * @param amount The amount of management fee minted
   212	     */
   213	    event ManagementFeeMinted(uint256 amount);
   214	
   215	    /**
   216	     * @notice Emitted when the grace period is extended.
   217	     * @param newGracePeriodEnd The new grace period end
   218	     */
   219	    event GracePeriodExtended(uint256 newGracePeriodEnd);
   220	
   221	    /**
   222	     * @notice Emitted when the protocol owner claims remaining funds for buyback and burn
   223	     */
   224	    event AbandonedFundsClaimed();
   225	
   226	    // errors
   227	    error FundClosed();
   228	    error ProtocolHalted();
   229	    error UnexpectedFeeData(
   230	        uint256 fees,
   231	        uint256 maxExpectedFees,
   232	        address feeRecipient
   233	    );
   234	    error MaxCooldownExceeded(uint256 cooldown);
   235	    error DepositMustIncreaseTvl(int256 tvlDelta);
   236	    error InvalidDepositNonce(uint256 depositNonce, uint256 currentNonce);
   237	    error InvalidSwapDirection(address tokenFrom, address tokenTo);
   238	    error AssetIndexAlreadyOccupied(uint8 index, ERC20 assetAtIndex);
   239	    error CooldownNotExpired(uint256 availableBalance, uint256 transferAmount);
   240	    error InvalidTransferAmount(
   241	        uint256 availableBalance,
   242	        uint256 transferAmount
   243	    );
   244	    error ManagementFeePeriodNotElapsed();
   245	    error GracePeriodEnded();
   246	
   247	    // modifiers
   248	    modifier onlyOpen() {
   249	        require(isOpen, FundClosed());
   250	        _;
   251	    }
   252	
   253	    modifier onlyNotPaused() {
   254	        require(!fundFactory.paused(), ProtocolHalted());
   255	        _;
   256	    }
   257	
   258	    modifier verifyingSignature(SignedPayload calldata _payload) {
   259	        fundFactory.verifySignature(msg.sender, _payload);
   260	        _;
   261	    }
   262	
   263	    /**
   264	     * @dev This will only be called once when deploying the Fund Factory.
   265	     * Clones initializers will be called by the FundFactory.
   266	     */
   267	    constructor() {
   268	        fundFactory = FundFactory(msg.sender);
   269	        _disableInitializers();
   270	    }
   271	
   272	    /// External Functions ///
   273	
   274	    /**
   275	     * @notice Initializes the fund with a deposit of ETH, which is wrapped.
   276	     * @dev Only the fund factory can call this function, after verifying the signature of the payload.
   277	     * @param _guru The address of the Guru (owner) of the fund
   278	     * @param _initialDeposit The initial deposit of the fund
   279	     */
   280	    function initialize(
   281	        address _guru,
   282	        string calldata _name,
   283	        string calldata _symbol,
   284	        InitialDeposit calldata _initialDeposit
   285	    ) external payable initializer {
   286	        require(msg.sender == address(fundFactory), Error.Unauthorized());
   287	        require(
   288	            _initialDeposit.minUserDepositCooldown <= MAX_DEPOSIT_COOLDOWN,
   289	            MaxCooldownExceeded(_initialDeposit.minUserDepositCooldown)
   290	        );
   291	
   292	        __SwapHelper_init_unchained(
   293	            address(fundFactory.weth()),
   294	            address(fundFactory.vault())
   295	        );
   296	        __Ownable_init_unchained(_guru);
   297	        __ERC20_init_unchained(_name, _symbol);
   298	
   299	        // Open the fund
   300	        minUserDepositValue = _initialDeposit.minUserDepositValue;
   301	        minUserDepositCooldown = _initialDeposit.minUserDepositCooldown;
   302	        latestManagementFeeMint = block.timestamp; // Allows first mint in the next period
   303	        isOpen = true;
   304	
   305	        // Wrap the initial deposit net amount
   306	        _wrapETH(msg.value - _initialDeposit.amountsWei.buybackFee);
   307	
   308	        // Initialize the assets array with WETH
   309	        assets[0] = ERC20(address(fundFactory.weth()));
   310	
   311	        emit AssetsUpdated(assets);
   312	
   313	        // Mint the fund tokens to the Guru
   314	        _mint(_guru, _initialDeposit.amountsValue.input);
   315	
   316	        // Update the invested capital
   317	        investedCapital[_guru] = uint256(_initialDeposit.amountsValue.input);
   318	
   319	        // Handle buyback and burn
   320	        _safeTransferETH(
   321	            fundFactory.guruBurner(),
   322	            _initialDeposit.amountsWei.buybackFee
   323	        );
   324	
   325	        emit Deposited(
   326	            _guru,
   327	            int256(_initialDeposit.amountsValue.input), // first ∆ TVL is the initial deposit value
   328	            _initialDeposit.amountsValue.input, // first deposit mint amount matches its USDT value
   329	            _initialDeposit.amountsWei,
   330	            _initialDeposit.amountsValue
   331	        );
   332	    }
   333	
   334	    /**
   335	     * @notice Gurus can call this function to directly deposit an asset into the fund.
   336	     * @param _signedAssetDeposit The signed payload containing the asset to deposit
   337	     */
   338	    function depositAsset(
   339	        SignedPayload calldata _signedAssetDeposit
   340	    ) external nonReentrant onlyOwner verifyingSignature(_signedAssetDeposit) {
   341	        FundAction.AssetDeposit memory _deposit = abi.decode(
   342	            _signedAssetDeposit.data,
   343	            (FundAction.AssetDeposit)
   344	        );
   345	
   346	        require(
   347	            _deposit.tvlDelta >= 0,
   348	            DepositMustIncreaseTvl(_deposit.tvlDelta)
   349	        );
   350	
   351	        /// 1. Update asset index
   352	        require(
   353	            assets[_deposit.assetIndex] == ERC20(address(0)) ||
   354	                assets[_deposit.assetIndex] == _deposit.asset,
   355	            AssetIndexAlreadyOccupied(
   356	                _deposit.assetIndex,
   357	                assets[_deposit.assetIndex]
   358	            )
   359	        );
   360	
   361	        assets[_deposit.assetIndex] = _deposit.asset;
   362	
   363	        /// 2. Transfer deposit in
   364	        _deposit.asset.safeTransferFrom(
   365	            msg.sender,
   366	            address(this),
   367	            _deposit.amount
   368	        );
   369	
   370	        /// 3. Mint fund tokens
   371	        _mint(msg.sender, _deposit.mintAmount);
   372	
   373	        /// 4. Update invested capital
   374	        investedCapital[msg.sender] += uint256(_deposit.tvlDelta);
   375	
   376	        emit DepositedAsset(_deposit.asset, _deposit.amount, _deposit.tvlDelta);
   377	    }
   378	
   379	    /**
   380	     * @notice Deposits ETH into the fund, which will get swapped and rebalanced
   381	     * accordingly to the current fund composition.
   382	     * @param _signedDepositPayload The signed payload containing the deposit data,
   383	     * including the amount of ETH to deposit and the swaps to execute.
   384	     */
   385	    function deposit(
   386	        SignedPayload calldata _signedDepositPayload
   387	    )
   388	        external
   389	        payable
   390	        nonReentrant
   391	        onlyOpen
   392	        onlyNotPaused
   393	        verifyingSignature(_signedDepositPayload)
   394	    {
   395	        // Prevents management from accidentally depositing into any fund
   396	        require(msg.sender != fundFactory.admin(), Error.Unauthorized());
   397	
   398	        FundAction.Deposit memory _deposit = abi.decode(
   399	            _signedDepositPayload.data,
   400	            (FundAction.Deposit)
   401	        );
   402	
   403	        require(
   404	            _deposit.nonce == nonce,
   405	            InvalidDepositNonce(_deposit.nonce, nonce)
   406	        );
   407	
   408	        require(
   409	            _deposit.tvlDelta >= 0,
   410	            DepositMustIncreaseTvl(_deposit.tvlDelta)
   411	        );
   412	
   413	        /// 1. Validate fees and deposit amounts
   414	
   415	        uint256 fees = _deposit.amountsWei.fee + _deposit.amountsWei.buybackFee;
   416	
   417	        require(
   418	            fees <= (msg.value * fundFactory.protocolDepositFee()) / 100_000 &&
   419	                _deposit.feeRecipient != address(0),
   420	            UnexpectedFeeData(

exec
/bin/bash -lc "nl -ba contracts/helpers/SwapHelper.sol | sed -n '1,220p'" in /tmp/codex-uls25_n0
 succeeded in 0ms:
     1	// SPDX-License-Identifier: MIT
     2	pragma solidity =0.8.27;
     3	
     4	import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
     5	import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
     6	import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol';
     7	
     8	import 'contracts/structs/Swap.sol';
     9	
    10	contract SwapHelper is Initializable {
    11	    using SafeERC20 for ERC20;
    12	
    13	    address public weth;
    14	    address public feeCollector;
    15	
    16	    function __SwapHelper_init_unchained(
    17	        address _weth,
    18	        address _feeCollector
    19	    ) internal onlyInitializing {
    20	        weth = _weth;
    21	        feeCollector = _feeCollector;
    22	    }
    23	
    24	    event SwapExecuted(
    25	        address indexed sender,
    26	        address indexed tokenIn,
    27	        address indexed tokenOut,
    28	        uint256 amountSent,
    29	        uint256 amountReceived,
    30	        address router
    31	    );
    32	
    33	    /**
    34	     * @notice Execute the swaps in the provided order, based on the swap type and fee tier
    35	     * @param _swaps The swaps to execute
    36	     */
    37	    function _executeSwaps(Swap[] memory _swaps) internal {
    38	        for (uint8 i = 0; i < _swaps.length; i++) {
    39	            _executeSingleSwap(_swaps[i]);
    40	        }
    41	    }
    42	
    43	    /**
    44	     * @notice Executes a single swap.
    45	     * @param _swap The swap to execute
    46	     */
    47	    function _executeSingleSwap(Swap memory _swap) internal {
    48	        uint256 tokenInBalanceBefore = _swap.tokenIn.balanceOf(address(this));
    49	        uint256 tokenOutBalanceBefore = _swap.tokenOut.balanceOf(address(this));
    50	
    51	        // Approve the router to spend the tokenIn
    52	        _swap.tokenIn.forceApprove(address(_swap.router), _swap.amountToSend);
    53	
    54	        // Forward the call to the router
    55	        (bool success, bytes memory returnData) = _swap.router.call(
    56	            _swap.callData
    57	        );
    58	        require(success, string(returnData));
    59	
    60	        ERC20(weth).safeTransfer(feeCollector, _swap.swapFee);
    61	
    62	        uint256 tokenInBalanceAfter = _swap.tokenIn.balanceOf(address(this));
    63	        uint256 tokenOutBalanceAfter = _swap.tokenOut.balanceOf(address(this));
    64	
    65	        emit SwapExecuted(
    66	            msg.sender,
    67	            address(_swap.tokenIn),
    68	            address(_swap.tokenOut),
    69	            tokenInBalanceBefore - tokenInBalanceAfter,
    70	            tokenOutBalanceAfter - tokenOutBalanceBefore,
    71	            _swap.router
    72	        );
    73	    }
    74	}

exec
/bin/bash -lc "nl -ba contracts/helpers/EIP712Helper.sol | sed -n '1,220p'" in /tmp/codex-uls25_n0
 succeeded in 0ms:
     1	// SPDX-License-Identifier: MIT
     2	pragma solidity =0.8.27;
     3	
     4	import '@openzeppelin/contracts/access/Ownable.sol';
     5	import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
     6	import '@openzeppelin/contracts/utils/cryptography/EIP712.sol';
     7	import '@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol';
     8	
     9	import 'contracts/lib/Error.sol';
    10	import 'contracts/structs/SignedPayload.sol';
    11	
    12	/// @title EIP712Helper: Verifies EIP712 signatures
    13	abstract contract EIP712Helper is EIP712, Ownable {
    14	    address private signer;
    15	    mapping(address => uint256) public noncesByUser;
    16	
    17	    error InvalidSignature();
    18	    error ExpiredSignature();
    19	    event SignerUpdated(address signer);
    20	
    21	    /**
    22	     * @param _name Name of the signing domain.
    23	     * @param _version Version of the signing domain.
    24	     * @param _signer Signer
    25	     */
    26	    constructor(
    27	        string memory _name,
    28	        string memory _version,
    29	        address _signer
    30	    ) EIP712(_name, _version) {
    31	        _setOffchainSigner(_signer);
    32	    }
    33	
    34	    /**
    35	     * @notice Get the off-chain signer
    36	     * @return Signer address
    37	     */
    38	    function getOffchainSigner() external view returns (address) {
    39	        return signer;
    40	    }
    41	
    42	    /**
    43	     * @notice Set the off-chain signer (only Owner)
    44	     * @param _signer New signer
    45	     */
    46	    function setOffchainSigner(address _signer) external onlyOwner {
    47	        _setOffchainSigner(_signer);
    48	    }
    49	
    50	    /// @param _signer Signer
    51	    function _setOffchainSigner(address _signer) internal {
    52	        require(
    53	            _signer != address(0) && signer != _signer,
    54	            Error.InvalidAddress()
    55	        );
    56	        signer = _signer;
    57	        emit SignerUpdated(_signer);
    58	    }
    59	
    60	    /**
    61	     * @dev Verifies the signature
    62	     * @param _typeHash Type hash
    63	     * @param _account Address of the user the signature was signed for
    64	     * @param _payload Signed payload containing the data, signature and expiration
    65	     */
    66	    function _verifyEIP712(
    67	        bytes32 _typeHash,
    68	        address _account,
    69	        SignedPayload calldata _payload
    70	    ) internal {
    71	        require(_payload.expiresAt >= block.number, ExpiredSignature());
    72	
    73	        unchecked {
    74	            require(
    75	                SignatureChecker.isValidSignatureNow(
    76	                    signer,
    77	                    _hashTypedDataV4(
    78	                        keccak256(
    79	                            abi.encode(
    80	                                _typeHash,
    81	                                noncesByUser[_account]++,
    82	                                _account,
    83	                                keccak256(_payload.data),
    84	                                _payload.expiresAt
    85	                            )
    86	                        )
    87	                    ),
    88	                    _payload.signature
    89	                ),
    90	                InvalidSignature()
    91	            );
    92	        }
    93	    }
    94	}

exec
/bin/bash -lc "nl -ba contracts/FundFactory.sol | sed -n '1,260p'" in /tmp/codex-uls25_n0
 succeeded in 0ms:
     1	// SPDX-License-Identifier: MIT
     2	pragma solidity =0.8.27;
     3	
     4	import { Ownable } from '@openzeppelin/contracts/access/Ownable.sol';
     5	import { Pausable } from '@openzeppelin/contracts/utils/Pausable.sol';
     6	import { ERC20 } from '@openzeppelin/contracts/token/ERC20/ERC20.sol';
     7	import { SafeERC20 } from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
     8	import { Clones } from '@openzeppelin/contracts/proxy/Clones.sol';
     9	
    10	import { GuruFund } from 'contracts/GuruFund.sol';
    11	import { EIP712Helper } from 'contracts/helpers/EIP712Helper.sol';
    12	import { SignedPayload } from 'contracts/structs/SignedPayload.sol';
    13	import { IWETH } from 'contracts/interfaces/IWETH.sol';
    14	import { InitialDeposit } from 'contracts/structs/InitialDeposit.sol';
    15	import { Error } from 'contracts/lib/Error.sol';
    16	
    17	contract FundFactory is EIP712Helper, Pausable {
    18	    // Immutable variables (stored in code, not storage)
    19	    IWETH public immutable weth;
    20	    GuruFund public immutable fundImplementation;
    21	
    22	    // Constants (not stored in storage)
    23	    bytes32 public constant SIGNED_ACTION_TYPEHASH =
    24	        keccak256(
    25	            'SignedAction(uint256 nonce,address account,bytes data,uint256 expiresAt)'
    26	        );
    27	    uint24 public constant FEE_DENOMINATOR = 100_000;
    28	    uint16 public constant MAX_PROFIT_FEE = 20_000; // 20%
    29	    uint16 public constant GURU_PROFIT_FEE = 10_000; // 10%
    30	    uint16 public constant MAX_DEPOSIT_FEE = 1_000; // 1%
    31	    uint16 public constant MAX_SWAP_FEE = 200; // 0.2%
    32	
    33	    // Storage variables packed into slots
    34	    address public vault; // slot 0 (20 bytes)
    35	    uint32 public signatureValidityPeriod = 10; // slot 0 (4 bytes)
    36	    uint16 public protocolProfitFee = 5_000; // slot 0 (2 bytes)
    37	    uint16 public protocolDepositFee = 1_000; // slot 0 (2 bytes)
    38	    uint16 public protocolSwapFee = 200; // slot 0 (2 bytes)
    39	
    40	    address public guruBurner; // slot 1 (20 bytes)
    41	    uint64 public minimumGuruInitialDepositValue = 1000_000000; // slot 1 (8 bytes)
    42	    uint64 public protocolFundCreationFeeValue = 100_000000; // slot 1 (8 bytes)
    43	
    44	    address public admin; // slot 2 (20 bytes)
    45	
    46	    // events
    47	    event VaultUpdated(address newVault);
    48	    event GuruBurnerUpdated(address newGuruBurner);
    49	    event AdminUpdated(address newAdmin);
    50	    event MinimumGuruInitialDepositValueUpdated(
    51	        uint64 newMinimumInitialDeposit
    52	    );
    53	    event SignatureValidityPeriodUpdated(uint32 newSignatureValidityPeriod);
    54	    event ProtocolProfitFeeUpdated(uint16 newProtocolProfitFee);
    55	    event ProtocolFundCreationFeeUpdated(uint64 newProtocolFundCreationFee);
    56	    event ProtocolDepositFeeUpdated(uint16 newProtocolDepositFee);
    57	    event ProtocolSwapFeeUpdated(uint16 newProtocolSwapFee);
    58	    event FundCreated(address indexed fund, address indexed creator);
    59	
    60	    // errors
    61	    error InsufficientFirstDeposit(uint256 received, uint256 required);
    62	    error FeesTooHigh(uint256 fee, uint256 max);
    63	
    64	    constructor(
    65	        address _offchainSigner,
    66	        address _vault,
    67	        address _guruBurner,
    68	        address _admin,
    69	        IWETH _wethAddress
    70	    ) Ownable(msg.sender) EIP712Helper('GURU.FUND', 'v0.1.0', _offchainSigner) {
    71	        vault = _vault;
    72	        guruBurner = _guruBurner;
    73	        weth = _wethAddress;
    74	        admin = _admin;
    75	        fundImplementation = new GuruFund();
    76	    }
    77	
    78	    function getTotalProfitFee() public view returns (uint256) {
    79	        return GURU_PROFIT_FEE + protocolProfitFee;
    80	    }
    81	
    82	    // config
    83	    function setVault(address _newVault) public onlyOwner {
    84	        vault = _newVault;
    85	        emit VaultUpdated(_newVault);
    86	    }
    87	
    88	    function setGuruBurner(address _newGuruBurner) public onlyOwner {
    89	        guruBurner = _newGuruBurner;
    90	        emit GuruBurnerUpdated(_newGuruBurner);
    91	    }
    92	
    93	    function setAdmin(address _newAdmin) public onlyOwner {
    94	        admin = _newAdmin;
    95	        emit AdminUpdated(_newAdmin);
    96	    }
    97	
    98	    function setProtocolFundCreationFee(
    99	        uint64 _newProtocolFundCreationFee
   100	    ) public onlyOwner {
   101	        protocolFundCreationFeeValue = _newProtocolFundCreationFee;
   102	        emit ProtocolFundCreationFeeUpdated(_newProtocolFundCreationFee);
   103	    }
   104	
   105	    function setProtocolDepositFee(
   106	        uint16 _newProtocolDepositFee
   107	    ) public onlyOwner {
   108	        require(
   109	            _newProtocolDepositFee <= MAX_DEPOSIT_FEE,
   110	            FeesTooHigh(_newProtocolDepositFee, MAX_DEPOSIT_FEE)
   111	        );
   112	        protocolDepositFee = _newProtocolDepositFee;
   113	        emit ProtocolDepositFeeUpdated(_newProtocolDepositFee);
   114	    }
   115	
   116	    function setProtocolProfitFee(
   117	        uint16 _newProtocolFeeOnProfits
   118	    ) public onlyOwner {
   119	        require(
   120	            GURU_PROFIT_FEE + _newProtocolFeeOnProfits <= MAX_PROFIT_FEE,
   121	            FeesTooHigh(
   122	                GURU_PROFIT_FEE + _newProtocolFeeOnProfits,
   123	                MAX_PROFIT_FEE
   124	            )
   125	        );
   126	        protocolProfitFee = _newProtocolFeeOnProfits;
   127	        emit ProtocolProfitFeeUpdated(_newProtocolFeeOnProfits);
   128	    }
   129	
   130	    function setProtocolSwapFee(uint16 _newProtocolSwapFee) public onlyOwner {
   131	        require(
   132	            _newProtocolSwapFee <= MAX_SWAP_FEE,
   133	            FeesTooHigh(_newProtocolSwapFee, MAX_SWAP_FEE)
   134	        );
   135	        protocolSwapFee = _newProtocolSwapFee;
   136	        emit ProtocolSwapFeeUpdated(_newProtocolSwapFee);
   137	    }
   138	
   139	    function setMinimumFirstDepositValue(uint64 _newMinimum) public onlyOwner {
   140	        minimumGuruInitialDepositValue = _newMinimum;
   141	        emit MinimumGuruInitialDepositValueUpdated(_newMinimum);
   142	    }
   143	
   144	    function setSignatureValidityPeriod(
   145	        uint32 _newSignatureValidityPeriod
   146	    ) public onlyOwner {
   147	        signatureValidityPeriod = _newSignatureValidityPeriod;
   148	        emit SignatureValidityPeriodUpdated(_newSignatureValidityPeriod);
   149	    }
   150	
   151	    function verifySignature(
   152	        address account,
   153	        SignedPayload calldata _signedPayload
   154	    ) public {
   155	        _verifyEIP712(SIGNED_ACTION_TYPEHASH, account, _signedPayload);
   156	    }
   157	
   158	    function pause() public onlyOwner {
   159	        _pause();
   160	    }
   161	
   162	    function unpause() public onlyOwner {
   163	        _unpause();
   164	    }
   165	
   166	    /**
   167	     * @notice Creates a new fund with a minimum deposit of 1000 USDT worth of WETH
   168	     * @param _name Name of the fund token
   169	     * @param _symbol Symbol of the fund token
   170	     * @param _signedPayload Signed payload containing expiration, signature, and
   171	     * the encoded price feed with latest WETH/USDT price
   172	     */
   173	    function createFund(
   174	        string calldata _name,
   175	        string calldata _symbol,
   176	        SignedPayload calldata _signedPayload
   177	    ) public payable whenNotPaused {
   178	        verifySignature(msg.sender, _signedPayload);
   179	
   180	        InitialDeposit memory initialDeposit = abi.decode(
   181	            _signedPayload.data,
   182	            (InitialDeposit)
   183	        );
   184	
   185	        // Any check on the deposit min amount is handled offchain: here we
   186	        // just verify that the amount received is matching the signed payload
   187	        require(
   188	            initialDeposit.amountsWei.input +
   189	                initialDeposit.amountsWei.buybackFee ==
   190	                msg.value,
   191	            Error.MismatchingDepositAmount(initialDeposit.amountsWei, msg.value)
   192	        );
   193	
   194	        address fund = Clones.clone(address(fundImplementation));
   195	
   196	        GuruFund(payable(fund)).initialize{ value: msg.value }(
   197	            msg.sender,
   198	            _name,
   199	            _symbol,
   200	            initialDeposit
   201	        );
   202	
   203	        emit FundCreated(fund, msg.sender);
   204	    }
   205	}

exec
/bin/bash -lc "nl -ba contracts/GuruFund.sol | sed -n '420,620p'" in /tmp/codex-uls25_n0
 succeeded in 0ms:
   420	            UnexpectedFeeData(
   421	                fees,
   422	                fundFactory.protocolDepositFee(),
   423	                _deposit.feeRecipient
   424	            )
   425	        );
   426	
   427	        uint256 netDeposit = msg.value - fees;
   428	
   429	        require(
   430	            _deposit.amountsWei.input == netDeposit,
   431	            Error.MismatchingDepositAmount(_deposit.amountsWei, netDeposit)
   432	        );
   433	
   434	        /// 2. Wrap ETH
   435	        _wrapETH(netDeposit);
   436	
   437	        /// 3. Loop and swap
   438	        _executeSwaps(_deposit.swaps);
   439	
   440	        /// 4. Mint fund tokens
   441	        _mint(msg.sender, _deposit.mintAmount);
   442	
   443	        /// 5. Update invested capital
   444	        investedCapital[msg.sender] += uint256(_deposit.tvlDelta);
   445	
   446	        /// 6. Collect fees
   447	        if (_deposit.amountsWei.fee > 0) {
   448	            _safeTransferETH(_deposit.feeRecipient, _deposit.amountsWei.fee);
   449	        }
   450	
   451	        if (_deposit.amountsWei.buybackFee > 0) {
   452	            _safeTransferETH(
   453	                fundFactory.guruBurner(),
   454	                _deposit.amountsWei.buybackFee
   455	            );
   456	        }
   457	
   458	        emit Deposited(
   459	            msg.sender,
   460	            _deposit.tvlDelta,
   461	            _deposit.mintAmount,
   462	            _deposit.amountsWei,
   463	            _deposit.amountsValue
   464	        );
   465	    }
   466	
   467	    /**
   468	     * @notice Swaps tokens for ETH.
   469	     * @param _signedSwapPayload The signed payload containing the swap data
   470	     */
   471	    function swapTokensForETH(
   472	        SignedPayload calldata _signedSwapPayload
   473	    )
   474	        external
   475	        nonReentrant
   476	        onlyOpen
   477	        onlyNotPaused
   478	        onlyOwner
   479	        verifyingSignature(_signedSwapPayload)
   480	    {
   481	        FundAction.SingleSwap memory swapAction = abi.decode(
   482	            _signedSwapPayload.data,
   483	            (FundAction.SingleSwap)
   484	        );
   485	
   486	        require(
   487	            address(swapAction.swap.tokenOut) == address(fundFactory.weth()),
   488	            InvalidSwapDirection(
   489	                address(swapAction.swap.tokenIn),
   490	                address(swapAction.swap.tokenOut)
   491	            )
   492	        );
   493	
   494	        _executeSingleSwap(swapAction.swap);
   495	        _updateAssets(swapAction.assetIndexes);
   496	
   497	        unchecked {
   498	            nonce++;
   499	        }
   500	    }
   501	
   502	    /**
   503	     * @notice Swaps ETH for tokens.
   504	     * @param _signedSwapPayload The signed payload containing the swap data
   505	     */
   506	    function swapETHForTokens(
   507	        SignedPayload calldata _signedSwapPayload
   508	    )
   509	        external
   510	        nonReentrant
   511	        onlyOpen
   512	        onlyNotPaused
   513	        onlyOwner
   514	        verifyingSignature(_signedSwapPayload)
   515	    {
   516	        FundAction.SingleSwap memory swapAction = abi.decode(
   517	            _signedSwapPayload.data,
   518	            (FundAction.SingleSwap)
   519	        );
   520	
   521	        require(
   522	            address(swapAction.swap.tokenIn) == address(fundFactory.weth()),
   523	            InvalidSwapDirection(
   524	                address(swapAction.swap.tokenIn),
   525	                address(swapAction.swap.tokenOut)
   526	            )
   527	        );
   528	
   529	        _executeSingleSwap(swapAction.swap);
   530	        _updateAssets(swapAction.assetIndexes);
   531	
   532	        unchecked {
   533	            nonce++;
   534	        }
   535	    }
   536	
   537	    /**
   538	     * @notice Rebalances the fund by changing the allocations of the assets.
   539	     * @param _signedRebalancePayload The signed payload containing the rebalancing data,
   540	     * including the changes to apply to the asset lists and the swaps to execute.
   541	     */
   542	    function rebalance(
   543	        SignedPayload calldata _signedRebalancePayload
   544	    )
   545	        external
   546	        nonReentrant
   547	        onlyOpen
   548	        onlyNotPaused
   549	        onlyOwner
   550	        verifyingSignature(_signedRebalancePayload)
   551	    {
   552	        FundAction.Rebalance memory _rebalance = abi.decode(
   553	            _signedRebalancePayload.data,
   554	            (FundAction.Rebalance)
   555	        );
   556	
   557	        _updateAssets(_rebalance.assetIndexes);
   558	        _executeSwaps(_rebalance.swaps);
   559	
   560	        unchecked {
   561	            nonce++;
   562	        }
   563	
   564	        emit Rebalanced();
   565	    }
   566	
   567	    /**
   568	     * @notice Withdraws the user's share of the fund, swapping the assets back to ETH.
   569	     * @param _signedWithdrawPayload The signed payload containing the withdrawal data,
   570	     */
   571	    function withdraw(
   572	        SignedPayload calldata _signedWithdrawPayload
   573	    ) external nonReentrant verifyingSignature(_signedWithdrawPayload) {
   574	        if (!isOpen) {
   575	            // Investors can withdraw only until the grace period ends
   576	            require(block.timestamp <= gracePeriodEnd, GracePeriodEnded());
   577	        }
   578	
   579	        FundAction.Withdraw memory _userWithdrawal = abi.decode(
   580	            _signedWithdrawPayload.data,
   581	            (FundAction.Withdraw)
   582	        );
   583	
   584	        // 1. Burn tokens
   585	        _burn(msg.sender, _userWithdrawal.burnAmount);
   586	
   587	        // 2. Update invested capital
   588	        unchecked {
   589	            investedCapital[msg.sender] -= _userWithdrawal
   590	                .amountsValue
   591	                .investedCapital;
   592	        }
   593	
   594	        // 3. Execute swaps
   595	        _executeSwaps(_userWithdrawal.swaps);
   596	
   597	        // 4. Handle ETH transfers and fees
   598	        _executeWithdrawalTransfers(_userWithdrawal.amountsWei);
   599	
   600	        emit Withdrawn(
   601	            msg.sender,
   602	            _userWithdrawal.burnAmount,
   603	            _userWithdrawal.amountsWei,
   604	            _userWithdrawal.amountsValue,
   605	            _userWithdrawal.tvlDelta
   606	        );
   607	    }
   608	
   609	    /**
   610	     * @notice Executes the withdrawal transfers, including fees.
   611	     * @param amountsWei The withdrawal amounts in wei units
   612	     */
   613	    function _executeWithdrawalTransfers(
   614	        WithdrawalAmounts memory amountsWei
   615	    ) internal {
   616	        if (amountsWei.grossPnl <= 0) {
   617	            _unwrapETH(amountsWei.netOutput);
   618	        } else {
   619	            unchecked {
   620	                _unwrapETH(

exec
/bin/bash -lc "nl -ba contracts/GuruFund.sol | sed -n '620,900p'" in /tmp/codex-uls25_n0
 succeeded in 0ms:
   620	                _unwrapETH(
   621	                    amountsWei.netOutput +
   622	                        amountsWei.protocolFee +
   623	                        amountsWei.guruFee
   624	                );
   625	            }
   626	
   627	            _safeTransferETH(fundFactory.vault(), amountsWei.protocolFee);
   628	            _safeTransferETH(owner(), amountsWei.guruFee);
   629	        }
   630	        _safeTransferETH(msg.sender, amountsWei.netOutput);
   631	    }
   632	
   633	    /**
   634	     * @notice Mints the management fee to the admin.
   635	     * @dev Only the management admin can call this function.
   636	     */
   637	    function mintManagementFee() external onlyOpen onlyNotPaused {
   638	        require(msg.sender == fundFactory.admin(), Error.Unauthorized());
   639	        require(
   640	            block.timestamp - latestManagementFeeMint > MANAGEMENT_FEE_PERIOD,
   641	            ManagementFeePeriodNotElapsed()
   642	        );
   643	
   644	        uint256 amount = totalSupply() / MANAGEMENT_FEE_DENOMINATOR;
   645	        _mint(fundFactory.admin(), amount);
   646	        latestManagementFeeMint = block.timestamp;
   647	
   648	        emit ManagementFeeMinted(amount);
   649	    }
   650	
   651	    /**
   652	     * @notice Closes the fund, liquidating all assets. Users will be able to withdraw their capital.
   653	     * @param _signedClosePayload The signed payload containing the close data
   654	     */
   655	    function close(
   656	        SignedPayload calldata _signedClosePayload
   657	    )
   658	        external
   659	        nonReentrant
   660	        onlyOpen
   661	        onlyOwner
   662	        verifyingSignature(_signedClosePayload)
   663	    {
   664	        isOpen = false;
   665	        gracePeriodEnd = block.timestamp + GRACE_PERIOD_DURATION;
   666	
   667	        // Liquidate all assets
   668	        FundAction.Close memory _liquidation = abi.decode(
   669	            _signedClosePayload.data,
   670	            (FundAction.Close)
   671	        );
   672	
   673	        _executeSwaps(_liquidation.swaps);
   674	
   675	        emit Closed();
   676	    }
   677	
   678	    /**
   679	     * @notice Extends the grace period.
   680	     * @param _newGracePeriodEnd The new grace period end
   681	     */
   682	    function extendGracePeriod(uint256 _newGracePeriodEnd) external {
   683	        // Only protocol owner can extend the grace period
   684	        require(
   685	            msg.sender == fundFactory.owner() &&
   686	                _newGracePeriodEnd > gracePeriodEnd,
   687	            Error.Unauthorized()
   688	        );
   689	        gracePeriodEnd = _newGracePeriodEnd;
   690	        emit GracePeriodExtended(_newGracePeriodEnd);
   691	    }
   692	
   693	    /**
   694	     * @notice After the grace period ends, the protocol owner can claim any
   695	     * remaining funds to buyback and burn $GURU.
   696	     */
   697	    function claimAbandonedFundsForBuybackAndBurn() external {
   698	        require(
   699	            !isOpen &&
   700	                msg.sender == fundFactory.owner() &&
   701	                block.timestamp > gracePeriodEnd,
   702	            Error.Unauthorized()
   703	        );
   704	        _unwrapETH(fundFactory.weth().balanceOf(address(this)));
   705	        _safeTransferETH(fundFactory.guruBurner(), address(this).balance);
   706	        emit AbandonedFundsClaimed();
   707	    }
   708	
   709	    /**
   710	     * @notice Updates the minimum deposit value for a user.
   711	     * @param _newMinValue The new minimum deposit value
   712	     */
   713	    function updateMinUserDepositValue(
   714	        uint256 _newMinValue
   715	    ) external onlyOpen onlyOwner {
   716	        minUserDepositValue = _newMinValue;
   717	        emit MinUserDepositValueUpdated(_newMinValue);
   718	    }
   719	
   720	    /**
   721	     * @notice Updates the minimum deposit cooldown for a user.
   722	     * @param _newMinCooldown The new minimum deposit cooldown
   723	     */
   724	    function updateMinDepositCooldown(
   725	        uint256 _newMinCooldown
   726	    ) external onlyOpen onlyOwner {
   727	        require(
   728	            _newMinCooldown <= MAX_DEPOSIT_COOLDOWN,
   729	            MaxCooldownExceeded(_newMinCooldown)
   730	        );
   731	        minUserDepositCooldown = _newMinCooldown;
   732	        emit MinUserDepositCooldownUpdated(_newMinCooldown);
   733	    }
   734	
   735	    /**
   736	     * @notice Returns the available balance for a user, i.e. the balance that is not cooling down.
   737	     * @param _account The address of the user
   738	     * @return availableBalance The available balance for the user
   739	     */
   740	    function availableBalanceOf(
   741	        address _account
   742	    ) external view returns (uint256 availableBalance) {
   743	        availableBalance = balanceOf(_account);
   744	
   745	        if (hasCooldown(_account)) {
   746	            (uint256 lockedBalance, ) = _getCooldownDetails(_account);
   747	            availableBalance -= lockedBalance;
   748	        }
   749	    }
   750	
   751	    /**
   752	     * @notice Returns the assets of the fund.
   753	     */
   754	    function getAssets() external view returns (ERC20[8] memory) {
   755	        if (isOpen) {
   756	            return assets;
   757	        } else {
   758	            ERC20[8] memory _assets;
   759	            _assets[0] = ERC20(address(fundFactory.weth()));
   760	            return _assets;
   761	        }
   762	    }
   763	
   764	    /**
   765	     * @notice Returns the cooldown details for a user.
   766	     * @param _account The address of the user
   767	     * @return cooldownDetails The cooldown details for the user
   768	     */
   769	    function getCooldownByUser(
   770	        address _account
   771	    ) public view returns (CooldownsByUser memory) {
   772	        return _cooldownsByUser[_account];
   773	    }
   774	
   775	    /// Public Functions ///
   776	
   777	    /**
   778	     * @dev [ERC20] Using 6 decimals to match USDT precision
   779	     */
   780	    function decimals() public pure override returns (uint8) {
   781	        return 6;
   782	    }
   783	
   784	    /**
   785	     * @notice Returns whether a user has a cooldown.
   786	     * @param _account The address of the user
   787	     * @return Whether the user has a cooldown
   788	     */
   789	    function hasCooldown(address _account) public view returns (bool) {
   790	        return _cooldownsByUser[_account].cooldowns.length > 0;
   791	    }
   792	
   793	    /**
   794	     * @notice Disable direct token transfers
   795	     */
   796	    function transfer(address, uint256) public pure override returns (bool) {
   797	        revert Error.Unauthorized();
   798	    }
   799	
   800	    /**
   801	     * @notice Disable direct token transfers
   802	     */
   803	    function transferFrom(
   804	        address,
   805	        address,
   806	        uint256
   807	    ) public pure override returns (bool) {
   808	        revert Error.Unauthorized();
   809	    }
   810	
   811	    /**
   812	     * @notice Transfers shares to another account updating the invested capital.
   813	     * @param to The address of the recipient
   814	     * @param amount The amount of shares to transfer
   815	     * @return Whether the transfer was successful
   816	     */
   817	    function transferShares(address to, uint256 amount) public returns (bool) {
   818	        uint256 senderBalance = balanceOf(msg.sender);
   819	        // Validate transfer amount
   820	        require(
   821	            senderBalance >= amount && amount != 0,
   822	            InvalidTransferAmount(senderBalance, amount)
   823	        );
   824	
   825	        // Update invested capital for both sender and recipient when transferring between accounts
   826	        unchecked {
   827	            // Transfer amount validation ensures arithmetic safety: cannot divide by zero
   828	            uint256 capitalTransferred = (amount *
   829	                investedCapital[msg.sender]) / senderBalance;
   830	            // `capitalTransferred` is proportional to `amount`, which is capped to sender balance,
   831	            // so it cannot exceed sender's invested capital
   832	            investedCapital[msg.sender] -= capitalTransferred;
   833	            investedCapital[to] += capitalTransferred;
   834	        }
   835	
   836	        _transfer(msg.sender, to, amount);
   837	
   838	        return true;
   839	    }
   840	
   841	    /**
   842	     * @notice Disable ownership renouncement
   843	     */
   844	    function renounceOwnership() public pure override {
   845	        revert Error.Unauthorized();
   846	    }
   847	
   848	    /**
   849	     * @notice Transfer ownership to a new address
   850	     * @param newOwner The address of the new owner
   851	     */
   852	    function transferOwnership(address newOwner) public override onlyOwner {
   853	        require(newOwner != address(0), Error.Unauthorized());
   854	
   855	        uint256 ownerBalance = balanceOf(owner());
   856	        if (ownerBalance > 0) {
   857	            transferShares(newOwner, ownerBalance);
   858	        }
   859	
   860	        _transferOwnership(newOwner);
   861	    }
   862	
   863	    /// Internal Functions ///
   864	
   865	    /**
   866	     * @dev Wraps ETH into WETH
   867	     */
   868	    function _wrapETH(uint256 amount) internal {
   869	        fundFactory.weth().deposit{ value: amount }();
   870	    }
   871	
   872	    /**
   873	     * @dev Unwraps WETH into ETH
   874	     */
   875	    function _unwrapETH(uint256 amount) internal {
   876	        fundFactory.weth().withdraw(amount);
   877	    }
   878	
   879	    /**
   880	     * @dev Updates the fund asset list.
   881	     * NOTE: Validation of these asset list updates is done off-chain
   882	     * @param _updates The updates to apply
   883	     */
   884	    function _updateAssets(AssetIndex[] memory _updates) internal {
   885	        for (uint8 i = 0; i < _updates.length; i++) {
   886	            assets[_updates[i].index] = _updates[i].asset;
   887	        }
   888	
   889	        emit AssetsUpdated(assets);
   890	    }
   891	
   892	    /**
   893	     * @notice Returns the cooldown details for a user.
   894	     * NOTE: this assumes that the user does have a cooldown, meaning:
   895	     * `_cooldownsByUser[_user].cooldowns.length > 0`
   896	     * @dev The offset represents the index before which all cooldowns have expired.
   897	     * The loop is checking backwards, from the end of the cooldowns array (most
   898	     * recent) to the beginning (earliest) and stopping when it finds a cooldown that
   899	     * has eventually expired. This means that any previous cooldowns are also expired,
   900	     * and we can update the offset with the current index.

exec
/bin/bash -lc "nl -ba contracts/helpers/TransferHelper.sol | sed -n '1,220p'" in /tmp/codex-uls25_n0
 succeeded in 0ms:
     1	// SPDX-License-Identifier: MIT
     2	pragma solidity =0.8.27;
     3	
     4	contract TransferHelper {
     5	    mapping(address => uint256) creditByAddress;
     6	
     7	    event CreditAdded(address indexed creditor, uint256 value);
     8	    event CreditWithdrawn(address indexed recipient, uint256 value);
     9	
    10	    error NativeTransferFailed();
    11	
    12	    /**
    13	     * @notice Safe transfer of ETH to an address. If the transfer fails, the value is added to the credit of the address.
    14	     * @param recipient The address to transfer ETH to
    15	     * @param value The amount of ETH to transfer
    16	     */
    17	    function _safeTransferETH(address recipient, uint256 value) internal {
    18	        (bool success, ) = recipient.call{ value: value }('');
    19	
    20	        if (!success) {
    21	            creditByAddress[recipient] += value;
    22	            emit CreditAdded(recipient, value);
    23	        }
    24	    }
    25	
    26	    /**
    27	     * @notice Withdraws the caller's credit to the specified recipient. This transfer will either succeed or revert.
    28	     * @param recipient The address to transfer the ETH to
    29	     */
    30	    function withdrawCredit(address recipient) external {
    31	        uint256 value = creditByAddress[msg.sender];
    32	        creditByAddress[msg.sender] = 0;
    33	        (bool success, ) = recipient.call{ value: value }('');
    34	        require(success, NativeTransferFailed());
    35	        emit CreditWithdrawn(recipient, value);
    36	    }
    37	}

exec
/bin/bash -lc 'rg -n "minUserDepositValue|minimumGuruInitialDepositValue|protocolFundCreationFeeValue|signatureValidityPeriod|protocolSwapFee|getTotalProfitFee|protocolProfitFee|protocolDepositFee" contracts' in /tmp/codex-uls25_n0
 succeeded in 0ms:
contracts/FundFactory.sol:35:    uint32 public signatureValidityPeriod = 10; // slot 0 (4 bytes)
contracts/FundFactory.sol:36:    uint16 public protocolProfitFee = 5_000; // slot 0 (2 bytes)
contracts/FundFactory.sol:37:    uint16 public protocolDepositFee = 1_000; // slot 0 (2 bytes)
contracts/FundFactory.sol:38:    uint16 public protocolSwapFee = 200; // slot 0 (2 bytes)
contracts/FundFactory.sol:41:    uint64 public minimumGuruInitialDepositValue = 1000_000000; // slot 1 (8 bytes)
contracts/FundFactory.sol:42:    uint64 public protocolFundCreationFeeValue = 100_000000; // slot 1 (8 bytes)
contracts/FundFactory.sol:78:    function getTotalProfitFee() public view returns (uint256) {
contracts/FundFactory.sol:79:        return GURU_PROFIT_FEE + protocolProfitFee;
contracts/FundFactory.sol:101:        protocolFundCreationFeeValue = _newProtocolFundCreationFee;
contracts/FundFactory.sol:112:        protocolDepositFee = _newProtocolDepositFee;
contracts/FundFactory.sol:126:        protocolProfitFee = _newProtocolFeeOnProfits;
contracts/FundFactory.sol:135:        protocolSwapFee = _newProtocolSwapFee;
contracts/FundFactory.sol:140:        minimumGuruInitialDepositValue = _newMinimum;
contracts/FundFactory.sol:147:        signatureValidityPeriod = _newSignatureValidityPeriod;
contracts/GuruFund.sol:91:    uint256 public minUserDepositValue;
contracts/GuruFund.sol:300:        minUserDepositValue = _initialDeposit.minUserDepositValue;
contracts/GuruFund.sol:418:            fees <= (msg.value * fundFactory.protocolDepositFee()) / 100_000 &&
contracts/GuruFund.sol:422:                fundFactory.protocolDepositFee(),
contracts/GuruFund.sol:716:        minUserDepositValue = _newMinValue;
contracts/structs/InitialDeposit.sol:17:    uint256 minUserDepositValue;

exec
/bin/bash -lc "rg -n \"verifySignature\\(|noncesByUser|withdrawCredit|feeCollector|forceApprove|router.call|claimAbandonedFundsForBuybackAndBurn|depositAsset\\(\" contracts" in /tmp/codex-uls25_n0
 succeeded in 0ms:
contracts/GuruFund.sol:259:        fundFactory.verifySignature(msg.sender, _payload);
contracts/GuruFund.sol:338:    function depositAsset(
contracts/GuruFund.sol:697:    function claimAbandonedFundsForBuybackAndBurn() external {
contracts/helpers/EIP712Helper.sol:15:    mapping(address => uint256) public noncesByUser;
contracts/helpers/EIP712Helper.sol:81:                                noncesByUser[_account]++,
contracts/helpers/SwapHelper.sol:14:    address public feeCollector;
contracts/helpers/SwapHelper.sol:18:        address _feeCollector
contracts/helpers/SwapHelper.sol:21:        feeCollector = _feeCollector;
contracts/helpers/SwapHelper.sol:52:        _swap.tokenIn.forceApprove(address(_swap.router), _swap.amountToSend);
contracts/helpers/SwapHelper.sol:55:        (bool success, bytes memory returnData) = _swap.router.call(
contracts/helpers/SwapHelper.sol:60:        ERC20(weth).safeTransfer(feeCollector, _swap.swapFee);
contracts/FundFactory.sol:151:    function verifySignature(
contracts/FundFactory.sol:178:        verifySignature(msg.sender, _signedPayload);
contracts/helpers/TransferHelper.sol:30:    function withdrawCredit(address recipient) external {

codex
{"no_findings":false,"summary":"Found 2 exploitable signature-validation issues affecting the GuruFund implementation: signed payloads can be replayed across different funds, and withdrawal quotes can be executed after fund state/price changes to over-withdraw value.","findings":[{"severity":"high","category":"auth","title":"Signed payloads are not bound to a specific fund instance","exploit_scenario":"A user who holds shares in two different GuruFund clones can request a legitimate signed withdrawal for Fund A, then submit the exact same payload to Fund B. Because signature verification is centralized in the factory and the signed struct omits the target fund address, Fund B will accept Fund A's quote, burn Fund B shares, and pay out Fund A's signed amounts whenever Fund B has compatible liquidity/assets. The same replay surface exists for owner-only signed actions across multiple funds owned by the same guru.","recommendation":"Bind every signed action to the specific fund address and action type. The simplest fix is to include `address(this)` (and preferably an explicit action discriminator/function selector) in the signed struct, or move EIP-712 verification into each fund so each clone has its own domain separator. Use per-fund/per-action nonces rather than a factory-global user nonce.","evidence":"`GuruFund` forwards signature checks to the shared factory with `fundFactory.verifySignature(msg.sender, _payload)`. The factory's `SIGNED_ACTION_TYPEHASH` only covers `(nonce, account, data, expiresAt)`, and `_verifyEIP712` hashes only those fields. No fund identifier or `address(this)` is ever signed, so the same payload is valid for any fund clone that calls the same factory verifier.","locations":["contracts/GuruFund.sol:258","contracts/GuruFund.sol:338","contracts/GuruFund.sol:385","contracts/GuruFund.sol:471","contracts/GuruFund.sol:506","contracts/GuruFund.sol:542","contracts/GuruFund.sol:571","contracts/GuruFund.sol:655","contracts/FundFactory.sol:23","contracts/FundFactory.sol:151","contracts/helpers/EIP712Helper.sol:66"]},{"severity":"medium","category":"auth","title":"Withdrawal signatures remain valid after rebalances, fee mints, and price moves","exploit_scenario":"A user can obtain a signed withdrawal quote, then wait until the fund state becomes less favorable to remaining LPs before executing it. `withdraw()` does not bind the payload to the fund's current `nonce`, asset set, or a protocol-enforced short validity window; it simply burns shares, executes the signed swaps, and pays the signed `netOutput`. If prices move down, the guru rebalances, or management fees dilute supply before execution, a stale quote can withdraw more value than the shares are currently worth, with the shortfall socialized to other investors.","recommendation":"Treat withdrawals like deposits: bind them to a current fund-state nonce (or another state root) and reject stale payloads after any rebalance/close/management-fee event. Enforce a maximum on-chain validity window instead of relying only on caller-supplied `expiresAt`, and validate post-swap output/value against current balances before paying out.","evidence":"The contract documents `nonce` as stale-state protection and increments it on rebalances/swaps, but only `deposit()` checks `_deposit.nonce == nonce`. `withdraw()` has no comparable freshness check and blindly trusts signed `swaps` and `amountsWei`. The factory also stores `signatureValidityPeriod`, but verification ignores it and only checks `_payload.expiresAt >= block.number`, so quote lifetime is whatever the offchain signer put in the payload.","locations":["contracts/GuruFund.sol:83","contracts/GuruFund.sol:403","contracts/GuruFund.sol:497","contracts/GuruFund.sol:532","contracts/GuruFund.sol:560","contracts/GuruFund.sol:571","contracts/GuruFund.sol:597","contracts/FundFactory.sol:35","contracts/FundFactory.sol:144","contracts/helpers/EIP712Helper.sol:71"]}]}
tokens used
66,157
Metadata
{
    "codex": {
        "ran_at": "2026-04-04T02:56:02.607178+00:00",
        "returncode": 0
    },
    "project": {
        "mode": "source",
        "file_count": 47,
        "import_count": 56,
        "first_party_contracts_count": 14
    },
    "summary": "Found 2 exploitable signature-validation issues affecting the GuruFund implementation: signed payloads can be replayed across different funds, and withdrawal quotes can be executed after fund state/price changes to over-withdraw value.",
    "raw_final": {
        "summary": "Found 2 exploitable signature-validation issues affecting the GuruFund implementation: signed payloads can be replayed across different funds, and withdrawal quotes can be executed after fund state/price changes to over-withdraw value.",
        "findings": [
            {
                "title": "Signed payloads are not bound to a specific fund instance",
                "category": "auth",
                "evidence": "`GuruFund` forwards signature checks to the shared factory with `fundFactory.verifySignature(msg.sender, _payload)`. The factory's `SIGNED_ACTION_TYPEHASH` only covers `(nonce, account, data, expiresAt)`, and `_verifyEIP712` hashes only those fields. No fund identifier or `address(this)` is ever signed, so the same payload is valid for any fund clone that calls the same factory verifier.",
                "severity": "high",
                "locations": [
                    "contracts/GuruFund.sol:258",
                    "contracts/GuruFund.sol:338",
                    "contracts/GuruFund.sol:385",
                    "contracts/GuruFund.sol:471",
                    "contracts/GuruFund.sol:506",
                    "contracts/GuruFund.sol:542",
                    "contracts/GuruFund.sol:571",
                    "contracts/GuruFund.sol:655",
                    "contracts/FundFactory.sol:23",
                    "contracts/FundFactory.sol:151",
                    "contracts/helpers/EIP712Helper.sol:66"
                ],
                "recommendation": "Bind every signed action to the specific fund address and action type. The simplest fix is to include `address(this)` (and preferably an explicit action discriminator/function selector) in the signed struct, or move EIP-712 verification into each fund so each clone has its own domain separator. Use per-fund/per-action nonces rather than a factory-global user nonce.",
                "exploit_scenario": "A user who holds shares in two different GuruFund clones can request a legitimate signed withdrawal for Fund A, then submit the exact same payload to Fund B. Because signature verification is centralized in the factory and the signed struct omits the target fund address, Fund B will accept Fund A's quote, burn Fund B shares, and pay out Fund A's signed amounts whenever Fund B has compatible liquidity/assets. The same replay surface exists for owner-only signed actions across multiple funds owned by the same guru."
            },
            {
                "title": "Withdrawal signatures remain valid after rebalances, fee mints, and price moves",
                "category": "auth",
                "evidence": "The contract documents `nonce` as stale-state protection and increments it on rebalances/swaps, but only `deposit()` checks `_deposit.nonce == nonce`. `withdraw()` has no comparable freshness check and blindly trusts signed `swaps` and `amountsWei`. The factory also stores `signatureValidityPeriod`, but verification ignores it and only checks `_payload.expiresAt >= block.number`, so quote lifetime is whatever the offchain signer put in the payload.",
                "severity": "medium",
                "locations": [
                    "contracts/GuruFund.sol:83",
                    "contracts/GuruFund.sol:403",
                    "contracts/GuruFund.sol:497",
                    "contracts/GuruFund.sol:532",
                    "contracts/GuruFund.sol:560",
                    "contracts/GuruFund.sol:571",
                    "contracts/GuruFund.sol:597",
                    "contracts/FundFactory.sol:35",
                    "contracts/FundFactory.sol:144",
                    "contracts/helpers/EIP712Helper.sol:71"
                ],
                "recommendation": "Treat withdrawals like deposits: bind them to a current fund-state nonce (or another state root) and reject stale payloads after any rebalance/close/management-fee event. Enforce a maximum on-chain validity window instead of relying only on caller-supplied `expiresAt`, and validate post-swap output/value against current balances before paying out.",
                "exploit_scenario": "A user can obtain a signed withdrawal quote, then wait until the fund state becomes less favorable to remaining LPs before executing it. `withdraw()` does not bind the payload to the fund's current `nonce`, asset set, or a protocol-enforced short validity window; it simply burns shares, executes the signed swaps, and pays the signed `netOutput`. If prices move down, the guru rebalances, or management fees dilute supply before execution, a stale quote can withdraw more value than the shares are currently worth, with the shortfall socialized to other investors."
            }
        ],
        "no_findings": false
    },
    "no_findings": false,
    "proxy_context": {
        "proxy_run_id": "019d5666-7189-71df-84df-536a07997697",
        "proxy_address": "0xba4f097d22f283e619873f8e034755fc2e5a7c90",
        "implementation_run_id": "019d5666-7793-71fe-8365-fe0e83fcb95f",
        "implementation_address": "0xec8902afffb06d0b075ea2d6fb3a45ec8598c39b"
    },
    "schema_version": 1
}

Opcode Heuristics

tool.cast_disasm
Delegatecall
1
Selfdestruct
0
CREATE2
0
CALL-family (heavy)
0
EXT*/BALANCE
0
Total opcodes
24
Flags
delegatecall_present
View cast disassembly output
00000000: CALLDATASIZE
00000001: RETURNDATASIZE
00000002: RETURNDATASIZE
00000003: CALLDATACOPY
00000004: RETURNDATASIZE
00000005: RETURNDATASIZE
00000006: RETURNDATASIZE
00000007: CALLDATASIZE
00000008: RETURNDATASIZE
00000009: PUSH20 0xec8902afffb06d0b075ea2d6fb3a45ec8598c39b
0000001e: GAS
0000001f: DELEGATECALL
00000020: RETURNDATASIZE
00000021: DUP3
00000022: DUP1
00000023: RETURNDATACOPY
00000024: SWAP1
00000025: RETURNDATASIZE
00000026: SWAP2
00000027: PUSH1 0x2b
00000029: JUMPI
0000002a: REVERT
0000002b: JUMPDEST
0000002c: RETURN

Findings

validated = confirmed for call sinks; sink observed for others
Severity Category Tool Title Confidence Validated Confirmed Details
critical upgradeability detector Untrusted DELEGATECALL target reachable 70 no no
view
trace_id: 0xa4baae9fba6bae81972323f40d50a382440c5e06a7047d1f327fa36a3f2f0065
call_targets: 0xec8902afffb06d0b075ea2d6fb3a45ec8598c39b
target_varies: no
classification: constant_target
validation_json
{
    "sink": "DELEGATECALL",
    "errors": 0,
    "status": "sink_reached",
    "attempts": 1,
    "trace_id": "0xa4baae9fba6bae81972323f40d50a382440c5e06a7047d1f327fa36a3f2f0065",
    "confirmed": false,
    "trace_mode": "callTracer",
    "call_targets": [
        "0xec8902afffb06d0b075ea2d6fb3a45ec8598c39b"
    ],
    "matched_probe": null,
    "target_varies": false,
    "classification": "constant_target"
}
witness_json
{
    "notes": "heuristic witness; entrypoints are best-effort selectors with zeroed calldata",
    "constraints": [],
    "entrypoints": [
        {
            "calldata": "0x",
            "selector": "fallback",
            "calldata_variants": [
                "0x"
            ]
        }
    ]
}
evidence_json
{
    "examples": [
        {
            "pc": 31,
            "opcode": "DELEGATECALL",
            "returndata_checked": null,
            "value_expression_category": "n/a",
            "target_expression_category": "computed"
        }
    ],
    "guards_detected": [],
    "delegatecall_count": 1,
    "untrusted_delegatecall_count": 1
}
high auth codex Signed payloads are not bound to a specific fund instance 85 no
view
evidence_json
{
    "evidence": "`GuruFund` forwards signature checks to the shared factory with `fundFactory.verifySignature(msg.sender, _payload)`. The factory's `SIGNED_ACTION_TYPEHASH` only covers `(nonce, account, data, expiresAt)`, and `_verifyEIP712` hashes only those fields. No fund identifier or `address(this)` is ever signed, so the same payload is valid for any fund clone that calls the same factory verifier.",
    "locations": [
        "contracts/GuruFund.sol:258",
        "contracts/GuruFund.sol:338",
        "contracts/GuruFund.sol:385",
        "contracts/GuruFund.sol:471",
        "contracts/GuruFund.sol:506",
        "contracts/GuruFund.sol:542",
        "contracts/GuruFund.sol:571",
        "contracts/GuruFund.sol:655",
        "contracts/FundFactory.sol:23",
        "contracts/FundFactory.sol:151",
        "contracts/helpers/EIP712Helper.sol:66"
    ],
    "recommendation": "Bind every signed action to the specific fund address and action type. The simplest fix is to include `address(this)` (and preferably an explicit action discriminator/function selector) in the signed struct, or move EIP-712 verification into each fund so each clone has its own domain separator. Use per-fund/per-action nonces rather than a factory-global user nonce.",
    "exploit_scenario": "A user who holds shares in two different GuruFund clones can request a legitimate signed withdrawal for Fund A, then submit the exact same payload to Fund B. Because signature verification is centralized in the factory and the signed struct omits the target fund address, Fund B will accept Fund A's quote, burn Fund B shares, and pay out Fund A's signed amounts whenever Fund B has compatible liquidity/assets. The same replay surface exists for owner-only signed actions across multiple funds owned by the same guru."
}
medium upgradeability cast DELEGATECALL present 80 no
view
evidence_json
{
    "notes": "delegatecall enables execution in caller context; common in proxies/upgradeable patterns",
    "delegatecall_count": 1
}
medium auth codex Withdrawal signatures remain valid after rebalances, fee mints, and price moves 75 no
view
evidence_json
{
    "evidence": "The contract documents `nonce` as stale-state protection and increments it on rebalances/swaps, but only `deposit()` checks `_deposit.nonce == nonce`. `withdraw()` has no comparable freshness check and blindly trusts signed `swaps` and `amountsWei`. The factory also stores `signatureValidityPeriod`, but verification ignores it and only checks `_payload.expiresAt >= block.number`, so quote lifetime is whatever the offchain signer put in the payload.",
    "locations": [
        "contracts/GuruFund.sol:83",
        "contracts/GuruFund.sol:403",
        "contracts/GuruFund.sol:497",
        "contracts/GuruFund.sol:532",
        "contracts/GuruFund.sol:560",
        "contracts/GuruFund.sol:571",
        "contracts/GuruFund.sol:597",
        "contracts/FundFactory.sol:35",
        "contracts/FundFactory.sol:144",
        "contracts/helpers/EIP712Helper.sol:71"
    ],
    "recommendation": "Treat withdrawals like deposits: bind them to a current fund-state nonce (or another state root) and reject stale payloads after any rebalance/close/management-fee event. Enforce a maximum on-chain validity window instead of relying only on caller-supplied `expiresAt`, and validate post-swap output/value against current balances before paying out.",
    "exploit_scenario": "A user can obtain a signed withdrawal quote, then wait until the fund state becomes less favorable to remaining LPs before executing it. `withdraw()` does not bind the payload to the fund's current `nonce`, asset set, or a protocol-enforced short validity window; it simply burns shares, executes the signed swaps, and pays the signed `netOutput`. If prices move down, the guru rebalances, or management fees dilute supply before execution, a stale quote can withdraw more value than the shares are currently worth, with the shortfall socialized to other investors."
}