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 with initialize.

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 mintFromDeposit atomically 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() sums Math.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 run accrueMgmtFee first, so if a management fee is owed they slightly overstate the real mint cost / redeem payout — call accrueMgmtFee() 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); receive sharesOut - 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.