Basket
contracts/src/Basket.sol · Solidity 0.8.26
The vault. A Basket holds the raw constituent stock tokens and issues ERC-20 shares. Its core is oracle-free in-kind mint/redeem (pro-rata on tracked balances); NAV/price-per-share are used strictly for display, zap pricing, and fee accounting — never to gate mint/redeem. It streams a management fee via share dilution and splits every fee 90% creator / 10% protocol treasury.
- Inherits:
Initializable,ERC20Upgradeable,ReentrancyGuardUpgradeable. Deployed once as an implementation and used via minimal-proxy clones created by the BasketFactory; each clone is configured withinitialize.
Constants
| Constant | Value | Meaning |
|---|---|---|
DEAD_SHARES |
1e15 |
Shares minted to a dead address at init and locked forever (supply floor / inflation guard) |
ENTRY_FEE_CAP |
300 |
Max entry fee, bps (3%) |
EXIT_FEE_CAP |
100 |
Max exit fee, bps (1%) |
MGMT_FEE_CAP |
300 |
Max management fee, bps/year (3%) |
PROTOCOL_CUT_BPS |
1000 |
Protocol's cut of every fee: 10% (creator gets 90%) |
YEAR |
365 days |
Annualization denominator for the management fee |
Storage
address public factory; // the BasketFactory that created this clone
address public creator; // receives 90% of fees + the seed shares
address public treasury; // receives 10% of fees
Whitelist public whitelist; // oracle / allowlist config source
mapping(address => uint256) public trackedBalance; // per-constituent held units (internal accounting)
uint16 public entryFeeBps;
uint16 public exitFeeBps;
uint16 public mgmtFeeBpsPerYear;
uint64 public lastMgmtAccrual; // timestamp of last mgmt-fee accrual
bool public mintPaused; // pauses mint paths (redeem is never pausable)
struct InitParams {
address creator; address treasury; address whitelist;
address[] tokens; uint256[] seedUnits; uint256 seedShares;
uint16 entryFeeBps; uint16 exitFeeBps; uint16 mgmtFeeBpsPerYear;
string name; string symbol;
}
Mutating functions
initialize
function initialize(InitParams calldata p) external initializer
One-time setup (called by the factory, which becomes factory). Validates fee caps and params, verifies each constituent is whitelisted, non-duplicate, has a non-zero seed unit, and that the clone already holds at least the required seed amount, sets trackedBalance, then mints seedShares to the creator and DEAD_SHARES to the dead address.
Reverts FeeAboveCap() if any fee exceeds its cap; BadParams() for bad token/seed arrays (tokens.length != seedUnits.length, < 2 or > 20 tokens, seedShares == 0, zero treasury, a zero seed unit, a non-whitelisted or duplicate token, or insufficient pre-transferred balance).
mintInKind
function mintInKind(uint256 sharesOut, address to) external nonReentrant returns (uint256[] memory unitsIn)
Oracle-free pro-rata mint. Deposit each constituent in the vault's current ratio and receive sharesOut shares minus the entry fee. Accrues the management fee first, computes unitsIn = previewMintInKind(sharesOut), pulls each amount from the caller, and mints net shares to to. Reverts MintPaused(), ZeroShares(). Emits MintInKind.
mintFromDeposit (zap-mint)
function mintFromDeposit(address to, uint256 minSharesOut) external nonReentrant returns (uint256 netShares)
Permissionless zap-mint: shares are credited by the measured token-balance delta, not caller intent. For each token it computes delta = balanceOf(this) - trackedBalance[t] and cred = delta × S / trackedBalance[t], takes the minimum across constituents (worst-leg pro-rata) as the gross, then syncs all tracked balances to live. Charges the entry fee; netShares = gross - fee. Reverts MintPaused(), ZeroShares(), SlippageExceeded() if netShares < minSharesOut. Emits ZapMint.
Security: this is not an escrow. Deltas are claimed by whoever calls first, regardless of who sent the tokens. Integrators (e.g. the HoodZapRouter) must transfer tokens in and call
mintFromDepositatomically in the same transaction — tokens left across transactions can be front-run.
redeemInKind
function redeemInKind(uint256 sharesIn, address to) external nonReentrant returns (uint256[] memory unitsOut)
Oracle-free pro-rata redeem. Never pausable — the solvency guarantee. Accrues the management fee, computes the exit fee and its protocol cut, burns sharesIn - fee from the caller, transfers the fee shares to creator/treasury, decrements tracked balances, then pushes each constituent to to (effects before external calls). Reverts ZeroShares(). Emits RedeemInKind.
Others
function accrueMgmtFee() external nonReentrant // permissionless poke to stream the mgmt fee
function setMintPaused(bool paused) external onlyFactory // pause/unpause mint (reverts NotFactory)
Plus the inherited ERC-20 surface (transfer, approve, transferFrom, balanceOf, totalSupply, …).
View functions
function allTokens() external view returns (address[] memory)
function previewMintInKind(uint256 sharesOut) public view returns (uint256[] memory unitsIn)
function previewRedeemInKind(uint256 sharesIn) public view returns (uint256[] memory unitsOut)
function nav() public view returns (uint256 navUsd1e18, bool stale)
function pricePerShare() external view returns (uint256 pps1e18, bool stale)
nav()sumsMath.mulDiv(trackedBalance[t], price1e18, 10 ** whitelist.tokenDecimals(t))over all tokens, using OracleLib prices; if any leg is stale,stale = true(and that leg contributes 0). Display / pricing / fee-accounting only.pricePerShare()=nav × 1e18 / totalSupply(returns(0, true)if supply is 0).- The two
preview*functions do not runaccrueMgmtFeefirst, so if a management fee is owed they slightly overstate the real mint cost / redeem payout — callaccrueMgmtFee()first for an exact quote.
Events
event MintInKind(address indexed to, uint256 sharesGross, uint256 feeShares);
event RedeemInKind(address indexed from, uint256 sharesIn, uint256 feeShares);
event ZapMint(address indexed to, uint256 sharesGross, uint256 feeShares);
event MgmtFeeAccrued(uint256 feeShares);
event MintPauseSet(bool paused);
Errors
| Error | Thrown when |
|---|---|
FeeAboveCap() |
initialize with a fee above its cap |
NotFactory() |
setMintPaused called by non-factory |
MintPaused() |
mint attempted while paused |
ZeroShares() |
a share amount is zero (or a zap gross resolves to 0) |
BadParams() |
initialize with invalid tokens/seed/treasury/duplicate/balance |
SlippageExceeded() |
mintFromDeposit produced fewer than minSharesOut |
NotCreator() |
declared in the ABI; currently unused (no function reverts it) |
NotGuardian() |
declared in the ABI; currently unused (no function reverts it) |
Key mechanics
- Fee split (all fees):
protocolShares = fee × 1000 / 10000(10% to treasury), remainder (90%) to creator. - Management fee:
feeShares = totalSupply × mgmtFeeBpsPerYear × dt / (10000 × YEAR), minted as dilution and split 90/10. Floor division — never over-charges. - In-kind mint: deposit
unitsIn_i = ceil(trackedBalance_i × sharesOut / S)(rounds up, favoring the vault); receivesharesOut - entryFee. - In-kind redeem: receive
unitsOut_i = floor(trackedBalance_i × (sharesIn - exitFee) / S)(rounds down, favoring the vault). - Zap-mint:
gross = min_i floor(delta_i × S / trackedBalance_i)(worst-leg pro-rata — resistant to donation/rounding attacks); over-delivery dust stays in the vault and accrues to all holders. - DEAD_SHARES:
totalSeed = seedShares + DEAD_SHARES, so the seed transfer must cover the locked dead shares too. - All mutating entry points are
nonReentrant; in-kind mint/redeem never touch the oracle.