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.
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 productshares × 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).
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.
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.
Shares are credited 1:1 with the asset actually received, so totalSharesis 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.
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.
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.
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.
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
LPdepositorYou own the vault, you fund it, you close the gate before bids go live.
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.
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.
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.
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.
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.
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.
market makeranyonePermissionless to call. The whitelist constrains the token, not the caller.
depositPremium(address token, uint256 amount)
Pays for the option. token must be factory-whitelisted (isPremiumToken, else
NotPremiumToken) and the vault must already hold deposits (totalShares > 0, else
NoShares). Credited pro-rata to depositors instantly through the accumulator; the credited amount
is the balance delta, so fee-on-transfer tokens credit net.
Step 1 of settling in the money. Your payment is escrowed immediately — it leaves your wallet on this
call, before anyone at Ivy has looked at it.
No cancel, no expiry
There is NO proposer-side cancel and NO proposal expiry. The ONLY exits are
approveExercise by the multisig — you receive assetOut of the vault asset — or
rejectExercise — full refund of the escrow. Until one of those lands, the escrow is
unreachable by you, by depositors and by skim. Agree a settlement SLA with Ivy off-chain
before you escrow.
Wrong terms
Ask Ivy to rejectExercise(id) for a full refund, then re-propose with corrected terms. There is
no amend path — a proposal is settled once and ProposalClosed guards the rest.
Ivy multisigOps runbook. Deploy order, weekly cadence, and the two calls that move money.
Per-chain bring-up — order matters
Deploy the Gnosis Safe FIRST. Ivy's Safes do not exist on any chain yet.
Deploy IvyVaultFactory with that Safe address as multisig. Vaults snapshot it
at construction, so a wrong address means redeploying vaults, not fixing one setter.
setPremiumToken(token, true) for each accepted stable on that chain.
On brand-new chains, verify the CREATE2 deterministic-deployer proxy exists (or deploy it) before
anything else — same-address deployment depends on it. See CHAINS.md.
Weekly cadence
Bids close (e.g. Wednesday) → options sold off-chain → settle on-chain → process withdrawals. The contract
has no notion of a week; the clock is entirely yours.
fulfillWithdraw(address user, uint256 amount)
You choose the amount, not the requester: a request of 100 can be fulfilled with 30 when 70 is committed.
No prior request is required at all — this is how principal goes back when a cycle found no buyer.
Critical — premium token == vault asset
When premium is paid in the vault's own asset (USDC premium into a USDC vault), one balance holds
principal AND unclaimed premium AND live escrows. Size every fulfill against the free part only:
Overshoot and a later claim reverts on a balance that is no longer
there. The contract will not stop you — it only checks amount ≤ shares[user].
approveExercise(uint256 id) — checklist
Do the proposal's paymentToken, paymentAmount and assetOut match
the bid agreed off-chain? The escrowed terms are the price; your approval is the price check.
Is totalShares > 0? If the vault is empty you MUST reject — approval reverts
NoShares because there is nobody to credit.
Approve. Payment auto-distributes pro-rata through the premium accumulator; assetOut goes to
the proposer. Shares are NOT reduced — price the haircut into later fulfills.
rejectExercise(uint256 id)
For wrong terms, stale proposals, or an emptied vault. Full refund to the proposer; asset, shares and
accumulator untouched. This is the only refund path in the system.
skim(address token)
Sweeps only the excess over principal + owed premium + live escrows — positive rebases, stray transfers,
airdrops, rounding dust — to the multisig. Reverts NothingToSkim when there is no excess. It
cannot reach depositor money by construction.
Honor the escrow SLA
Proposals have no MM exit by design, so an unanswered proposal is the MM's capital frozen indefinitely.
Approve or reject inside the agreed window, every time.
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.
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.
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).
Multisig address is address(0). Unreachable in a vault built through the factory, which never stores a zero multisig.
ZeroAmount()
deposit, depositPremium, proposeExercise
The 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()
deposit
depositsOpen == false; the LP shut the gate so live quotes cannot be diluted. Claims, premium, exercises and withdrawals keep working.
NotLP()
deposit
Covered-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).
Caller is not the owner (vault: the LP; factory: the Ivy deployer), or is not pendingOwner() on accept.
OwnableInvalidOwner(address owner)
both constructors; factory transferOwnership
Owner 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 function
A token callback (ERC-777 / ERC-1363 hook) re-entered the vault.
SafeERC20FailedOperation(address token)
vault, every transfer path
The token reverted without data or returned false — includes USDT-style blacklist freezes.
Array index out of bounds — a solc panic, not a custom error. Bound id with proposalsLength() first.
Events
Event
Fields (★ = indexed)
When
Indexer note
IvyVaultFactory
VaultCreated
★ vault · ★ lp · multiDeposit · vaultIntent
createVault
Registry discovery — the log stream equivalent of allVaults(). multiDeposit tags the vault type: false ⇒ covered call, true ⇒ cash-secured put.
MultisigSet
multisig (not indexed)
constructor + setMultisig
Future vaults only. Not indexed — filter client-side. Deployed vaults keep their immutable, so never back-apply this to existing vaults.
PremiumTokenSet
★ token · allowed
setPremiumToken
Latest-wins per token; fires even for a no-op write. Vaults read the mapping live, so the newest log is the truth.
IvyVault
AssetSet
★ asset
first-ever deposit
Exactly one per vault, forever; precedes the Deposited in the same tx. Until it lands, asset() is address(0).
Deposited
★ user · amount · intent
deposit
amount = net received, not sent — shares credited equal it 1:1. Never reconstruct balances from the caller's transfer amount.
DepositsToggled
open
setDepositsOpen
Fires even when the value is unchanged — dedupe on state, not on the event.
IntentSet
★ user · intent
setIntent, or deposit with a non-empty intent
Latest-wins per user; emitted before Deposited when inline. Empty string on deposit leaves the old intent and emits nothing.
WithdrawRequested
★ user · amount
requestWithdraw
Advisory queue signal — moves nothing and is not validated against shares. 0 cancels; last write wins.
WithdrawFulfilled
★ user · amount
fulfillWithdraw
Also decrements the open request, saturating at 0 — so pair it with WithdrawRequested to keep a queue honest. amount = shares debited = asset units sent.
PremiumDeposited
★ from · ★ token · amount
depositPremium
amount = net received and credited to accPerShare. from is whoever paid; the function is permissionless.
Claimed
★ user · ★ token · amount
claim / claimAll, banked > 0
Skipped entirely when nothing is banked, so claimAll emits 0…N of these. Σamount per token is the paid-out total.
On 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.
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
Power
Call
Where 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 paymentAmount → p.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) thenapproveExercise —
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 tbalanceOf(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 functionCEI — state written before the transferSafeERC20 on every transfercustom errors, no revert stringsno upgradeability · no proxy · no delegatecallimmutable multisig / factory / multiDepositasset pinned by the first deposit, no setterOwnable2Step 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 fundsScope: 2 contracts · IvyVault.sol + IvyVaultFactory.sol~440 source lines incl. NatSpecThis page is the canonical known-issues list