Add OptionsVault: pooled covered-call writing vault - #53
Conversation
Pooled covered-call writing vault — the Bitcoin-native analog of a Rysk
Premium / Thetavault, where covered_call.ark is a single bilateral option.
- LP shares as a fungible Arkade Asset: minted on deposit, burned on
withdraw (the burn is the authentication), monotone price-per-share.
Pattern A from examples/vault_lending/vault_covenant.ark.
- BTC-margined, cash-settled covered call via the Fuji oracle; premium
collected upfront accrues to LPs (PPS rises). One option per epoch
(write -> expiry -> settle -> write), the weekly-roll Thetavault cadence.
- Curator writes strike/expiry (non-custodial on the coop path); settle
is permissionless.
Exit: a custodial curator-gated `unilateral` tapscript placeholder
(older(exit) + checkSig). A pool has no sound single-key unilateral exit
— an LP claim is a share burn, which needs introspection unavailable in
tapscript — so passive LPs get no standing exit here. That gap is
TODO(PULSE): docs/recurrent-exit-pulse.md (`recurrent` exit, per-LP
pre-signed exit lattices).
Written against current master syntax: no options{} block, asset ids as
explicit (txid, gidx) params, new SingleSig(pk, exit), explicit tapscript
unilateral. Compiles to 5 groups; 8 integration tests in
tests/options_vault_test.rs; cargo fmt clean; full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FR3RUik4c6jbSLpc8CXp4
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Playground PreviewA live preview of this PR's playground is available at:
|
Gate the LP share mint on a control asset (lpCtrl), the bonds RepaymentPool pattern: the share asset group must be controlIs(lpCtrl), lpCtrl lives only in the vault and is retained on output[0] by every function, so the share supply can rise only inside a genuine deposit spend. withdraw burns via the group supply delta (no mint authority needed); writeOption/settle pin share + control supply unchanged. Also revise the example's comments: plainer Bitcoin-output/transaction wording, affirmative exit-model description, drop external protocol name-drops. lpAssetId + lpCtrl are each two explicit (txid, gidx) params. Compiles to 5 groups; 10 integration tests; cargo fmt clean; full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FR3RUik4c6jbSLpc8CXp4
Follow the emulator contract-identity pattern (arkade-os/emulator #54, e2e in #66): instead of a dedicated lpCtrl mint-control token, the vault carries a single contract-identity singleton (asset-backed contract ID, genesis-issued, non-reissuable) that does double duty — it identifies the genuine vault AND is the control asset of the LP share group. One asset, both jobs; no separate mint-control token. Rename lpCtrlId(Txid,Gidx) -> contractId(Txid,Gidx); mint asserts controlIs(contractId); the identity singleton is retained on output[0] by every function so mint authority never leaks. Behavior unchanged; compiles to 5 groups, 10 tests, fmt clean, full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FR3RUik4c6jbSLpc8CXp4
Register examples/options/options_vault.ark in the playground's Options folder so the pooled cash-settled covered-call writing vault is loadable in the WASM playground. contracts.js is auto-generated (walks examples/**/*.ark) and gitignored; only the main.js registry needs the entry. Description updated to cover the vault. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FR3RUik4c6jbSLpc8CXp4
tiero
left a comment
There was a problem hiding this comment.
Self-review (extra-high effort) of the OptionsVault example. 8 inline findings, ranked; the two blockers are the top of the list:
- 🔴 settle payoff
OP_MUL64overflow (line 292) — bricks settlement at 1-BTC size, funds lock. - 🟠 permissionless post-expiry settlement price (line 273), withdraw ignores live-option ITM liability (line 184), fairness cross-product overflow (line 128).
- 🟡 defense-in-depth / edge cases: genesis ignores orphaned collateral (132), dust ITM under-pays the buyer (306), vacuous fairness at
totalCollateral==0(130), missingsharesBurned<=totalShares(174).
Non-bug notes (not inlined):
- Test coverage:
tests/options_vault_test.rsis purely structural (opcode/witness presence); none of the numeric/economic behavior (payoff math, fairness, overflow boundary, ITM/OTM branches) is exercised, so a math regression would pass CI. - CLAUDE.md drift: the repo-root CLAUDE.md still says "ALL contracts: use
options { server = server; exit = exit; }", which no longer parses on current master — the doc is stale, this contract is correct. - Altitude: option terms (buyer/strike/expiry/covered) live in the pool covenant, forcing one live option per epoch; a per-leg child covenant (BondMint-style) would support a real book of concurrent options.
Note this is a reference/example contract, so some of these (settlement fixing, option-liability-aware NAV) may be acceptable documented simplifications rather than must-fix — worth a call before addressing.
Generated by Claude Code
| // ITM: buyer owed coveredSats × (spot − strike) / spot, capped by covered. | ||
| // FOLLOW-UP: coveredSats × (oraclePrice − strikePrice) overflows int64 for | ||
| // large pools; chunk settlement or rescale the price unit. | ||
| int payoutSats = coveredSats * (oraclePrice - strikePrice) / oraclePrice; |
There was a problem hiding this comment.
🔴 int64 overflow in the ITM payoff → settlement bricks. coveredSats * (oraclePrice - strikePrice) compiles to OP_MUL64. For a 1-BTC covered call with an 8-decimal USD price, coveredSats=1e8 × (price diff ~5e11) = 5e19, past the int64 max (~9.22e18) → OP_MUL64 fails closed → settle can never execute, so the collateral and the option lock permanently. This triggers at single-BTC size, not just the "large pools" the FOLLOW-UP comment mentions (even a $1000 ITM move on 1 BTC = 1e19 overflows).
Fix: rescale the price unit (e.g. whole USD instead of ×1e8), or divide before multiplying with controlled precision loss. (Confirmed OP_MUL64 in the emitted settle ASM; the bignum switch in PR #51 is an unmerged draft.)
Generated by Claude Code
| // ------------------------------------------------------------------------- | ||
| function settle(int oraclePrice, int oracleTime, signature oracleSig) { | ||
| require(coveredSats > 0, "no live option"); | ||
| require(tx.time >= expiryHeight, "before expiry"); |
There was a problem hiding this comment.
🟠 Settlement is permissionless, unbounded in time, and prices off a post-expiry spot. settle only requires tx.time >= expiryHeight and oracleAge <= 600; nothing binds the price to the expiry-block fixing. Whichever party benefits picks the moment: a buyer waits for a favourable post-expiry spike (option expires OTM at $99k, later settles ITM at $110k), while the curator settles instantly at a low price. Consider pinning the settlement price to the expiry block, or a bounded settlement window.
Generated by Claude Code
| // FOLLOW-UP: the cross-product overflows int64 for very large pools; chunk. | ||
| int redeemLhs = payoutSats * totalShares; | ||
| int redeemRhs = sharesBurned * totalCollateral; | ||
| require(redeemLhs <= redeemRhs, "over-redeemed"); |
There was a problem hiding this comment.
🟠 withdraw redeems at gross NAV, ignoring the live option's ITM liability. The check uses raw totalCollateral, so an LP can burn shares and exit at full value right before an ITM settlement, dumping the option loss on the remaining holders. Example: 200M collateral / 200M shares, live call coveredSats=100M deep ITM (settle pays ~50M to the buyer). An LP withdraws 100M sats (passes newCollateral=100M >= coveredSats), then settle leaves 50M for the other 100M shares → value/share halves. The redemption NAV should net out the outstanding option liability.
Generated by Claude Code
| // Bootstrap an empty pool at 1 share per sat. | ||
| // FOLLOW-UP: the cross-product overflows int64 for very large pools; chunk. | ||
| int mintLhs = sharesIssued * totalCollateral; | ||
| int mintRhs = depositSats * totalShares; |
There was a problem hiding this comment.
🟠 Fairness cross-products overflow OP_MUL64 for large pools → deposit/withdraw DoS. sharesIssued * totalCollateral here (and payoutSats * totalShares in withdraw) overflow int64 past a few tens of BTC of pool value (totalCollateral≈1e10, sharesIssued≈1e9 → 1e19 > 9.22e18), so the calls revert — the liquidity path breaks, not just settlement. Same rescale / divide-first fix as the settle payoff.
Generated by Claude Code
| if (totalShares > 0) { | ||
| require(mintLhs <= mintRhs, "over-issued shares"); | ||
| } else { | ||
| require(sharesIssued == depositSats, "genesis must be 1:1"); |
There was a problem hiding this comment.
🟡 Genesis mint ignores orphaned collateral. This branch mints 1:1 without requiring totalCollateral == 0. If a full exit leaves residual sats (totalShares==0 but totalCollateral>0 — reachable when the last withdrawer takes less than all collateral, or via rounding dust), the next depositor enters genesis and captures the residual for free: (R + depositSats) sats over depositSats shares → value/share > 1. Add require(totalCollateral == 0) here (or fold the residual into the first mint).
Generated by Claude Code
| require(tx.outputs[0].value >= newCollateral, "pool not preserved"); | ||
| require(tx.outputs[0].assets.lookup(contractIdTxid, contractIdGidx) >= 1, "identity not retained"); | ||
|
|
||
| if (payoutSats > 330) { |
There was a problem hiding this comment.
🟡 Dust ITM payout leaves the buyer unpaid. When payoutSats <= 330 the buyer output is skipped, but newCollateral = totalCollateral - payoutSats still subtracts it. An in-the-money buyer (e.g. owed 300 sats) receives nothing while the pool loses the amount. Unlike a dust residual returning to its own owner, here the counterparty is under-paid — consider rounding the buyer's dust into the pool (no subtraction) instead.
Generated by Claude Code
| int mintLhs = sharesIssued * totalCollateral; | ||
| int mintRhs = depositSats * totalShares; | ||
| if (totalShares > 0) { | ||
| require(mintLhs <= mintRhs, "over-issued shares"); |
There was a problem hiding this comment.
🟡 Fairness check is vacuous when totalCollateral == 0. mintLhs = sharesIssued * 0 = 0 <= mintRhs always holds, so if the pool ever reached totalCollateral==0 with shares outstanding, a depositor could mint unbounded shares for 1 sat. Looks unreachable today via withdraw's coupling, but nothing asserts it — add require(totalCollateral > 0) in this non-genesis branch as defense in depth.
Generated by Claude Code
| // output[0]: vault re-created (totalCollateral - payoutSats, -sharesBurned) | ||
| // output[1]: payoutSats → LP | ||
| // ------------------------------------------------------------------------- | ||
| function withdraw(int sharesBurned, int payoutSats) { |
There was a problem hiding this comment.
🟡 No explicit sharesBurned <= totalShares bound. newShares = totalShares - sharesBurned relies entirely on asset conservation to avoid a negative recreated share count. A cheap require(sharesBurned <= totalShares) removes the dependence on that external invariant and fails closed if share accounting is ever bypassed.
Generated by Claude Code
…alized) The quant's two-token covered-call design, and the cleaner canonical form vs options_vault.ark. One vault per (oracle/strike/maturity/type). - issue: deposit sats -> mint 1:1:1 LONG + SHORT shares (identity-gated, paired). Long/short supply stay equal, so the pool is always exactly collateralized; the premium is the market price of the two legs, so deposit/burn cannot be mispriced (removes options_vault's NAV/PPS gap). - burnPair: return a matched LONG+SHORT pair for BTC (pre-maturity). - settle: once, oracle price attested within +/-60s of maturity; splits the pot longPot = totalBTC*(spot-strike)/spot, shortPot = totalBTC-longPot (subtraction => exact conservation). Price in WHOLE USD to keep the OP_MUL64 product inside int64 (the overflow found in options_vault). - redeemLong/redeemShort: drain each side's pot pro-rata; numerator and denominator drain together so the rate is order-invariant (bonds redeem shape) - resolves the quant's redemption-denominator question without a settlement snapshot or burn-to-unspendable. Exit: no compiler leaf. The short-side unilateral exit is the PULSE per-holder lattice (docs/recurrent-exit-pulse.md), SDK/ceremony surface, like the bonds pool. Long/call-buyers bear operator-liveness risk (noted). Compiles to 5 cooperative groups; 8 integration tests; playground wired; cargo fmt clean; full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FR3RUik4c6jbSLpc8CXp4
Summary
Adds a complete example of a pooled, cash-settled covered-call writing vault (
OptionsVault) — the Bitcoin-native analog of Rysk Premium / Ribbon-style Thetavaults. This demonstrates Arkade's pooling pattern with fungible LP shares as an Arkade Asset, oracle-driven settlement, and curator-gated option writing.Key Changes
New Contract:
examples/options/options_vault.arkdeposit: Permissionless LP deposit; mints shares at fair PPS (no over-issuance).withdraw: LP burns shares to redeem at NAV; bounded by idle collateral (cannot withdraw under a live option).writeOption: Curator-gated; writes one covered call, locks collateral, collects premium.settle: Permissionless oracle-driven settlement; pays buyer intrinsic value (ITM) or returns all collateral (OTM).unilateral: CSV tapscript placeholder (custodial curator-gated exit; passive-LP recurrent exit deferred to PULSE).New Test Suite:
tests/options_vault_test.rslpAssetIdTxid,lpAssetIdGidx), not a single raw bytes32.Notable Implementation Details
totalCollateral / totalSharescarried in state; premium lifts numerator only, so PPS rises monotonically.stability_vault.arkconvention.docs/recurrent-exit-pulse.md).Documentation
Extensive inline comments explain the pooling pattern, instrument mechanics, roles, exit model, and simplifications. The contract serves as a reference for Arkade's ERC-4626-style pooling and oracle-driven settlement patterns.
https://claude.ai/code/session_011FR3RUik4c6jbSLpc8CXp4