019d5666-7189-71df-84df-536a07997697
Open this run to answer one question: is this real attacker-relevant behavior with real impact, or just normal contract plumbing?
| 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 |
{
"method": "minimal_proxy",
"status": "onchain_only",
"implementation": "0xec8902afffb06d0b075ea2d6fb3a45ec8598c39b",
"onchain_method": "minimal_proxy"
}
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
{"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"]}]}
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
{
"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
}
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
| Severity | Category | Tool | Title | Confidence | Validated | Confirmed | Details |
|---|---|---|---|---|---|---|---|
| critical | upgradeability | detector | Untrusted DELEGATECALL target reachable | 70 | no | no |
viewtrace_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 | — |
viewevidence_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 | — |
viewevidence_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 | — |
viewevidence_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."
}
|