/
01

Ivy Vaults — manual-settlement option vaults (covered calls + cash-secured puts)

Two contracts, ~300 lines. An LP (covered call) or a crowd of depositors (cash-secured put) put an ERC-20 into a vault and receive shares 1:1 with the amount actually received. Shares are an internal accounting entry — not an ERC-20, not transferable, and deliberately so: each depositor's row carries context that cannot travel with a token (their premium baseline, their intent, their relationship with the multisig). A market maker pays premium on-chain in a whitelisted stablecoin; the vault credits it pro-rata through a MasterChef accumulator, and depositors pull it with claim. When an option finishes in the money the MM escrows the exercise payment and names the amount of underlying it is buying; the Ivy multisig approves (payment goes to depositors pro-rata, underlying goes to the MM) or rejects (escrow refunded). Nothing about strike, expiry or spot is on-chain — the MM's escrowed terms are the price and the multisig's approval is the price check. Principal leaves only through fulfillWithdraw, sized by the multisig against what is genuinely idle.

Trust model — stated bluntly

  • The Ivy multisig is fully trusted. LPs are curated, settlement is manual, and this is disclosed by design — in the README, in this document, and in the UI. There is no on-chain strike, no expiry, no oracle, no automatic redemption.
  • The multisig acting alone can only route funds to depositors or back to an escrow's proposer. Its unilateral powers are exactly fulfillWithdraw (asset → a depositor, debiting their shares) and rejectExercise (escrow → the MM that posted it).
  • Extracting the underlying takes two parties. Asset can leave to a non-depositor only via proposeExercise (MM signs, escrows real payment) followed by approveExercise (multisig signs). There is no direct multisig withdrawal function; a compromised multisig key cannot move underlying on its own.
  • One bounded path pays the multisig: skim. It sweeps only balance above principal + owed premium + live escrows — positive rebases, strays, airdrops, rounding dust. It can never reach depositor money.
  • Known, accepted, unfixable in a contract: a token that can freeze this address (USDT-style blacklist) bricks any vault holding it. A more trustless 2-of-3 design (LP / MM / Ivy, terms committed on-chain) is sketched as v2 and deliberately out of scope for v1.
IvyVault · Ownable2Step · ReentrancyGuard IvyVaultFactory · Ownable multiDeposit=false ⇒ covered call multiDeposit=true ⇒ cash-secured put no ERC-4626 (permissionless withdraw fights msig approval) no upgradeability
02

Lifecycle

One full cycle, from deposit to settled withdrawal. Hover a stage for its note; click to pin it. Diagram scrolls horizontally on narrow screens.

LP depositor market maker (anyone) Ivy multisig vault-internal accounting
03

Every external function

Both contracts, complete. Each card carries the exact semantics (what), the operational reason it exists (why), and the design-review call behind it (decision — what was debated and what was chosen).

Filter by role
04

Pro-rata premium — live

The accumulator is MasterChef, per premium token, scaled by 1e18. Everything below is computed in BigInt with floor division, exactly as the EVM does it — including the dust that rounds down and stays locked. Round 2 exists to show rewardDebt banking: depositor C joins after premium 1 and is baselined at the current accumulator — as the exact unscaled product shares × acc, so the single floor lands on the difference at bank time — and therefore cannot reach into premium already credited.


Units are raw token units on both axes; shares are credited 1:1 with the asset amount received, so totalShares = A + B (+ C).

TOTAL PREMIUM
Claimable after both rounds
A
B
C
dust (locked)
Round 1 — split across A + B
Round 2 — split across A + B + C
05

Exercise escrow — state machine

A Proposal has exactly one boolean of state: open. It is set true by the MM and false by the multisig, and there is no third transition. Click a node for detail.

proposeExercise() approveExercise(id) rejectExercise(id) no MM exit — no cancel, no expiry Market maker escrows payment Proposed open == true Approved · terminal asset ⇒ MM · payment ⇒ LPs Rejected · terminal escrow ⇒ back to MM open = false open = false
06

Accounting invariants

What an auditor should assert. All of it is enforced by construction — the vault never computes a NAV, it only tracks three disjoint claims on its balances: principal, owed premium, live escrows.

Asset solvency

balanceOf(asset) ≥ totalShares + totalOwed[asset] + escrowed[asset]

Shares are credited 1:1 with the asset actually received, so totalShares is the principal claim. Premium or an exercise payment denominated in the asset itself piles on top of the same balance — totalOwed[asset] and escrowed[asset] keep those claims separable, which is exactly why premiumToken == asset is allowed.

Premium conservation, per token

Σ claimed[t] + Σ claimable[u][t] + Σ pending[u][t] ≤ Σ credited[t] (equality up to dust) totalOwed[t] = Σ claimable[u][t] + Σ pending[u][t]

totalOwed[t] rises only in _credit (premium and approved exercise payments share one path) and falls only in _claim. The inequality never inverts because every division floors.

Escrow conservation, per token

escrowed[t] = Σ p.paymentAmount for all p where p.open

Incremented only in proposeExercise, decremented only in approveExercise / rejectExercise, and each proposal flips open exactly once — the ProposalClosed guard makes double-settlement unreachable.

Skim is defined by exclusion

locked = (token == asset ? totalShares : 0) + totalOwed[token] + escrowed[token] require(bal > locked) // else NothingToSkim transfer(multisig, bal − locked)

Every claim the vault knows about is subtracted before anything moves, so skim cannot touch depositor principal, owed premium, or an open proposal's escrow — whatever a rebasing token does to bal.

Dust rounds down and stays

accPerShare[t] += (received × 1e18) / totalShares pending(u) = (shares[u] × accPerShare[t] − rewardDebt[u][t]) / 1e18

Both divisions floor, so the sum of credits is ≤ the amount received. The remainder is permanently unclaimable by depositors and sits in totalOwed[t]'s shadow; it is recoverable only by skim, and only once totalOwed has actually been paid down.

Debt is re-baselined, never stale

_bank(u) // (shares×acc − rewardDebt)/1e18 ⇒ claimable shares[u] ±= x _resetDebt(u) // rewardDebt = shares × acc (unscaled)

Every share change is bracketed by bank-then-rebaseline, in deposit and fulfillWithdraw alike. rewardDebt stores the exact product shares × accPerShare — never divided — so re-baselining is lossless and flooring happens exactly once, at banking. That is what makes the subtraction in pendingPremium underflow-free by construction and what stops a late depositor from reaching earlier premium.

07

Scenarios — every way a cycle can end

Every way a cycle can finish, as call sequences. Nine live paths plus two degenerate ones the design permits and the runbook has to cover. Pick one for its exact call order and the end state it leaves behind. Timelines scroll sideways.

Multi-deposit vaults run all of the above per depositor — A can be sitting in S5 while B is in S9. Shares and pendingWithdraw are per-user, and every premium split stays pro-rata throughout.

08

Guides — LP, market maker, Ivy ops

Three runbooks against the same two contracts. Settlement is manual: every step below is either a call you make or a call you wait on the Ivy multisig to make.

Guide for
LPdepositor You own the vault, you fund it, you close the gate before bids go live.
  1. factory.createVault(bool multiDeposit, string vaultIntent)

    false = covered call — only you deposit. true = cash-secured put — anyone can. You become the vault's Ownable2Step owner; the factory's current multisig is baked in as an immutable.

  2. token.approve(vault, amount)vault.deposit(token, amount, intent)

    The FIRST deposit permanently fixes the vault's asset and must come from you, the LP — it is owner-only even in multi vaults (front-run protection); everyone else deposits after it. Any other token later reverts WrongToken. Fee-on-transfer tokens credit the net amount received, not the amount requested. intent is optional free text stored on-chain; empty leaves the existing one, and setIntent(intent) edits it later without a deposit.

  3. setDepositsOpen(false)

    Call it once bids go live. On multi-deposit vaults this is what stops premium dilution: a late depositor landing just before depositPremium would take a pro-rata cut of premium quoted against a smaller book. Reopen next cycle. Claims, premium, exercises and withdrawals all keep working while shut.

  4. pendingPremium(you, token)claim(token) / claimAll()

    Premium is yours the instant the MM pays it, even while principal is locked against a live option. Claim any time; claimAll() sweeps every premium token this vault has ever seen.

  5. requestWithdraw(amount)

    Advisory signal only — moves nothing, is not checked against your shares, last write wins, 0 cancels. The multisig fulfills what is genuinely free: possibly partial, possibly unprompted when a cycle finds no buyer.

  6. transferOwnership(newOwner) → new key calls acceptOwnership()

    Standard Ownable2Step. Shares stay exactly where they are — ownership only gates deposit in covered-call mode and the setDepositsOpen toggle. The vault's vaultIntent was fixed at creation; per-depositor setIntent is open to anyone.

09

Reference — ABI, errors, events, deployment

Read straight off src/IvyVaultFactory.sol and src/IvyVault.sol at solc 0.8.26. Signatures are Solidity source form — IERC20 ABI-encodes as address, so deposit's selector is over deposit(address,uint256,string). The box filters every table in this section.

Filter

IvyVaultFactory — functions

FunctionAccessRevertsEmits
constructor(address multisig_)anyoneZeroAddressMultisigSet · OwnershipTransferred
setMultisig(address multisig_)ownerZeroAddress · OwnableUnauthorizedAccountMultisigSet
setPremiumToken(address token, bool allowed)ownerOwnableUnauthorizedAccountPremiumTokenSet
createVault(bool multiDeposit, string calldata vaultIntent) → address vaultanyoneVaultCreated · vault's OwnershipTransferred
transferOwnership(address newOwner)ownerOwnableInvalidOwner (zero) · OwnableUnauthorizedAccountOwnershipTransferred — 1-step here
renounceOwnership()ownerOwnableUnauthorizedAccountOwnershipTransferred
views & public getters
multisig() → addressanyone
isPremiumToken(address token) → boolanyone
vaults(uint256 i) → addressanyonePanic(0x32) out of bounds
vaultsLength() → uint256anyone
allVaults() → address[] memoryanyone
owner() → addressanyone

Factory is plain Ownable — ownership moves in one step, unlike the vault. owner is the Ivy deployer key, a different principal from a vault's multisig.

IvyVault — functions

FunctionAccessRevertsEmits
depositors
deposit(IERC20 token, uint256 amount, string calldata intent)LP anyoneDepositsClosed · NotLP · WrongToken · ZeroAmountAssetSet (first ever) · IntentSet (non-empty) · Deposited
setDepositsOpen(bool open)LPOwnableUnauthorizedAccountDepositsToggled
setIntent(string calldata intent)anyoneIntentSet
requestWithdraw(uint256 amount)anyoneWithdrawRequested
claim(address token)anyoneClaimed (skipped when banked = 0)
claimAll()anyoneClaimed × 0…premiumTokensLength()
market maker
depositPremium(address token, uint256 amount)anyoneNotPremiumToken · NoShares · ZeroAmountPremiumDeposited
proposeExercise(address paymentToken, uint256 paymentAmount, uint256 assetOut)anyoneNotPremiumToken · ZeroAmount (assetOut or net received)ExerciseProposed
multisig
fulfillWithdraw(address user, uint256 amount)multisigNotMultisig · ExceedsSharesWithdrawFulfilled
approveExercise(uint256 id)multisigNotMultisig · ProposalClosed · NoShares · Panic(0x32)ExerciseApproved
rejectExercise(uint256 id)multisigNotMultisig · ProposalClosed · Panic(0x32)ExerciseRejected
skim(address token)multisigNotMultisig · NothingToSkimSkimmed
ownership — Ownable2Step
transferOwnership(address newOwner)LPOwnableUnauthorizedAccount — no zero check on this overrideOwnershipTransferStarted
acceptOwnership()anyone = pendingOwner()OwnableUnauthorizedAccountOwnershipTransferred
renounceOwnership()LPOwnableUnauthorizedAccountOwnershipTransferred
views & public getters
pendingPremium(address user, address token) → uint256anyone— banked claimable + unbanked accrual
premiumTokensLength() → uint256anyone
proposalsLength() → uint256anyone
proposals(uint256 id) → (address proposer, address paymentToken, uint256 paymentAmount, uint256 assetOut, bool open)anyonePanic(0x32) out of bounds
shares(address user) → uint256anyone
totalShares() → uint256anyone
claimable(address user, address token) → uint256anyone— banked only; prefer pendingPremium
rewardDebt(address user, address token) → uint256anyone— unscaled shares × accPerShare
accPerShare(address token) → uint256anyone— scaled by 1e18
totalOwed(address token) → uint256anyone
escrowed(address token) → uint256anyone
pendingWithdraw(address user) → uint256anyone— advisory signal only
intents(address user) → string memoryanyone
premiumTokens(uint256 i) → addressanyonePanic(0x32) out of bounds
isTrackedPremiumToken(address token) → boolanyone— ever credited here, not the whitelist
asset() → addressanyone— zero until the first deposit
depositsOpen() → boolanyone
multiDeposit() → boolanyone— immutable; false ⇒ covered call
multisig() → addressanyone— immutable
factory() → addressanyone— immutable
vaultIntent() → string memoryanyone— fixed at creation
owner() → addressanyone— the LP
pendingOwner() → addressanyone

Every token-moving function is nonReentrant and routes through SafeERC20, so each can additionally revert ReentrancyGuardReentrantCall(), SafeERC20FailedOperation(token), or the token's own revert data — omitted per row above. deposit is LP-only while multiDeposit == false (covered call) and anyone when it is true (cash-secured put).

Custom errors

ErrorThrown byMeaning
ZeroAddress()factory constructor, setMultisig; vault constructorMultisig address is address(0). Unreachable in a vault built through the factory, which never stores a zero multisig.
ZeroAmount()deposit, depositPremium, proposeExerciseThe balance delta actually received was 0 — zero amount, or a fee-on-transfer token that took all of it. Also raised by proposeExercise when assetOut == 0.
DepositsClosed()depositdepositsOpen == false; the LP shut the gate so live quotes cannot be diluted. Claims, premium, exercises and withdrawals keep working.
NotLP()depositCovered-call vault (multiDeposit == false) and the caller is not owner() — or this is the vault's FIRST deposit (the asset-setting deposit is LP-only in both modes).
NotMultisig()fulfillWithdraw, approveExercise, rejectExercise, skimCaller is not the vault's immutable multisig. Distinct from owner(), which is the LP.
WrongToken()depositasset is already pinned and token != asset. The first deposit ever fixes it permanently.
ExceedsShares()fulfillWithdrawamount > shares[user]. The multisig may pay less than requested, never more than held.
NotPremiumToken()depositPremium, proposeExercisefactory.isPremiumToken(token) is false. The whitelist is read live, so de-whitelisting blocks new payments immediately.
NoShares()depositPremium, approveExercisetotalShares == 0 — nobody to credit pro-rata. With no shares left an open proposal can only be rejected, not approved.
ProposalClosed()approveExercise, rejectExerciseproposals[id].open == false. Each proposal flips exactly once, which makes double-settlement unreachable.
NothingToSkim()skimBalance ≤ (token == asset ? totalShares : 0) + totalOwed[token] + escrowed[token]. Skim only ever reaches genuine excess.
inherited — OpenZeppelin v5.7
OwnableUnauthorizedAccount(address account)both, every onlyOwner; vault acceptOwnershipCaller is not the owner (vault: the LP; factory: the Ivy deployer), or is not pendingOwner() on accept.
OwnableInvalidOwner(address owner)both constructors; factory transferOwnershipOwner would become address(0). The vault's Ownable2Step.transferOwnership override has no such check — it just parks a zero pendingOwner, which nobody can accept.
ReentrancyGuardReentrantCall()vault, every nonReentrant functionA token callback (ERC-777 / ERC-1363 hook) re-entered the vault.
SafeERC20FailedOperation(address token)vault, every transfer pathThe token reverted without data or returned false — includes USDT-style blacklist freezes.
Panic(0x32)proposals, premiumTokens, vaults, approveExercise, rejectExerciseArray index out of bounds — a solc panic, not a custom error. Bound id with proposalsLength() first.

Events

EventFields (★ = indexed)WhenIndexer note
IvyVaultFactory
VaultCreatedvault · ★ lp · multiDeposit · vaultIntentcreateVaultRegistry discovery — the log stream equivalent of allVaults(). multiDeposit tags the vault type: false ⇒ covered call, true ⇒ cash-secured put.
MultisigSetmultisig (not indexed)constructor + setMultisigFuture vaults only. Not indexed — filter client-side. Deployed vaults keep their immutable, so never back-apply this to existing vaults.
PremiumTokenSettoken · allowedsetPremiumTokenLatest-wins per token; fires even for a no-op write. Vaults read the mapping live, so the newest log is the truth.
IvyVault
AssetSetassetfirst-ever depositExactly one per vault, forever; precedes the Deposited in the same tx. Until it lands, asset() is address(0).
Depositeduser · amount · intentdepositamount = net received, not sent — shares credited equal it 1:1. Never reconstruct balances from the caller's transfer amount.
DepositsToggledopensetDepositsOpenFires even when the value is unchanged — dedupe on state, not on the event.
IntentSetuser · intentsetIntent, or deposit with a non-empty intentLatest-wins per user; emitted before Deposited when inline. Empty string on deposit leaves the old intent and emits nothing.
WithdrawRequesteduser · amountrequestWithdrawAdvisory queue signal — moves nothing and is not validated against shares. 0 cancels; last write wins.
WithdrawFulfilleduser · amountfulfillWithdrawAlso decrements the open request, saturating at 0 — so pair it with WithdrawRequested to keep a queue honest. amount = shares debited = asset units sent.
PremiumDepositedfrom · ★ token · amountdepositPremiumamount = net received and credited to accPerShare. from is whoever paid; the function is permissionless.
Claimeduser · ★ token · amountclaim / claimAll, banked > 0Skipped entirely when nothing is banked, so claimAll emits 0…N of these. Σamount per token is the paid-out total.
ExerciseProposedid · ★ proposer · ★ paymentToken · paymentAmount · assetOutproposeExerciseid is the proposals[] index — key every later approve/reject on it. paymentAmount = net escrowed, not sent.
ExerciseApprovedid · assetOut · ★ paymentToken · paymentAmountapproveExerciseTerminal for id. paymentAmount enters the same accumulator premium uses; assetOut leaves to the proposer with no shares debited.
ExerciseRejectedidrejectExerciseTerminal for id, escrow refunded in full. Carries no amounts — join back to ExerciseProposed on id.
Skimmedtoken · amountskimExcess above principal + owed premium + live escrows only. Never depositor money, so exclude it from any TVL delta.
inherited — OpenZeppelin v5.7
OwnershipTransferStartedpreviousOwner · ★ newOwnervault transferOwnershipVault only (Ownable2Step). Not a handover yet — LP change is final on OwnershipTransferred.
OwnershipTransferredpreviousOwner · ★ newOwnerboth: construction, accept, renounce; factory transferOwnershipOn the vault this is the LP of record; on the factory, the Ivy deployer key.

Integration notes

  • Shares are 1:1 with the net asset received. No exchange rate, no NAV, no ERC-20, no redeem: shares[u] / totalShares is the only ratio in the system, and it is a ledger of who put in what.
  • Display pendingPremium(user, token), never claimable. claimable is banked premium only; pendingPremium = claimable + the unbanked accumulator accrual, which is what the user actually gets.
  • Premium math floors — expect ≤ wei-level dust. Both _credit and the pending computation round down, so Σ credited to depositors is a hair under Σ received. Do not assert equality; assert .
  • The proposals index is the id. After proposeExercise, id == proposalsLength() - 1; proposals(id) and every exercise event key on it. There is no separate handle.
  • asset() is address(0) until the first deposit. A freshly created vault has no asset, no decimals and no symbol to render — read AssetSet or poll asset() before assuming one.
  • deposit's IERC20 is an address on the wire. The selector is over deposit(address,uint256,string); encode it as a plain address.

Deployment

  • Step 0 — the Safe. A chain's Ivy multisig is a constructor argument, so the Safe must exist before the factory does. None exist yet; Tier-1 chains deploy through the official Safe{Wallet} UI.
  • Factory. new IvyVaultFactory(multisig_) — reverts ZeroAddress on zero, emits MultisigSet, and makes the deployer the 1-step owner.
  • Whitelist. The owner then calls setPremiumToken(stable, true) once per stablecoin. Vaults read isPremiumToken live, so this must land before the first depositPremium or proposeExercise.
  • Same address everywhere. CREATE2 through a deterministic-deployer proxy (Arachnid / CreateX), same salt and same init code per chain. On brand-new chains — Tempo, Stable, Robinhood Chain, MegaETH — verify the deployer exists or deploy it first.
  • 24 chains green for day 1. Top 10 by TVL:
Ethereum · 1 BNB Chain · 56 Base · 8453 HyperEVM · 999 Arbitrum One · 42161 Polygon PoS · 137 Monad · 143 Plasma · 9745 Avalanche C-Chain · 43114 Robinhood Chain · 4663

Full matrix — Tier 2 caveats, ZK-stack exclusions and deploy notes: CHAINS.md.

solc 0.8.26 evm_version = paris · required on Sei (no PUSH0), safe everywhere OpenZeppelin v5.7 · SafeERC20 · Ownable2Step · ReentrancyGuard Foundry · optimizer 200 runs MIT
10

Security & trust — powers, invariants, accepted risks

This section is the canonical known-issues disclosure for the Sherlock contest. Ivy Vaults is a manual-settlement design with a fully trusted multisig: most of what follows is deliberate behaviour, not a defect. Where a trade-off was made, it is named here — the power, the bound that contains it, and why the bound is where it is. Contracts win over this page; src/IvyVault.sol and src/IvyVaultFactory.sol are the truth.

Multisig powers — what it can and cannot do alone

PowerCallWhere funds can go · bound
What the Ivy multisig CAN do alone unilateral
Return principal fulfillWithdraw(user, amount) Asset → any depositor, any amount ≤ shares[user]. No prior requestWithdraw is required and the request is never validated against it. Shares are debited before the transfer.
Refund an escrow rejectExercise(id) Full paymentAmountp.proposer, and nowhere else. The multisig picks the id, not the recipient.
Sweep surplus to itself skim(token) multisig, but only bal − (token == asset ? totalShares : 0) − totalOwed[token] − escrowed[token]. bal ≤ locked reverts NothingToSkim.
Whitelist settlement tokens factory.setPremiumToken(t, ok) Factory owner — a different key from any vault's multisig. Read live by every vault in depositPremium and proposeExercise. Moves no funds.
Rotate the Ivy Safe factory.setMultisig(m) Factory owner. Applies to future vaults only; every deployed vault keeps its immutable multisig.
What it CANNOT do alone bounded by construction
Take principal — no such function There is no settle() and no multisig withdrawal path. Asset reaches a non-depositor only via proposeExercise (MM signs and escrows real payment) then approveExercise — two parties. A compromised Safe key on its own cannot move underlying.
Skim depositor money skim(token) Cannot touch principal, owed premium or a live escrow: locked subtracts totalShares + totalOwed[token] + escrowed[token] before anything moves, whatever a rebasing token does to bal.
Repoint a live vault — no such function multisig, factory and multiDeposit are immutable; asset is pinned by the first deposit and has no setter. A vault's trust surface is fixed and readable from its deployment transaction.
Block premium claims — no such function claim / claimAll are permissionless depositor calls with no multisig gate and no pause. De-whitelisting a token stops new premium and new proposals; already-credited premium stays claimable forever.

The liveness assumption, stated bluntly

Depositors fully trust Ivy for liveness. If the multisig stops signing, principal and open escrows are stuck in the vault — permanently, by design. No timeout, no emergency exit, no permissionless redeem. That is the curated-LP model, and it is disclosed before anyone deposits.

Invariants — what an indexer or an auditor should assert

// INV-1 — asset solvency (holds at every external call boundary) balanceOf(asset) ≥ totalShares + totalOwed[asset] + escrowed[asset] reducible only by: fulfillWithdraw (debits the shares first) approveExercise (assetOut, after the escrow closes) skim (strictly the excess above the line above) // INV-2 — per premium token t balanceOf(t) ≥ totalOwed[t] + escrowed[t] // INV-3 — premium is never over-promised (pendingPremium = claimable + un-banked accrual) Σu pendingPremium(u, t) ≤ totalOwed[t] // gap = floored dust, permanently locked // INV-4 — skim can never reduce a protected bucket locked = (t == asset ? totalShares : 0) + totalOwed[t] + escrowed[t] bal ≤ locked ⇒ revert NothingToSkim // totalShares, totalOwed[t], escrowed[t] unchanged by skim // INV-5 — a proposal transitions open → closed exactly once proposals[id].open : true → false, one time, by approveExercise XOR rejectExercise re-settling ⇒ revert ProposalClosed // double-settlement unreachable

Accepted behaviours — the known-issues list

Disclosed for the Sherlock contest · 8 items · all intended

Accumulator dust floors down and stays locked — wei-scale, permanently

acceptedKI-01
Why accepted
Both accPerShare[t] += received * 1e18 / totalShares and the bank division floor, so the sum of credits is ≤ the amount received. The remainder is not claimable by anyone and it is not skimmable either — it sits inside totalOwed[t], which skim subtracts. Alternatives are worse: handing the remainder to a chosen depositor is arbitrary and gameable, carrying it costs a storage slot per token per credit. Magnitude is bounded by roughly 1 wei per credit event, and the fuzz test asserts the gap stays under 1e6 raw units across arbitrary interleavings.

Fee-on-transfer assets: fulfillWithdraw debits gross shares, pays net

acceptedKI-02
Why accepted
fulfillWithdraw(user, amount) debits amount shares and calls safeTransfer(user, amount); a fee-on-transfer asset delivers less than amount. Deposits already credit the balance delta, so shares are credited net — mirroring that on the way out means a second balance read plus a second share credit, i.e. letting the token dictate the ledger. The multisig chooses the amount anyway and can size it against the fee. FoT vaults are curated and the haircut is the depositor's, disclosed.

Rebasing tokens are supported operationally, not accounted for

acceptedKI-03
Why accepted
Shares are credited 1:1 with the amount received and never rebase. A negative rebase silently reduces per-share backing; a positive rebase creates surplus above locked that skim routes to the multisig. Both are intended: shares are advisory accounting for a manual desk, and the multisig prices every outflow at fulfillWithdraw time. A depositor's on-chain claim is totalShares, not the balance — an LP funding a rebasing asset must price that in.

Escrow has no MM exit — no cancel, no expiry

acceptedKI-04
Why accepted
Once proposeExercise escrows the payment, only approveExercise or rejectExercise can release it; an unresponsive multisig locks it indefinitely. This is anti-race, not oversight: a proposer-side cancel lets an MM watch the Safe queue and pull the escrow ahead of an in-flight approval, leaving the approval to revert or to settle against a moved balance. Removing the exit removes the race. Turnaround is an off-chain SLA in the MM terms, and MMs are counterparties Ivy already deals with directly.

Nothing about strike, expiry or spot is on-chain

acceptedKI-05
Why accepted
The contract cannot distinguish a fair exercise from a bad one: approveExercise releases assetOut against whatever paymentAmount was escrowed. That is the design — the escrowed terms are the price and the multisig's approval is the price check. There is no oracle, no strike, no expiry, no automatic redemption to attack. A mispriced approval is a trusted-party failure, in scope for the trust model and out of scope for the contracts.

The premiumTokens loop is bounded by the factory whitelist

acceptedKI-06
Why accepted
_bank, _resetDebt and claimAll iterate premiumTokens, which grows by one on the first credit of each new token. Only tokens passing factory.isPremiumToken can ever be credited and the whitelist is onlyOwner on the factory, so no user can grow the array — the bound is the size of the Ivy-curated stable list. That gas bound is precisely why the whitelist lives on the factory rather than per vault.

A blacklisting token (USDT-style freeze) can brick any vault holding it

acceptedKI-07
Why accepted
If a token freezes the vault address, every path that moves it fails: claims, fulfills, escrow refunds. No contract-level fix exists — an escape hatch that could route around a frozen balance would be a strictly larger power than this trust model grants, and it would not move a frozen token anyway. Mitigation is token selection and per-chain curation (CHAINS.md), and the risk is disclosed in README and PLAN.md.

Multisig inaction = stuck funds

acceptedKI-08
Why accepted
fulfillWithdraw, approveExercise and rejectExercise are onlyMultisig. If the Safe stops signing, principal and open escrows stay in the vault permanently — there is no timeout and no permissionless redeem. Already-credited premium is the exception and survives: claim / claimAll need no multisig. This is the trust model itself, not a bug in it; the semi-trustless fix is sketched as v2 below and is not built.

Case study — caught pre-launch by the fuzzer

A naive rewardDebt made Σclaimable exceed totalOwed — wei-scale insolvency plus a claim DoS

The textbook MasterChef form stores rewardDebt already divided. Every share change therefore drops up to 1 wei of a user's baseline, and the user re-earns those wei from the same accumulator. Small, and fatal at the last claim: _claim decrements totalOwed[t], and once the sum of claimable rose above it the final claimer's subtraction underflowed.

// NAIVE — floors on every share change, the dropped wei is re-earned rewardDebt[u][t] = (shares[u] * acc) / 1e18 pending(u) = (shares[u] * acc) / 1e18 − rewardDebt[u][t] ⇒ Σ claimable can exceed totalOwed[t] ⇒ last claimer: totalOwed[t] −= amt underflows → panic 0x11 ⇒ wei-scale insolvency + permanent claim DoS for the last depositor // FIXED — the product is stored exact and unscaled; one floor, at banking rewardDebt[u][t] = shares[u] * acc // never divided pending(u) = (shares[u] * acc − rewardDebt[u][t]) / 1e18 // the single floor ⇒ re-baselining is lossless, sub-wei remainders are forfeited as locked dust ⇒ Σ claimable ≤ totalOwed[t] by construction — the subtraction cannot underflow // verification 35 / 35 tests green · full-regime fuzz (deposit / premium / mid-flight claim / late deposit) green

Engineering guardrails

nonReentrant on every token-moving function CEI — state written before the transfer SafeERC20 on every transfer custom errors, no revert strings no upgradeability · no proxy · no delegatecall immutable multisig / factory / multiDeposit asset pinned by the first deposit, no setter Ownable2Step for the LP role

Roadmap

v2 — semi-trustless sketch · not built

  • Terms committed on-chain, per cycle: strike, expiry, premium and the MM address, written before the cycle opens.
  • Outflows need 2-of-3 among {LP, MM, Ivy} instead of one Ivy signature.
  • Exercise = MM + Ivy, with the payment verified on-chain against the committed terms rather than by a human reading a Safe transaction.
  • Principal after expiry = LP + Ivy; dispute fallback = any 2 of the three.
  • The trade: it removes unilateral-Ivy liveness trust — KI-08 stops being an accepted risk — at the cost of putting terms on-chain and requiring a second signature per action. Revisit after v1 ships.
Sherlock audit contest · planned before real funds Scope: 2 contracts · IvyVault.sol + IvyVaultFactory.sol ~440 source lines incl. NatSpec This page is the canonical known-issues list
solc 0.8.26 evm_version = paris · required on Sei (no PUSH0), harmless everywhere else OpenZeppelin v5.7 · SafeERC20 · Ownable2Step · ReentrancyGuard 24 chains green for day 1 (CHAINS.md) CREATE2 same-address via deterministic-deployer proxy Sherlock audit contest planned before real funds MIT

Sources of truth: src/IvyVault.sol, src/IvyVaultFactory.sol, PLAN.md, CHAINS.md. This document is an explainer, not a spec — where it disagrees with the contracts, the contracts win.