From 547eb2251ccdcd85fdd5d4c18fb0be112b5613d0 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Sat, 3 Oct 2026 16:28:00 +0000 Subject: [PATCH] Options: store the underlying and strike amounts, not contracts An option account stored `contracts`, `underlying_per_contract` and `strike_per_contract`, and every handler multiplied them back together. An `OptionContract` holding `contracts` also read as a contract made of contracts. The account now stores the two amounts that change hands: - `underlying_amount`: what a call writer posts and a call holder receives, or a put holder delivers. - `strike_amount`: what a put writer posts and a put holder receives, or a call holder pays. Settlement moves those amounts as they are, so `collateral_amount` and `exercise_payment` no longer compute anything or fail, and `underlying_total` and `strike_total` are gone. `write_option` refuses a zero underlying amount, strike amount or premium; with no multiplication left, an option's terms can no longer overflow, so the write-time overflow test is deleted. The walkthrough's numbers are unchanged: the call is 5 NVDAx for 900 USDC and the put 5 NVDAx for 750 USDC. Applied to the Anchor v2, Anchor v1 and Quasar builds, with their READMEs and changelogs. Claude-Session: https://claude.ai/code/session_01F5YFyiob21cT19ZMSBHwYY --- finance/options/anchor-v1/CHANGELOG.md | 10 ++ finance/options/anchor-v1/README.md | 47 ++++---- .../programs/options/src/contract_math.rs | 41 ++----- .../options/src/instructions/cancel_option.rs | 8 +- .../src/instructions/collect_proceeds.rs | 9 +- .../src/instructions/exercise_option.rs | 31 +++--- .../src/instructions/reclaim_collateral.rs | 9 +- .../options/src/instructions/write_option.rs | 45 +++----- .../options/src/state/option_contract.rs | 29 ++--- .../programs/options/tests/test_options.rs | 83 ++++++-------- finance/options/anchor/CHANGELOG.md | 12 ++ finance/options/anchor/README.md | 45 ++++---- .../programs/options/src/contract_math.rs | 44 ++------ .../options/src/instructions/cancel_option.rs | 8 +- .../src/instructions/collect_proceeds.rs | 9 +- .../src/instructions/exercise_option.rs | 31 +++--- .../src/instructions/reclaim_collateral.rs | 9 +- .../options/src/instructions/write_option.rs | 44 +++----- .../options/src/state/option_contract.rs | 29 ++--- .../programs/options/tests/test_options.rs | 81 ++++++-------- finance/options/quasar/CHANGELOG.md | 11 ++ finance/options/quasar/README.md | 4 +- .../quasar/src/instructions/cancel_option.rs | 7 +- .../src/instructions/collect_proceeds.rs | 7 +- .../src/instructions/exercise_option.rs | 23 ++-- .../src/instructions/reclaim_collateral.rs | 7 +- .../options/quasar/src/instructions/shared.rs | 37 ++----- .../quasar/src/instructions/write_option.rs | 32 ++---- finance/options/quasar/src/lib.rs | 10 +- finance/options/quasar/src/state.rs | 15 ++- finance/options/quasar/src/tests.rs | 103 +++++++----------- 31 files changed, 359 insertions(+), 521 deletions(-) diff --git a/finance/options/anchor-v1/CHANGELOG.md b/finance/options/anchor-v1/CHANGELOG.md index 914e1673..34043e2c 100644 --- a/finance/options/anchor-v1/CHANGELOG.md +++ b/finance/options/anchor-v1/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2026-10-03 + +The option now stores `underlying_amount` and `strike_amount`, the two amounts +that change hands on exercise, instead of `contracts`, `underlying_per_contract` +and `strike_per_contract`, which the program only ever multiplied together. +`OptionTerms` changes the same way. Settlement does no arithmetic: the +collateral, the exercise payment and the proceeds are each one of the stored +amounts. With no multiplication left there is nothing to overflow at write +time, so the write-time overflow test is gone. + ## 2026-09-30 Rename the market's `underlying_locked` and `quote_locked` to `underlying_owed` diff --git a/finance/options/anchor-v1/README.md b/finance/options/anchor-v1/README.md index 566c5837..efb9015a 100644 --- a/finance/options/anchor-v1/README.md +++ b/finance/options/anchor-v1/README.md @@ -47,11 +47,11 @@ call) or buy it at the strike (a put) if the holder asks. ### Covered and cash-secured: the collateral is the whole obligation A writer's obligation is bounded and known at write time, so this venue simply -takes all of it into custody. A call writer posts `contracts * -underlying_per_contract` of the underlying: the call is **covered**, and the -writer cannot fail to deliver because the shares are already in the vault. A -put writer posts `contracts * strike_per_contract` of the quote token: the put -is **cash-secured**, and the writer cannot fail to pay. Nothing is ever +takes all of it into custody. A call writer posts the option's +`underlying_amount` of the underlying: the call is **covered**, and the writer +cannot fail to deliver because the shares are already in the vault. A put +writer posts the option's `strike_amount` of the quote token: the put is +**cash-secured**, and the writer cannot fail to pay. Nothing is ever undercollateralized, which is why the program has no health check, no liquidation, and no need to know the price. @@ -66,14 +66,15 @@ program enforces the terms and nothing else. A cash-settled venue, which pays the holder the difference between the market price and the strike, would need a price feed and every check the *Offchain Truth* material describes. -### Every amount is a product of two integers +### The option stores the amounts that change hands -An option is defined by `contracts`, `underlying_per_contract` and -`strike_per_contract`, all minor-unit integers the writer chooses. The -collateral, the exercise payment and the proceeds are each one checked -multiplication of two of them. There is no division anywhere in settlement, so -there is no rounding to decide a direction for; the only rounding in the -program is the floor in the venue's fee. +An option is defined by `underlying_amount` and `strike_amount`, the two +minor-unit amounts that change hands on exercise, both chosen by the writer. +`strike_amount` is the strike for the whole option, as an amount rather than a +price. The collateral, the exercise payment and the proceeds are each one of +those two amounts, so settlement does no arithmetic at all: nothing is +multiplied, divided or rounded. The only rounding in the program is the floor +in the venue's fee. ### Expiry is one comparison and its complement @@ -108,11 +109,11 @@ both vaults: it owns them and signs every transfer out of them with its own seeds. Maria's key is recorded as `admin`: it can sweep fees and do nothing else. -### Step 2: Alice writes 5 covered calls +### Step 2: Alice writes a covered call on 5 NVDAx -`write_option(id = 1, kind = Call, contracts = 5, underlying_per_contract = -1 NVDAx, strike_per_contract = 180 USDC, premium = 25 USDC, expiry = a week -out)` moves her 5 NVDAx into the underlying vault and creates the +`write_option(id = 1, kind = Call, underlying_amount = 5 NVDAx, strike_amount += 900 USDC, premium = 25 USDC, expiry = a week out)`, a strike of 180 USDC a +share, moves her 5 NVDAx into the underlying vault and creates the `OptionContract` account (a PDA of the market, Alice, and her `id`) with status `Listed`. Nobody has paid anything yet; Alice can `cancel_option` at any time until someone does. @@ -126,9 +127,9 @@ is fixed at the 25 USDC he just paid. ### Step 4: NVIDIA rallies to $200 and Bob exercises -`exercise_option`, called by Bob before expiry, moves 5 × 180 = 900 USDC from -Bob into the quote vault and 5 NVDAx from the underlying vault to Bob. He now -holds 5 NVDAx worth about $1,000, having spent 925 USDC in total. The status is +`exercise_option`, called by Bob before expiry, moves the 900 USDC strike +amount from Bob into the quote vault and 5 NVDAx from the underlying vault to +Bob. He now holds 5 NVDAx worth about $1,000, having spent 925 USDC in total. The status is `Exercised`, and the 900 USDC sits in the vault owed to Alice. ### Step 5: Alice collects the strike @@ -137,11 +138,11 @@ holds 5 NVDAx worth about $1,000, having spent 925 USDC in total. The status is back to her. She sold her 5 NVDAx for 900 USDC plus the 24.75 USDC premium she already had, and gave up everything above $180. -### Step 6: Carol writes 5 cash-secured puts, and Dave buys them +### Step 6: Carol writes a cash-secured put on 5 NVDAx, and Dave buys it -Carol's `write_option(id = 2, kind = Put, contracts = 5, underlying_per_contract -= 1 NVDAx, strike_per_contract = 150 USDC, premium = 20 USDC)` moves 5 × 150 = -750 USDC into the quote vault. Dave's `buy_option` pays 19.80 USDC to Carol +Carol's `write_option(id = 2, kind = Put, underlying_amount = 5 NVDAx, +strike_amount = 750 USDC, premium = 20 USDC)`, a strike of 150 USDC a share, +moves 750 USDC into the quote vault. Dave's `buy_option` pays 19.80 USDC to Carol and 0.20 USDC to the vault for Maria. ### Step 7: The week passes above $150, and Carol reclaims her collateral diff --git a/finance/options/anchor-v1/programs/options/src/contract_math.rs b/finance/options/anchor-v1/programs/options/src/contract_math.rs index aa56a958..4a6708a1 100644 --- a/finance/options/anchor-v1/programs/options/src/contract_math.rs +++ b/finance/options/anchor-v1/programs/options/src/contract_math.rs @@ -2,54 +2,35 @@ //! can be unit-tested and model-checked (see `finance/options/kani-proofs`) //! without the Solana machinery. //! -//! There is no division anywhere: every settlement amount is the product of -//! two integers the writer chose, and the only rounding in the program is the -//! floor in the fee split. Every function returns `None` on the paths the -//! program maps to `OptionsError::MathOverflow`. +//! Settlement does no arithmetic at all: the option stores the two amounts +//! that change hands on exercise, so nothing is multiplied, divided or +//! rounded. The only rounding in the program is the floor in the fee split, +//! which returns `None` on the paths the program maps to +//! `OptionsError::MathOverflow`. use crate::state::OptionKind; /// Basis-point denominator, mirroring `constants::BASIS_POINTS_DENOMINATOR`. const BASIS_POINTS: u128 = 10_000; -/// The underlying side of an option: `contracts * underlying_per_contract`. -pub fn underlying_total(contracts: u64, underlying_per_contract: u64) -> Option { - contracts.checked_mul(underlying_per_contract) -} - -/// The quote side of an option: `contracts * strike_per_contract`. -pub fn strike_total(contracts: u64, strike_per_contract: u64) -> Option { - contracts.checked_mul(strike_per_contract) -} - /// What the writer posts, in the collateral token's minor units: the /// underlying for a call, the strike for a put. Whatever the holder is /// entitled to at exercise is sitting in the vault from the moment the option /// exists, which is what makes the option fully collateralized. -pub fn collateral_amount( - kind: OptionKind, - contracts: u64, - underlying_per_contract: u64, - strike_per_contract: u64, -) -> Option { +pub fn collateral_amount(kind: OptionKind, underlying_amount: u64, strike_amount: u64) -> u64 { match kind { - OptionKind::Call => underlying_total(contracts, underlying_per_contract), - OptionKind::Put => strike_total(contracts, strike_per_contract), + OptionKind::Call => underlying_amount, + OptionKind::Put => strike_amount, } } /// What the holder pays at exercise, and the writer later collects: the /// strike for a call, the underlying for a put. The mirror of /// `collateral_amount`, in the other token. -pub fn exercise_payment( - kind: OptionKind, - contracts: u64, - underlying_per_contract: u64, - strike_per_contract: u64, -) -> Option { +pub fn exercise_payment(kind: OptionKind, underlying_amount: u64, strike_amount: u64) -> u64 { match kind { - OptionKind::Call => strike_total(contracts, strike_per_contract), - OptionKind::Put => underlying_total(contracts, underlying_per_contract), + OptionKind::Call => strike_amount, + OptionKind::Put => underlying_amount, } } diff --git a/finance/options/anchor-v1/programs/options/src/instructions/cancel_option.rs b/finance/options/anchor-v1/programs/options/src/instructions/cancel_option.rs index d9ca081c..de9dd771 100644 --- a/finance/options/anchor-v1/programs/options/src/instructions/cancel_option.rs +++ b/finance/options/anchor-v1/programs/options/src/instructions/cancel_option.rs @@ -19,11 +19,9 @@ pub fn handle_cancel_option(context: Context) -> let collateral = contract_math::collateral_amount( option.kind, - option.contracts, - option.underlying_per_contract, - option.strike_per_contract, - ) - .ok_or(OptionsError::MathOverflow)?; + option.underlying_amount, + option.strike_amount, + ); let kind = option.kind; let market = &mut context.accounts.market; diff --git a/finance/options/anchor-v1/programs/options/src/instructions/collect_proceeds.rs b/finance/options/anchor-v1/programs/options/src/instructions/collect_proceeds.rs index afb28e6e..e56af951 100644 --- a/finance/options/anchor-v1/programs/options/src/instructions/collect_proceeds.rs +++ b/finance/options/anchor-v1/programs/options/src/instructions/collect_proceeds.rs @@ -21,13 +21,8 @@ pub fn handle_collect_proceeds(context: Context ); let kind = option.kind; - let underlying_total = - contract_math::underlying_total(option.contracts, option.underlying_per_contract) - .ok_or(OptionsError::MathOverflow)?; - let strike_total = contract_math::strike_total(option.contracts, option.strike_per_contract) - .ok_or(OptionsError::MathOverflow)?; + let underlying_amount = option.underlying_amount; + let strike_amount = option.strike_amount; // Effects: the option is exercised, and the vault now owes the writer the // payment instead of owing the holder the collateral. @@ -50,36 +47,36 @@ pub fn handle_exercise_option(context: Context OptionKind::Call => { market.underlying_owed = market .underlying_owed - .checked_sub(underlying_total) + .checked_sub(underlying_amount) .ok_or(OptionsError::MathOverflow)?; market.quote_owed = market .quote_owed - .checked_add(strike_total) + .checked_add(strike_amount) .ok_or(OptionsError::MathOverflow)?; ( underlying_before - .checked_sub(underlying_total) + .checked_sub(underlying_amount) .ok_or(OptionsError::CustodyInvariantViolated)?, quote_before - .checked_add(strike_total) + .checked_add(strike_amount) .ok_or(OptionsError::MathOverflow)?, ) } OptionKind::Put => { market.quote_owed = market .quote_owed - .checked_sub(strike_total) + .checked_sub(strike_amount) .ok_or(OptionsError::MathOverflow)?; market.underlying_owed = market .underlying_owed - .checked_add(underlying_total) + .checked_add(underlying_amount) .ok_or(OptionsError::MathOverflow)?; ( underlying_before - .checked_add(underlying_total) + .checked_add(underlying_amount) .ok_or(OptionsError::MathOverflow)?, quote_before - .checked_sub(strike_total) + .checked_sub(strike_amount) .ok_or(OptionsError::CustodyInvariantViolated)?, ) } @@ -95,7 +92,7 @@ pub fn handle_exercise_option(context: Context &context.accounts.quote_mint, &mut context.accounts.quote_vault, &context.accounts.holder, - strike_total, + strike_amount, )?; transfer_from_vault( &context.accounts.token_program, @@ -103,7 +100,7 @@ pub fn handle_exercise_option(context: Context &context.accounts.underlying_mint, &mut context.accounts.holder_underlying, market, - underlying_total, + underlying_amount, ) } OptionKind::Put => { @@ -113,7 +110,7 @@ pub fn handle_exercise_option(context: Context &context.accounts.underlying_mint, &mut context.accounts.underlying_vault, &context.accounts.holder, - underlying_total, + underlying_amount, )?; transfer_from_vault( &context.accounts.token_program, @@ -121,7 +118,7 @@ pub fn handle_exercise_option(context: Context &context.accounts.quote_mint, &mut context.accounts.holder_quote, market, - strike_total, + strike_amount, ) } } diff --git a/finance/options/anchor-v1/programs/options/src/instructions/reclaim_collateral.rs b/finance/options/anchor-v1/programs/options/src/instructions/reclaim_collateral.rs index e0976bd2..28b72e82 100644 --- a/finance/options/anchor-v1/programs/options/src/instructions/reclaim_collateral.rs +++ b/finance/options/anchor-v1/programs/options/src/instructions/reclaim_collateral.rs @@ -27,13 +27,8 @@ pub fn handle_reclaim_collateral( ); let kind = option.kind; - let collateral = contract_math::collateral_amount( - kind, - option.contracts, - option.underlying_per_contract, - option.strike_per_contract, - ) - .ok_or(OptionsError::MathOverflow)?; + let collateral = + contract_math::collateral_amount(kind, option.underlying_amount, option.strike_amount); let market = &mut context.accounts.market; let mut underlying_after = context.accounts.underlying_vault.amount; diff --git a/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs b/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs index 16630927..2416534b 100644 --- a/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs +++ b/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs @@ -16,15 +16,15 @@ use crate::state::{Market, OptionContract, OptionKind, OptionStatus}; pub struct OptionTerms { pub kind: OptionKind, - /// How many contracts the option holds. Bought and exercised as a whole. - pub contracts: u64, + /// Underlying minor units the option covers: what a call writer posts and + /// a call holder receives, or a put holder delivers (1 NVDAx = 1_000_000). + pub underlying_amount: u64, - /// Underlying minor units each contract is on (1 NVDAx = 1_000_000). - pub underlying_per_contract: u64, - - /// Quote minor units each contract settles at: the strike as an amount - /// per contract rather than a price, so exercise needs no decimals math. - pub strike_per_contract: u64, + /// Quote minor units paid for the underlying on exercise: what a put + /// writer posts and a put holder receives, or a call holder pays. The + /// strike for the whole option, as an amount rather than a price, so + /// exercise needs no decimals math. + pub strike_amount: u64, /// Quote minor units the buyer pays the writer for the whole option. pub premium: u64, @@ -44,17 +44,16 @@ pub fn handle_write_option( ) -> Result<()> { let OptionTerms { kind, - contracts, - underlying_per_contract, - strike_per_contract, + underlying_amount, + strike_amount, premium, expiry, } = terms; - // Every quantity is a multiplier in the settlement math, so a zero in any - // of them is an option that delivers nothing or costs nothing to exercise. A - // zero premium is a gift rather than a sale, and is refused as a mistake. + // A zero amount is an option that delivers nothing or costs nothing to + // exercise. A zero premium is a gift rather than a sale. Both are refused + // as mistakes. require!( - contracts > 0 && underlying_per_contract > 0 && strike_per_contract > 0 && premium > 0, + underlying_amount > 0 && strike_amount > 0 && premium > 0, OptionsError::InvalidParameter ); // Written in words: the holder may exercise while now < expiry. An expiry @@ -62,16 +61,7 @@ pub fn handle_write_option( let now = Clock::get()?.unix_timestamp; require!(expiry > now, OptionsError::ExpiryInPast); - // Both settlement amounts are computed here, at write time, so an option - // whose exercise would overflow is refused before anyone pays for it. - let underlying_total = contract_math::underlying_total(contracts, underlying_per_contract) - .ok_or(OptionsError::MathOverflow)?; - let strike_total = contract_math::strike_total(contracts, strike_per_contract) - .ok_or(OptionsError::MathOverflow)?; - let collateral = match kind { - OptionKind::Call => underlying_total, - OptionKind::Put => strike_total, - }; + let collateral = contract_math::collateral_amount(kind, underlying_amount, strike_amount); // Effects before the transfer: record the option and what the vault now owes. let option = &mut context.accounts.option; @@ -81,9 +71,8 @@ pub fn handle_write_option( option.holder = Pubkey::default(); option.kind = kind; option.status = OptionStatus::Listed; - option.contracts = contracts; - option.underlying_per_contract = underlying_per_contract; - option.strike_per_contract = strike_per_contract; + option.underlying_amount = underlying_amount; + option.strike_amount = strike_amount; option.premium = premium; option.expiry = expiry; option.bump = context.bumps.option; diff --git a/finance/options/anchor-v1/programs/options/src/state/option_contract.rs b/finance/options/anchor-v1/programs/options/src/state/option_contract.rs index 623210b8..0f01bb45 100644 --- a/finance/options/anchor-v1/programs/options/src/state/option_contract.rs +++ b/finance/options/anchor-v1/programs/options/src/state/option_contract.rs @@ -25,16 +25,11 @@ pub enum OptionStatus { Exercised, } -/// One option: `contracts` identical contracts, written by one -/// writer, held by at most one holder. One PDA per (market, writer, id). +/// One option, written by one writer, held by at most one holder. One PDA per +/// (market, writer, id). /// -/// Every amount the option ever moves is a product of two of its integers, -/// so settlement never divides and never rounds: -/// -/// - `contracts * underlying_per_contract` underlying minor units, which a -/// call writer posts and a call holder receives (or a put holder delivers). -/// - `contracts * strike_per_contract` quote minor units, which a put writer -/// posts and a put holder receives (or a call holder pays). +/// It stores the two amounts that change hands on exercise, so settlement +/// neither multiplies, divides, nor rounds. #[account] #[derive(InitSpace)] pub struct OptionContract { @@ -52,15 +47,15 @@ pub struct OptionContract { pub status: OptionStatus, - /// How many contracts the option holds. Bought and exercised as a whole. - pub contracts: u64, - - /// Underlying minor units each contract is on (1 NVDAx = 1_000_000). - pub underlying_per_contract: u64, + /// Underlying minor units the option covers: what a call writer posts and + /// a call holder receives, or a put holder delivers (1 NVDAx = 1_000_000). + pub underlying_amount: u64, - /// Quote minor units each contract settles at: the strike, per contract, - /// as an amount rather than a price, so exercise needs no decimals math. - pub strike_per_contract: u64, + /// Quote minor units paid for the underlying on exercise: what a put + /// writer posts and a put holder receives, or a call holder pays. The + /// strike for the whole option, as an amount rather than a price, so + /// exercise needs no decimals math. + pub strike_amount: u64, /// Quote minor units the buyer pays the writer for the whole option. pub premium: u64, diff --git a/finance/options/anchor-v1/programs/options/tests/test_options.rs b/finance/options/anchor-v1/programs/options/tests/test_options.rs index de3b6afe..e4cb5cf9 100644 --- a/finance/options/anchor-v1/programs/options/tests/test_options.rs +++ b/finance/options/anchor-v1/programs/options/tests/test_options.rs @@ -26,14 +26,15 @@ const DECIMALS: u8 = 6; // The venue charges 1% of every premium. const FEE_BPS: u16 = 100; -// The walkthrough's call: 5 contracts, each on 1 NVDAx, strike 180 USDC, -// asking 25 USDC for the option, expiring a week out. -const CONTRACTS: u64 = 5; -const ONE_NVDAX_PER_CONTRACT: u64 = ONE_TOKEN; -const CALL_STRIKE: u64 = 180 * ONE_TOKEN; +// The walkthrough's call: 5 NVDAx at a strike of 180 USDC each, 900 USDC in +// all, asking 25 USDC for the option, expiring a week out. +const CALL_UNDERLYING: u64 = 5 * ONE_TOKEN; +const CALL_STRIKE_AMOUNT: u64 = 900 * ONE_TOKEN; const CALL_PREMIUM: u64 = 25 * ONE_TOKEN; -// And the put: 5 contracts, strike 150 USDC, asking 20 USDC. -const PUT_STRIKE: u64 = 150 * ONE_TOKEN; +// And the put: 5 NVDAx at a strike of 150 USDC each, 750 USDC in all, asking +// 20 USDC. +const PUT_UNDERLYING: u64 = 5 * ONE_TOKEN; +const PUT_STRIKE_AMOUNT: u64 = 750 * ONE_TOKEN; const PUT_PREMIUM: u64 = 20 * ONE_TOKEN; const SECONDS_PER_DAY: i64 = 24 * 60 * 60; @@ -68,9 +69,8 @@ fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { fn call_terms(expiry: i64) -> OptionTerms { OptionTerms { kind: OptionKind::Call, - contracts: CONTRACTS, - underlying_per_contract: ONE_NVDAX_PER_CONTRACT, - strike_per_contract: CALL_STRIKE, + underlying_amount: CALL_UNDERLYING, + strike_amount: CALL_STRIKE_AMOUNT, premium: CALL_PREMIUM, expiry, } @@ -321,9 +321,8 @@ impl Venue { 2, OptionTerms { kind: OptionKind::Put, - contracts: CONTRACTS, - underlying_per_contract: ONE_NVDAX_PER_CONTRACT, - strike_per_contract: PUT_STRIKE, + underlying_amount: PUT_UNDERLYING, + strike_amount: PUT_STRIKE_AMOUNT, premium: PUT_PREMIUM, expiry, }, @@ -502,7 +501,7 @@ fn test_market_owns_both_vaults() { assert_eq!(venue.token_authority(&venue.quote_vault), venue.market); } -/// Alice writes 5 covered calls on her 5 NVDAx. The whole 5 NVDAx moves into +/// Alice writes a covered call on her 5 NVDAx. The whole 5 NVDAx moves into /// the vault at once; the option is listed for a 25 USDC premium. #[test] fn test_write_call_moves_underlying_into_vault() { @@ -518,8 +517,8 @@ fn test_write_call_moves_underlying_into_vault() { assert_eq!(state.holder, Pubkey::default()); assert_eq!(state.kind, OptionKind::Call); assert_eq!(state.status, OptionStatus::Listed); - assert_eq!(state.contracts, CONTRACTS); - assert_eq!(state.strike_per_contract, CALL_STRIKE); + assert_eq!(state.underlying_amount, CALL_UNDERLYING); + assert_eq!(state.strike_amount, CALL_STRIKE_AMOUNT); assert_eq!(state.premium, CALL_PREMIUM); assert_eq!(venue.market_state().underlying_owed, FIVE_NVDAX); venue.assert_vaults_match_ledger(); @@ -566,17 +565,19 @@ fn test_exercise_call_swaps_the_strike_for_the_underlying() { .exercise_option(&bob, &alice.pubkey(), &option) .unwrap(); - let strike_total = 900 * ONE_TOKEN; assert_eq!(venue.balance(&bob.underlying), FIVE_NVDAX); assert_eq!( venue.balance(&bob.quote), - STANDARD_USDC - CALL_PREMIUM - strike_total + STANDARD_USDC - CALL_PREMIUM - CALL_STRIKE_AMOUNT ); assert_eq!(venue.balance(&venue.underlying_vault), 0); - assert_eq!(venue.balance(&venue.quote_vault), strike_total + 250_000); + assert_eq!( + venue.balance(&venue.quote_vault), + CALL_STRIKE_AMOUNT + 250_000 + ); let market = venue.market_state(); assert_eq!(market.underlying_owed, 0); - assert_eq!(market.quote_owed, strike_total); + assert_eq!(market.quote_owed, CALL_STRIKE_AMOUNT); assert_eq!(venue.option_state(&option).status, OptionStatus::Exercised); venue.assert_vaults_match_ledger(); } @@ -643,10 +644,10 @@ fn test_collect_fees_pays_only_the_fees_owed() { // The put, and the option that expires unexercised // =========================================================================== -/// Carol writes 5 cash-secured puts at a 150 strike: 750 USDC of collateral. -/// Dave buys them for 20 USDC. NVIDIA falls below the strike offchain, so -/// Dave delivers his 5 NVDAx and takes the 750 USDC; Carol collects the -/// shares. Every amount is a product of two of the option's integers. +/// Carol writes a cash-secured put on 5 NVDAx at a 150 strike: 750 USDC of +/// collateral. Dave buys it for 20 USDC. NVIDIA falls below the strike +/// offchain, so Dave delivers his 5 NVDAx and takes the 750 USDC; Carol +/// collects the shares. Every amount is one the option stores. #[test] fn test_put_lifecycle_delivers_the_underlying_for_the_strike() { let mut venue = Venue::new(); @@ -935,25 +936,21 @@ fn test_collect_fees_is_refused_for_anyone_but_the_admin() { // =========================================================================== #[test] -fn test_write_option_rejects_zero_quantities_and_a_free_premium() { +fn test_write_option_rejects_zero_amounts_and_a_free_premium() { let mut venue = Venue::new(); let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); let expiry = venue.now() + ONE_WEEK; let attempts = [ - (0, ONE_NVDAX_PER_CONTRACT, CALL_STRIKE, CALL_PREMIUM), - (CONTRACTS, 0, CALL_STRIKE, CALL_PREMIUM), - (CONTRACTS, ONE_NVDAX_PER_CONTRACT, 0, CALL_PREMIUM), - (CONTRACTS, ONE_NVDAX_PER_CONTRACT, CALL_STRIKE, 0), + (0, CALL_STRIKE_AMOUNT, CALL_PREMIUM), + (CALL_UNDERLYING, 0, CALL_PREMIUM), + (CALL_UNDERLYING, CALL_STRIKE_AMOUNT, 0), ]; - for (id, (contracts, underlying_per_contract, strike_per_contract, premium)) in - attempts.into_iter().enumerate() - { + for (id, (underlying_amount, strike_amount, premium)) in attempts.into_iter().enumerate() { let terms = OptionTerms { kind: OptionKind::Call, - contracts, - underlying_per_contract, - strike_per_contract, + underlying_amount, + strike_amount, premium, expiry, }; @@ -978,22 +975,6 @@ fn test_write_option_rejects_an_expiry_that_has_passed() { } } -/// An option whose collateral would overflow is refused before anyone pays for -/// it, rather than failing at exercise. -#[test] -fn test_write_option_rejects_a_lot_whose_collateral_overflows() { - let mut venue = Venue::new(); - let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); - let expiry = venue.now() + ONE_WEEK; - - let terms = OptionTerms { - contracts: u64::MAX, - underlying_per_contract: 2, - ..call_terms(expiry) - }; - assert!(venue.write_option(&alice, 30, terms).is_err()); -} - #[test] fn test_initialize_market_rejects_a_full_fee() { assert!(Venue::try_new(10_000, false).is_err()); diff --git a/finance/options/anchor/CHANGELOG.md b/finance/options/anchor/CHANGELOG.md index 914e1673..f02a4bed 100644 --- a/finance/options/anchor/CHANGELOG.md +++ b/finance/options/anchor/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026-10-03 + +An option now stores `underlying_amount` and `strike_amount`, the two amounts +that change hands on exercise, instead of `contracts`, `underlying_per_contract` +and `strike_per_contract`, which the program only ever multiplied together. The +same change applies to the `OptionTerms` that `write_option` takes. Settlement +does no arithmetic: the collateral, the exercise payment and the proceeds are +each one of the two stored amounts, and `contract_math` loses `underlying_total` +and `strike_total`. With no multiplication left, nothing can overflow at write +time, so the test that wrote a lot whose collateral overflowed is gone. The +option account is 8 bytes smaller. + ## 2026-09-30 Rename the market's `underlying_locked` and `quote_locked` to `underlying_owed` diff --git a/finance/options/anchor/README.md b/finance/options/anchor/README.md index e86b497b..a827c158 100644 --- a/finance/options/anchor/README.md +++ b/finance/options/anchor/README.md @@ -47,11 +47,11 @@ call) or buy it at the strike (a put) if the holder asks. ### Covered and cash-secured: the collateral is the whole obligation A writer's obligation is bounded and known at write time, so this venue simply -takes all of it into custody. A call writer posts `contracts * -underlying_per_contract` of the underlying: the call is **covered**, and the -writer cannot fail to deliver because the shares are already in the vault. A -put writer posts `contracts * strike_per_contract` of the quote token: the put -is **cash-secured**, and the writer cannot fail to pay. Nothing is ever +takes all of it into custody. A call writer posts the option's +`underlying_amount` of the underlying: the call is **covered**, and the writer +cannot fail to deliver because the shares are already in the vault. A put +writer posts its `strike_amount` of the quote token: the put is +**cash-secured**, and the writer cannot fail to pay. Nothing is ever undercollateralized, which is why the program has no health check, no liquidation, and no need to know the price. @@ -66,14 +66,15 @@ program enforces the terms and nothing else. A cash-settled venue, which pays the holder the difference between the market price and the strike, would need a price feed and every check the *Offchain Truth* material describes. -### Every amount is a product of two integers +### The option stores the amounts that change hands -An option is defined by `contracts`, `underlying_per_contract` and -`strike_per_contract`, all minor-unit integers the writer chooses. The -collateral, the exercise payment and the proceeds are each one checked -multiplication of two of them. There is no division anywhere in settlement, so -there is no rounding to decide a direction for; the only rounding in the -program is the floor in the venue's fee. +An option stores two minor-unit amounts the writer chooses: `underlying_amount`, +the underlying it covers, and `strike_amount`, the quote paid for that +underlying on exercise (the strike for the whole option, as an amount rather +than a price). The collateral, the exercise payment and the proceeds are each +one of those two amounts, moved as stored. Settlement neither multiplies, +divides, nor rounds, so there is no rounding to decide a direction for; the +only rounding in the program is the floor in the venue's fee. ### Expiry is one comparison and its complement @@ -108,11 +109,11 @@ both vaults: it owns them and signs every transfer out of them with its own seeds. Maria's key is recorded as `admin`: it can sweep fees and do nothing else. -### Step 2: Alice writes 5 covered calls +### Step 2: Alice writes a covered call on 5 NVDAx -`write_option(id = 1, kind = Call, contracts = 5, underlying_per_contract = -1 NVDAx, strike_per_contract = 180 USDC, premium = 25 USDC, expiry = a week -out)` moves her 5 NVDAx into the underlying vault and creates the +`write_option(id = 1, kind = Call, underlying_amount = 5 NVDAx, strike_amount += 900 USDC, premium = 25 USDC, expiry = a week out)`, a strike of 180 USDC a +share, moves her 5 NVDAx into the underlying vault and creates the `OptionContract` account (a PDA of the market, Alice, and her `id`) with status `Listed`. Nobody has paid anything yet; Alice can `cancel_option` at any time until someone does. @@ -126,7 +127,7 @@ is fixed at the 25 USDC he just paid. ### Step 4: NVIDIA rallies to $200 and Bob exercises -`exercise_option`, called by Bob before expiry, moves 5 × 180 = 900 USDC from +`exercise_option`, called by Bob before expiry, moves the 900 USDC strike from Bob into the quote vault and 5 NVDAx from the underlying vault to Bob. He now holds 5 NVDAx worth about $1,000, having spent 925 USDC in total. The status is `Exercised`, and the 900 USDC sits in the vault owed to Alice. @@ -137,12 +138,12 @@ holds 5 NVDAx worth about $1,000, having spent 925 USDC in total. The status is back to her. She sold her 5 NVDAx for 900 USDC plus the 24.75 USDC premium she already had, and gave up everything above $180. -### Step 6: Carol writes 5 cash-secured puts, and Dave buys them +### Step 6: Carol writes a cash-secured put on 5 NVDAx, and Dave buys it -Carol's `write_option(id = 2, kind = Put, contracts = 5, underlying_per_contract -= 1 NVDAx, strike_per_contract = 150 USDC, premium = 20 USDC)` moves 5 × 150 = -750 USDC into the quote vault. Dave's `buy_option` pays 19.80 USDC to Carol -and 0.20 USDC to the vault for Maria. +Carol's `write_option(id = 2, kind = Put, underlying_amount = 5 NVDAx, +strike_amount = 750 USDC, premium = 20 USDC)`, a strike of 150 USDC a share, +moves the 750 USDC into the quote vault. Dave's `buy_option` pays 19.80 USDC +to Carol and 0.20 USDC to the vault for Maria. ### Step 7: The week passes above $150, and Carol reclaims her collateral diff --git a/finance/options/anchor/programs/options/src/contract_math.rs b/finance/options/anchor/programs/options/src/contract_math.rs index aa56a958..1603de95 100644 --- a/finance/options/anchor/programs/options/src/contract_math.rs +++ b/finance/options/anchor/programs/options/src/contract_math.rs @@ -1,55 +1,35 @@ -//! The pure arithmetic of one option, separated from account handling so it -//! can be unit-tested and model-checked (see `finance/options/kani-proofs`) +//! The pure logic of one option, separated from account handling so it can +//! be unit-tested and model-checked (see `finance/options/kani-proofs`) //! without the Solana machinery. //! -//! There is no division anywhere: every settlement amount is the product of -//! two integers the writer chose, and the only rounding in the program is the -//! floor in the fee split. Every function returns `None` on the paths the -//! program maps to `OptionsError::MathOverflow`. +//! There is no arithmetic in settlement at all: the option stores the two +//! amounts that change hands, and settlement moves them as they are. The only +//! rounding in the program is the floor in the fee split, and `split_premium` +//! returns `None` on the paths the program maps to `OptionsError::MathOverflow`. use crate::state::OptionKind; /// Basis-point denominator, mirroring `constants::BASIS_POINTS_DENOMINATOR`. const BASIS_POINTS: u128 = 10_000; -/// The underlying side of an option: `contracts * underlying_per_contract`. -pub fn underlying_total(contracts: u64, underlying_per_contract: u64) -> Option { - contracts.checked_mul(underlying_per_contract) -} - -/// The quote side of an option: `contracts * strike_per_contract`. -pub fn strike_total(contracts: u64, strike_per_contract: u64) -> Option { - contracts.checked_mul(strike_per_contract) -} - /// What the writer posts, in the collateral token's minor units: the /// underlying for a call, the strike for a put. Whatever the holder is /// entitled to at exercise is sitting in the vault from the moment the option /// exists, which is what makes the option fully collateralized. -pub fn collateral_amount( - kind: OptionKind, - contracts: u64, - underlying_per_contract: u64, - strike_per_contract: u64, -) -> Option { +pub fn collateral_amount(kind: OptionKind, underlying_amount: u64, strike_amount: u64) -> u64 { match kind { - OptionKind::Call => underlying_total(contracts, underlying_per_contract), - OptionKind::Put => strike_total(contracts, strike_per_contract), + OptionKind::Call => underlying_amount, + OptionKind::Put => strike_amount, } } /// What the holder pays at exercise, and the writer later collects: the /// strike for a call, the underlying for a put. The mirror of /// `collateral_amount`, in the other token. -pub fn exercise_payment( - kind: OptionKind, - contracts: u64, - underlying_per_contract: u64, - strike_per_contract: u64, -) -> Option { +pub fn exercise_payment(kind: OptionKind, underlying_amount: u64, strike_amount: u64) -> u64 { match kind { - OptionKind::Call => strike_total(contracts, strike_per_contract), - OptionKind::Put => underlying_total(contracts, underlying_per_contract), + OptionKind::Call => strike_amount, + OptionKind::Put => underlying_amount, } } diff --git a/finance/options/anchor/programs/options/src/instructions/cancel_option.rs b/finance/options/anchor/programs/options/src/instructions/cancel_option.rs index bfc06a8c..24823799 100644 --- a/finance/options/anchor/programs/options/src/instructions/cancel_option.rs +++ b/finance/options/anchor/programs/options/src/instructions/cancel_option.rs @@ -19,11 +19,9 @@ pub fn handle_cancel_option(context: &mut Context { market.underlying_owed = market .underlying_owed - .checked_sub(underlying_total) + .checked_sub(underlying_amount) .ok_or(OptionsError::MathOverflow)?; market.quote_owed = market .quote_owed - .checked_add(strike_total) + .checked_add(strike_amount) .ok_or(OptionsError::MathOverflow)?; ( underlying_before - .checked_sub(underlying_total) + .checked_sub(underlying_amount) .ok_or(OptionsError::CustodyInvariantViolated)?, quote_before - .checked_add(strike_total) + .checked_add(strike_amount) .ok_or(OptionsError::MathOverflow)?, ) } OptionKind::Put => { market.quote_owed = market .quote_owed - .checked_sub(strike_total) + .checked_sub(strike_amount) .ok_or(OptionsError::MathOverflow)?; market.underlying_owed = market .underlying_owed - .checked_add(underlying_total) + .checked_add(underlying_amount) .ok_or(OptionsError::MathOverflow)?; ( underlying_before - .checked_add(underlying_total) + .checked_add(underlying_amount) .ok_or(OptionsError::MathOverflow)?, quote_before - .checked_sub(strike_total) + .checked_sub(strike_amount) .ok_or(OptionsError::CustodyInvariantViolated)?, ) } @@ -97,7 +94,7 @@ pub fn handle_exercise_option( &context.accounts.quote_mint, &mut context.accounts.quote_vault, &context.accounts.holder, - strike_total, + strike_amount, )?; transfer_from_vault( &context.accounts.token_program, @@ -105,7 +102,7 @@ pub fn handle_exercise_option( &context.accounts.underlying_mint, &mut context.accounts.holder_underlying, market, - underlying_total, + underlying_amount, ) } OptionKind::Put => { @@ -115,7 +112,7 @@ pub fn handle_exercise_option( &context.accounts.underlying_mint, &mut context.accounts.underlying_vault, &context.accounts.holder, - underlying_total, + underlying_amount, )?; transfer_from_vault( &context.accounts.token_program, @@ -123,7 +120,7 @@ pub fn handle_exercise_option( &context.accounts.quote_mint, &mut context.accounts.holder_quote, market, - strike_total, + strike_amount, ) } } diff --git a/finance/options/anchor/programs/options/src/instructions/reclaim_collateral.rs b/finance/options/anchor/programs/options/src/instructions/reclaim_collateral.rs index 0b4ed984..592f0053 100644 --- a/finance/options/anchor/programs/options/src/instructions/reclaim_collateral.rs +++ b/finance/options/anchor/programs/options/src/instructions/reclaim_collateral.rs @@ -27,13 +27,8 @@ pub fn handle_reclaim_collateral( ); let kind = option.kind; - let collateral = contract_math::collateral_amount( - kind, - option.contracts, - option.underlying_per_contract, - option.strike_per_contract, - ) - .ok_or(OptionsError::MathOverflow)?; + let collateral = + contract_math::collateral_amount(kind, option.underlying_amount, option.strike_amount); let market = &mut context.accounts.market; let mut underlying_after = context.accounts.underlying_vault.amount(); diff --git a/finance/options/anchor/programs/options/src/instructions/write_option.rs b/finance/options/anchor/programs/options/src/instructions/write_option.rs index ae6c2168..df455a8c 100644 --- a/finance/options/anchor/programs/options/src/instructions/write_option.rs +++ b/finance/options/anchor/programs/options/src/instructions/write_option.rs @@ -16,15 +16,14 @@ use crate::state::{Market, OptionContract, OptionKind, OptionStatus}; pub struct OptionTerms { pub kind: OptionKind, - /// How many contracts the option holds. Bought and exercised as a whole. - pub contracts: u64, + /// Underlying minor units the option covers: what a call writer posts and + /// a call holder receives, or a put holder delivers (1 NVDAx = 1_000_000). + pub underlying_amount: u64, - /// Underlying minor units each contract is on (1 NVDAx = 1_000_000). - pub underlying_per_contract: u64, - - /// Quote minor units each contract settles at: the strike as an amount - /// per contract rather than a price, so exercise needs no decimals math. - pub strike_per_contract: u64, + /// Quote minor units paid for the underlying on exercise: what a put + /// writer posts and a put holder receives, or a call holder pays. The + /// strike for the whole option, as an amount rather than a price. + pub strike_amount: u64, /// Quote minor units the buyer pays the writer for the whole option. pub premium: u64, @@ -44,17 +43,16 @@ pub fn handle_write_option( ) -> Result<()> { let OptionTerms { kind, - contracts, - underlying_per_contract, - strike_per_contract, + underlying_amount, + strike_amount, premium, expiry, } = terms; - // Every quantity is a multiplier in the settlement math, so a zero in any - // of them is an option that delivers nothing or costs nothing to exercise. A - // zero premium is a gift rather than a sale, and is refused as a mistake. + // A zero amount is an option that delivers nothing or costs nothing to + // exercise. A zero premium is a gift rather than a sale, and is refused as + // a mistake. require!( - contracts > 0 && underlying_per_contract > 0 && strike_per_contract > 0 && premium > 0, + underlying_amount > 0 && strike_amount > 0 && premium > 0, OptionsError::InvalidParameter ); // Written in words: the holder may exercise while now < expiry. An expiry @@ -62,16 +60,7 @@ pub fn handle_write_option( let now = Clock::get()?.unix_timestamp; require!(expiry > now, OptionsError::ExpiryInPast); - // Both settlement amounts are computed here, at write time, so an option - // whose exercise would overflow is refused before anyone pays for it. - let underlying_total = contract_math::underlying_total(contracts, underlying_per_contract) - .ok_or(OptionsError::MathOverflow)?; - let strike_total = contract_math::strike_total(contracts, strike_per_contract) - .ok_or(OptionsError::MathOverflow)?; - let collateral = match kind { - OptionKind::Call => underlying_total, - OptionKind::Put => strike_total, - }; + let collateral = contract_math::collateral_amount(kind, underlying_amount, strike_amount); // Effects before the transfer: record the option and what the vault now owes. let option = &mut context.accounts.option; @@ -81,9 +70,8 @@ pub fn handle_write_option( option.holder = Address::default(); option.kind = kind; option.status = OptionStatus::Listed; - option.contracts = contracts; - option.underlying_per_contract = underlying_per_contract; - option.strike_per_contract = strike_per_contract; + option.underlying_amount = underlying_amount; + option.strike_amount = strike_amount; option.premium = premium; option.expiry = expiry; option.bump = context.bumps.option; diff --git a/finance/options/anchor/programs/options/src/state/option_contract.rs b/finance/options/anchor/programs/options/src/state/option_contract.rs index d25d77a6..1b892e24 100644 --- a/finance/options/anchor/programs/options/src/state/option_contract.rs +++ b/finance/options/anchor/programs/options/src/state/option_contract.rs @@ -29,16 +29,11 @@ pub enum OptionStatus { Exercised, } -/// One option: `contracts` identical contracts, written by one -/// writer, held by at most one holder. One PDA per (market, writer, id). +/// One option, written by one writer, held by at most one holder. One PDA +/// per (market, writer, id). /// -/// Every amount the option ever moves is a product of two of its integers, -/// so settlement never divides and never rounds: -/// -/// - `contracts * underlying_per_contract` underlying minor units, which a -/// call writer posts and a call holder receives (or a put holder delivers). -/// - `contracts * strike_per_contract` quote minor units, which a put writer -/// posts and a put holder receives (or a call holder pays). +/// It stores the two amounts that change hands on exercise, so settlement +/// neither multiplies, divides, nor rounds. #[account(borsh)] #[derive(InitSpace)] pub struct OptionContract { @@ -56,15 +51,15 @@ pub struct OptionContract { pub status: OptionStatus, - /// How many contracts the option holds. Bought and exercised as a whole. - pub contracts: u64, - - /// Underlying minor units each contract is on (1 NVDAx = 1_000_000). - pub underlying_per_contract: u64, + /// Underlying minor units the option covers: what a call writer posts and + /// a call holder receives, or a put holder delivers (1 NVDAx = 1_000_000). + pub underlying_amount: u64, - /// Quote minor units each contract settles at: the strike, per contract, - /// as an amount rather than a price, so exercise needs no decimals math. - pub strike_per_contract: u64, + /// Quote minor units paid for the underlying on exercise: what a put + /// writer posts and a put holder receives, or a call holder pays. The + /// strike for the whole option, as an amount rather than a price, so + /// exercise needs no decimals math. + pub strike_amount: u64, /// Quote minor units the buyer pays the writer for the whole option. pub premium: u64, diff --git a/finance/options/anchor/programs/options/tests/test_options.rs b/finance/options/anchor/programs/options/tests/test_options.rs index 3387f53c..472388eb 100644 --- a/finance/options/anchor/programs/options/tests/test_options.rs +++ b/finance/options/anchor/programs/options/tests/test_options.rs @@ -27,14 +27,15 @@ const DECIMALS: u8 = 6; // The venue charges 1% of every premium. const FEE_BPS: u16 = 100; -// The walkthrough's call: 5 contracts, each on 1 NVDAx, strike 180 USDC, -// asking 25 USDC for the option, expiring a week out. -const CONTRACTS: u64 = 5; -const ONE_NVDAX_PER_CONTRACT: u64 = ONE_TOKEN; -const CALL_STRIKE: u64 = 180 * ONE_TOKEN; +// The walkthrough's call: 5 NVDAx at a strike of 180 USDC each, 900 USDC in +// all, asking 25 USDC for the option, expiring a week out. +const CALL_UNDERLYING: u64 = 5 * ONE_TOKEN; +const CALL_STRIKE_AMOUNT: u64 = 900 * ONE_TOKEN; const CALL_PREMIUM: u64 = 25 * ONE_TOKEN; -// And the put: 5 contracts, strike 150 USDC, asking 20 USDC. -const PUT_STRIKE: u64 = 150 * ONE_TOKEN; +// And the put: 5 NVDAx at a strike of 150 USDC each, 750 USDC in all, asking +// 20 USDC. +const PUT_UNDERLYING: u64 = 5 * ONE_TOKEN; +const PUT_STRIKE_AMOUNT: u64 = 750 * ONE_TOKEN; const PUT_PREMIUM: u64 = 20 * ONE_TOKEN; const SECONDS_PER_DAY: i64 = 24 * 60 * 60; @@ -69,9 +70,8 @@ fn derive_ata(wallet: &Address, mint: &Address) -> Address { fn call_terms(expiry: i64) -> OptionTerms { OptionTerms { kind: OptionKind::Call, - contracts: CONTRACTS, - underlying_per_contract: ONE_NVDAX_PER_CONTRACT, - strike_per_contract: CALL_STRIKE, + underlying_amount: CALL_UNDERLYING, + strike_amount: CALL_STRIKE_AMOUNT, premium: CALL_PREMIUM, expiry, } @@ -328,9 +328,8 @@ impl Venue { 2, OptionTerms { kind: OptionKind::Put, - contracts: CONTRACTS, - underlying_per_contract: ONE_NVDAX_PER_CONTRACT, - strike_per_contract: PUT_STRIKE, + underlying_amount: PUT_UNDERLYING, + strike_amount: PUT_STRIKE_AMOUNT, premium: PUT_PREMIUM, expiry, }, @@ -525,8 +524,8 @@ fn test_write_call_moves_underlying_into_vault() { assert_eq!(state.holder, Address::default()); assert_eq!(state.kind, OptionKind::Call); assert_eq!(state.status, OptionStatus::Listed); - assert_eq!(state.contracts, CONTRACTS); - assert_eq!(state.strike_per_contract, CALL_STRIKE); + assert_eq!(state.underlying_amount, CALL_UNDERLYING); + assert_eq!(state.strike_amount, CALL_STRIKE_AMOUNT); assert_eq!(state.premium, CALL_PREMIUM); assert_eq!(venue.market_state().underlying_owed, FIVE_NVDAX); venue.assert_vaults_match_ledger(); @@ -573,17 +572,19 @@ fn test_exercise_call_swaps_the_strike_for_the_underlying() { .exercise_option(&bob, &alice.pubkey(), &option) .unwrap(); - let strike_total = 900 * ONE_TOKEN; assert_eq!(venue.balance(&bob.underlying), FIVE_NVDAX); assert_eq!( venue.balance(&bob.quote), - STANDARD_USDC - CALL_PREMIUM - strike_total + STANDARD_USDC - CALL_PREMIUM - CALL_STRIKE_AMOUNT ); assert_eq!(venue.balance(&venue.underlying_vault), 0); - assert_eq!(venue.balance(&venue.quote_vault), strike_total + 250_000); + assert_eq!( + venue.balance(&venue.quote_vault), + CALL_STRIKE_AMOUNT + 250_000 + ); let market = venue.market_state(); assert_eq!(market.underlying_owed, 0); - assert_eq!(market.quote_owed, strike_total); + assert_eq!(market.quote_owed, CALL_STRIKE_AMOUNT); assert_eq!(venue.option_state(&option).status, OptionStatus::Exercised); venue.assert_vaults_match_ledger(); } @@ -650,10 +651,10 @@ fn test_collect_fees_pays_only_the_fees_owed() { // The put, and the option that expires unexercised // =========================================================================== -/// Carol writes 5 cash-secured puts at a 150 strike: 750 USDC of collateral. -/// Dave buys them for 20 USDC. NVIDIA falls below the strike offchain, so -/// Dave delivers his 5 NVDAx and takes the 750 USDC; Carol collects the -/// shares. Every amount is a product of two of the option's integers. +/// Carol writes a cash-secured put on 5 NVDAx at a 150 strike: 750 USDC of +/// collateral. Dave buys it for 20 USDC. NVIDIA falls below the strike +/// offchain, so Dave delivers his 5 NVDAx and takes the 750 USDC; Carol +/// collects the shares. Every amount is one the option stores. #[test] fn test_put_lifecycle_delivers_the_underlying_for_the_strike() { let mut venue = Venue::new(); @@ -942,25 +943,21 @@ fn test_collect_fees_is_refused_for_anyone_but_the_admin() { // =========================================================================== #[test] -fn test_write_option_rejects_zero_quantities_and_a_free_premium() { +fn test_write_option_rejects_zero_amounts_and_a_free_premium() { let mut venue = Venue::new(); let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); let expiry = venue.now() + ONE_WEEK; let attempts = [ - (0, ONE_NVDAX_PER_CONTRACT, CALL_STRIKE, CALL_PREMIUM), - (CONTRACTS, 0, CALL_STRIKE, CALL_PREMIUM), - (CONTRACTS, ONE_NVDAX_PER_CONTRACT, 0, CALL_PREMIUM), - (CONTRACTS, ONE_NVDAX_PER_CONTRACT, CALL_STRIKE, 0), + (0, CALL_STRIKE_AMOUNT, CALL_PREMIUM), + (CALL_UNDERLYING, 0, CALL_PREMIUM), + (CALL_UNDERLYING, CALL_STRIKE_AMOUNT, 0), ]; - for (id, (contracts, underlying_per_contract, strike_per_contract, premium)) in - attempts.into_iter().enumerate() - { + for (id, (underlying_amount, strike_amount, premium)) in attempts.into_iter().enumerate() { let terms = OptionTerms { kind: OptionKind::Call, - contracts, - underlying_per_contract, - strike_per_contract, + underlying_amount, + strike_amount, premium, expiry, }; @@ -985,22 +982,6 @@ fn test_write_option_rejects_an_expiry_that_has_passed() { } } -/// An option whose collateral would overflow is refused before anyone pays for -/// it, rather than failing at exercise. -#[test] -fn test_write_option_rejects_a_lot_whose_collateral_overflows() { - let mut venue = Venue::new(); - let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); - let expiry = venue.now() + ONE_WEEK; - - let terms = OptionTerms { - contracts: u64::MAX, - underlying_per_contract: 2, - ..call_terms(expiry) - }; - assert!(venue.write_option(&alice, 30, terms).is_err()); -} - #[test] fn test_initialize_market_rejects_a_full_fee() { assert!(Venue::try_new(10_000, false).is_err()); diff --git a/finance/options/quasar/CHANGELOG.md b/finance/options/quasar/CHANGELOG.md index fd942915..10409853 100644 --- a/finance/options/quasar/CHANGELOG.md +++ b/finance/options/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2026-10-03 + +The option now stores `underlying_amount` and `strike_amount`, the two amounts +that change hands, instead of `contracts`, `underlying_per_contract` and +`strike_per_contract`, which the program only ever multiplied together. +`write_option` takes the two amounts as arguments in place of the three +quantities. Settlement does no arithmetic: the collateral is one stored amount +and the exercise payment is the other. The write-time overflow check, and the +test that wrote an option whose collateral overflowed, are gone. The account +layout changes. + ## 2026-09-30 Rename the market's `underlying_locked` and `quote_locked` to `underlying_owed` diff --git a/finance/options/quasar/README.md b/finance/options/quasar/README.md index e0830e62..f3187caf 100644 --- a/finance/options/quasar/README.md +++ b/finance/options/quasar/README.md @@ -14,8 +14,8 @@ differs in the Quasar version. `KIND_CALL` / `KIND_PUT` and `STATUS_LISTED` / `STATUS_HELD` / `STATUS_EXERCISED`. - **`write_option` takes its terms as separate arguments** (`kind`, - `contracts`, `underlying_per_contract`, `strike_per_contract`, `premium`, - `expiry`) rather than the Anchor sibling's `OptionTerms` struct. + `underlying_amount`, `strike_amount`, `premium`, `expiry`) rather than the + Anchor sibling's `OptionTerms` struct. - **Every party's token accounts must already exist.** The Anchor version uses `init_if_needed` to create a call holder's underlying account and a put writer's underlying account at the moment they are first paid in that diff --git a/finance/options/quasar/src/instructions/cancel_option.rs b/finance/options/quasar/src/instructions/cancel_option.rs index 95084388..4b5d12d6 100644 --- a/finance/options/quasar/src/instructions/cancel_option.rs +++ b/finance/options/quasar/src/instructions/cancel_option.rs @@ -53,11 +53,10 @@ pub fn handle_cancel_option( ); let terms = Terms { kind: accounts.option.kind, - contracts: accounts.option.contracts.get(), - underlying_per_contract: accounts.option.underlying_per_contract.get(), - strike_per_contract: accounts.option.strike_per_contract.get(), + underlying_amount: accounts.option.underlying_amount.get(), + strike_amount: accounts.option.strike_amount.get(), }; - let collateral = terms.collateral_amount()?; + let collateral = terms.collateral_amount(); let mut underlying_after = accounts.underlying_vault.amount(); let mut quote_after = accounts.quote_vault.amount(); diff --git a/finance/options/quasar/src/instructions/collect_proceeds.rs b/finance/options/quasar/src/instructions/collect_proceeds.rs index 8ab0205c..4aa10c80 100644 --- a/finance/options/quasar/src/instructions/collect_proceeds.rs +++ b/finance/options/quasar/src/instructions/collect_proceeds.rs @@ -54,11 +54,10 @@ pub fn handle_collect_proceeds( ); let terms = Terms { kind: accounts.option.kind, - contracts: accounts.option.contracts.get(), - underlying_per_contract: accounts.option.underlying_per_contract.get(), - strike_per_contract: accounts.option.strike_per_contract.get(), + underlying_amount: accounts.option.underlying_amount.get(), + strike_amount: accounts.option.strike_amount.get(), }; - let proceeds = terms.exercise_payment()?; + let proceeds = terms.exercise_payment(); let mut underlying_after = accounts.underlying_vault.amount(); let mut quote_after = accounts.quote_vault.amount(); diff --git a/finance/options/quasar/src/instructions/exercise_option.rs b/finance/options/quasar/src/instructions/exercise_option.rs index cef35fe3..e5d9102d 100644 --- a/finance/options/quasar/src/instructions/exercise_option.rs +++ b/finance/options/quasar/src/instructions/exercise_option.rs @@ -69,12 +69,9 @@ pub fn handle_exercise_option( let terms = Terms { kind: accounts.option.kind, - contracts: accounts.option.contracts.get(), - underlying_per_contract: accounts.option.underlying_per_contract.get(), - strike_per_contract: accounts.option.strike_per_contract.get(), + underlying_amount: accounts.option.underlying_amount.get(), + strike_amount: accounts.option.strike_amount.get(), }; - let underlying_total = terms.underlying_total()?; - let strike_total = terms.strike_total()?; // Effects: the option is exercised, and the vault now owes the writer the // payment instead of owing the holder the collateral. @@ -85,23 +82,23 @@ pub fn handle_exercise_option( sub_owed( &mut accounts.market.underlying_owed, &mut underlying_after, - underlying_total, + terms.underlying_amount, )?; add_owed( &mut accounts.market.quote_owed, &mut quote_after, - strike_total, + terms.strike_amount, )?; } else { sub_owed( &mut accounts.market.quote_owed, &mut quote_after, - strike_total, + terms.strike_amount, )?; add_owed( &mut accounts.market.underlying_owed, &mut underlying_after, - underlying_total, + terms.underlying_amount, )?; } check_custody(&accounts.market, underlying_after, quote_after)?; @@ -115,7 +112,7 @@ pub fn handle_exercise_option( &accounts.quote_mint, &accounts.quote_vault, &accounts.holder, - strike_total, + terms.strike_amount, accounts.quote_mint.decimals(), ) .invoke()?; @@ -125,7 +122,7 @@ pub fn handle_exercise_option( &accounts.underlying_mint, &accounts.holder_underlying, &accounts.market, - underlying_total, + terms.underlying_amount, ) } else { accounts @@ -135,7 +132,7 @@ pub fn handle_exercise_option( &accounts.underlying_mint, &accounts.underlying_vault, &accounts.holder, - underlying_total, + terms.underlying_amount, accounts.underlying_mint.decimals(), ) .invoke()?; @@ -145,7 +142,7 @@ pub fn handle_exercise_option( &accounts.quote_mint, &accounts.holder_quote, &accounts.market, - strike_total, + terms.strike_amount, ) } } diff --git a/finance/options/quasar/src/instructions/reclaim_collateral.rs b/finance/options/quasar/src/instructions/reclaim_collateral.rs index 7d046659..ac369abd 100644 --- a/finance/options/quasar/src/instructions/reclaim_collateral.rs +++ b/finance/options/quasar/src/instructions/reclaim_collateral.rs @@ -62,11 +62,10 @@ pub fn handle_reclaim_collateral( let terms = Terms { kind: accounts.option.kind, - contracts: accounts.option.contracts.get(), - underlying_per_contract: accounts.option.underlying_per_contract.get(), - strike_per_contract: accounts.option.strike_per_contract.get(), + underlying_amount: accounts.option.underlying_amount.get(), + strike_amount: accounts.option.strike_amount.get(), }; - let collateral = terms.collateral_amount()?; + let collateral = terms.collateral_amount(); let mut underlying_after = accounts.underlying_vault.amount(); let mut quote_after = accounts.quote_vault.amount(); diff --git a/finance/options/quasar/src/instructions/shared.rs b/finance/options/quasar/src/instructions/shared.rs index 9fe28d94..9fcc7c6c 100644 --- a/finance/options/quasar/src/instructions/shared.rs +++ b/finance/options/quasar/src/instructions/shared.rs @@ -1,7 +1,7 @@ //! The pure contract math and the custody check, ported from the Anchor -//! sibling (`options::contract_math` and `instructions::shared`). There is no -//! division anywhere in settlement: every amount is a product of two of the -//! option's integers, and the only rounding is the floor in the fee split. +//! sibling (`options::contract_math` and `instructions::shared`). Settlement +//! does no arithmetic at all: the option stores the two amounts that change +//! hands, and the only rounding is the floor in the fee split. use { crate::{ @@ -17,9 +17,8 @@ use { #[derive(Clone, Copy)] pub struct Terms { pub kind: u8, - pub contracts: u64, - pub underlying_per_contract: u64, - pub strike_per_contract: u64, + pub underlying_amount: u64, + pub strike_amount: u64, } impl Terms { @@ -27,36 +26,22 @@ impl Terms { self.kind == KIND_CALL } - /// `contracts * underlying_per_contract`. - pub fn underlying_total(&self) -> Result { - self.contracts - .checked_mul(self.underlying_per_contract) - .ok_or_else(|| OptionsError::MathOverflow.into()) - } - - /// `contracts * strike_per_contract`. - pub fn strike_total(&self) -> Result { - self.contracts - .checked_mul(self.strike_per_contract) - .ok_or_else(|| OptionsError::MathOverflow.into()) - } - /// What the writer posts: the underlying for a call, the strike for a put. - pub fn collateral_amount(&self) -> Result { + pub fn collateral_amount(&self) -> u64 { if self.is_call() { - self.underlying_total() + self.underlying_amount } else { - self.strike_total() + self.strike_amount } } /// What the holder pays at exercise and the writer later collects: the /// mirror of `collateral_amount`, in the other token. - pub fn exercise_payment(&self) -> Result { + pub fn exercise_payment(&self) -> u64 { if self.is_call() { - self.strike_total() + self.strike_amount } else { - self.underlying_total() + self.underlying_amount } } } diff --git a/finance/options/quasar/src/instructions/write_option.rs b/finance/options/quasar/src/instructions/write_option.rs index 14124009..2d02361a 100644 --- a/finance/options/quasar/src/instructions/write_option.rs +++ b/finance/options/quasar/src/instructions/write_option.rs @@ -15,9 +15,8 @@ use { pub struct WriteOptionArguments { pub id: u64, pub kind: u8, - pub contracts: u64, - pub underlying_per_contract: u64, - pub strike_per_contract: u64, + pub underlying_amount: u64, + pub strike_amount: u64, pub premium: u64, pub expiry: i64, } @@ -71,14 +70,11 @@ pub fn handle_write_option( bumps: &WriteOptionAccountConstraintsBumps, ) -> Result<(), ProgramError> { require_valid_kind(arguments.kind)?; - // Every quantity is a multiplier in the settlement math, so a zero in any - // of them is an option that delivers nothing or costs nothing to exercise. A - // zero premium is a gift rather than a sale, and is refused as a mistake. + // A zero underlying amount is an option that delivers nothing, and a zero + // strike amount is one that costs nothing to exercise. A zero premium is a + // gift rather than a sale, and is refused as a mistake. require!( - arguments.contracts > 0 - && arguments.underlying_per_contract > 0 - && arguments.strike_per_contract > 0 - && arguments.premium > 0, + arguments.underlying_amount > 0 && arguments.strike_amount > 0 && arguments.premium > 0, OptionsError::InvalidParameter ); // Written in words: the holder may exercise while now < expiry. An expiry @@ -88,15 +84,10 @@ pub fn handle_write_option( let terms = Terms { kind: arguments.kind, - contracts: arguments.contracts, - underlying_per_contract: arguments.underlying_per_contract, - strike_per_contract: arguments.strike_per_contract, + underlying_amount: arguments.underlying_amount, + strike_amount: arguments.strike_amount, }; - // Both settlement amounts are computed here, at write time, so an option - // whose exercise would overflow is refused before anyone pays for it. - terms.underlying_total()?; - terms.strike_total()?; - let collateral = terms.collateral_amount()?; + let collateral = terms.collateral_amount(); // Effects before the transfer: record the option and what the vault now owes. accounts.option.set_inner(OptionContractInner { @@ -104,9 +95,8 @@ pub fn handle_write_option( market: *accounts.market.address(), writer: *accounts.writer.address(), holder: Address::default(), - contracts: arguments.contracts, - underlying_per_contract: arguments.underlying_per_contract, - strike_per_contract: arguments.strike_per_contract, + underlying_amount: arguments.underlying_amount, + strike_amount: arguments.strike_amount, premium: arguments.premium, expiry: arguments.expiry, kind: arguments.kind, diff --git a/finance/options/quasar/src/lib.rs b/finance/options/quasar/src/lib.rs index 3c828682..791dc089 100644 --- a/finance/options/quasar/src/lib.rs +++ b/finance/options/quasar/src/lib.rs @@ -35,9 +35,8 @@ mod quasar_options { ctx: Ctx, id: u64, kind: u8, - contracts: u64, - underlying_per_contract: u64, - strike_per_contract: u64, + underlying_amount: u64, + strike_amount: u64, premium: u64, expiry: i64, ) -> Result<(), ProgramError> { @@ -46,9 +45,8 @@ mod quasar_options { WriteOptionArguments { id, kind, - contracts, - underlying_per_contract, - strike_per_contract, + underlying_amount, + strike_amount, premium, expiry, }, diff --git a/finance/options/quasar/src/state.rs b/finance/options/quasar/src/state.rs index 3a3070e4..6072a82b 100644 --- a/finance/options/quasar/src/state.rs +++ b/finance/options/quasar/src/state.rs @@ -30,9 +30,8 @@ pub struct Market { /// One option. Mirrors the Anchor `OptionContract`; `kind` and `status` /// are `u8` (see `constants.rs`) because the account layout is zero-copy. /// -/// Every amount the option ever moves is a product of two of its integers: -/// `contracts * underlying_per_contract` of the underlying, and -/// `contracts * strike_per_contract` of the quote token. +/// The option stores the two amounts that change hands, so settlement does no +/// arithmetic. #[account(discriminator = 2, set_inner)] #[seeds(b"option", market: Address, writer: Address, id: u64)] pub struct OptionContract { @@ -41,9 +40,13 @@ pub struct OptionContract { pub writer: Address, /// The buyer, once there is one. All zeroes while listed. pub holder: Address, - pub contracts: u64, - pub underlying_per_contract: u64, - pub strike_per_contract: u64, + /// Underlying minor units the option covers: what a call writer posts and + /// a call holder receives, or a put holder delivers. + pub underlying_amount: u64, + /// Quote minor units paid for the underlying on exercise: what a put + /// writer posts and a put holder receives, or a call holder pays. The + /// strike for the whole option, as an amount rather than a price. + pub strike_amount: u64, pub premium: u64, /// Unix timestamp after which the holder can no longer exercise and the /// writer may reclaim the collateral. Wall-clock time because an option's diff --git a/finance/options/quasar/src/tests.rs b/finance/options/quasar/src/tests.rs index 55569b54..9d59e41e 100644 --- a/finance/options/quasar/src/tests.rs +++ b/finance/options/quasar/src/tests.rs @@ -23,13 +23,14 @@ const ONE_TOKEN: u64 = 1_000_000; // The venue charges 1% of every premium. const FEE_BPS: u16 = 100; -// The walkthrough's call: 5 contracts, each on 1 NVDAx, strike 180 USDC, -// asking 25 USDC for the option. And the put: strike 150 USDC, asking 20 USDC. -const CONTRACTS: u64 = 5; -const ONE_NVDAX_PER_CONTRACT: u64 = ONE_TOKEN; -const CALL_STRIKE: u64 = 180 * ONE_TOKEN; +// The walkthrough's call: 5 NVDAx at a 180 USDC strike per share, so the +// holder pays 5 x 180 = 900 USDC on exercise, asking 25 USDC for the option. +// And the put: 5 NVDAx at 150 USDC, so 750 USDC, asking 20 USDC. +const CALL_UNDERLYING: u64 = 5 * ONE_TOKEN; +const CALL_STRIKE_AMOUNT: u64 = 900 * ONE_TOKEN; const CALL_PREMIUM: u64 = 25 * ONE_TOKEN; -const PUT_STRIKE: u64 = 150 * ONE_TOKEN; +const PUT_UNDERLYING: u64 = 5 * ONE_TOKEN; +const PUT_STRIKE_AMOUNT: u64 = 750 * ONE_TOKEN; const PUT_PREMIUM: u64 = 20 * ONE_TOKEN; const CALL_ID: u64 = 1; const PUT_ID: u64 = 2; @@ -151,9 +152,8 @@ fn write_option( writer: &Person, id: u64, kind: u8, - contracts: u64, - underlying_per_contract: u64, - strike_per_contract: u64, + underlying_amount: u64, + strike_amount: u64, premium: u64, expiry: i64, ) -> Outcome { @@ -167,9 +167,8 @@ fn write_option( writer_quote: writer.usdc, id, kind, - contracts, - underlying_per_contract, - strike_per_contract, + underlying_amount, + strike_amount, premium, expiry, }) @@ -187,9 +186,8 @@ fn write_call(test: &mut Test, env: &Env) -> Pubkey { &ALICE_P, CALL_ID, KIND_CALL, - CONTRACTS, - ONE_NVDAX_PER_CONTRACT, - CALL_STRIKE, + CALL_UNDERLYING, + CALL_STRIKE_AMOUNT, CALL_PREMIUM, EXPIRY, ) @@ -205,9 +203,8 @@ fn write_put(test: &mut Test, env: &Env) -> Pubkey { &CAROL_P, PUT_ID, KIND_PUT, - CONTRACTS, - ONE_NVDAX_PER_CONTRACT, - PUT_STRIKE, + PUT_UNDERLYING, + PUT_STRIKE_AMOUNT, PUT_PREMIUM, EXPIRY, ) @@ -334,7 +331,7 @@ fn market_owns_both_vaults(test: &mut Test) { assert_eq!(token_authority(test, env.quote_vault), env.market); } -/// Alice writes 5 covered calls on her 5 NVDAx. The whole 5 NVDAx moves into +/// Alice writes a covered call on her 5 NVDAx. The whole 5 NVDAx moves into /// the vault at once; the option is listed for a 25 USDC premium. #[quasar_test] fn write_call_moves_underlying_into_vault(test: &mut Test) { @@ -348,8 +345,8 @@ fn write_call_moves_underlying_into_vault(test: &mut Test) { assert_eq!(state.holder, Pubkey::default()); assert_eq!(state.kind, KIND_CALL); assert_eq!(state.status, STATUS_LISTED); - assert_eq!(u64::from(state.contracts), CONTRACTS); - assert_eq!(u64::from(state.strike_per_contract), CALL_STRIKE); + assert_eq!(u64::from(state.underlying_amount), CALL_UNDERLYING); + assert_eq!(u64::from(state.strike_amount), CALL_STRIKE_AMOUNT); assert_eq!(u64::from(state.premium), CALL_PREMIUM); assert_eq!(i64::from(state.expiry), EXPIRY); assert_eq!( @@ -390,17 +387,16 @@ fn exercise_call_swaps_the_strike_for_the_underlying(test: &mut Test) { let option = write_call(test, &env); buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); - let strike_total = 900 * ONE_TOKEN; exercise_option(test, &env, &BOB_P, &ALICE_P, CALL_ID) .succeeds() .has_tokens(BOB_NVDAX, FIVE_NVDAX) - .has_tokens(BOB_USDC, STANDARD_USDC - CALL_PREMIUM - strike_total) + .has_tokens(BOB_USDC, STANDARD_USDC - CALL_PREMIUM - CALL_STRIKE_AMOUNT) .has_tokens(env.underlying_vault, 0) - .has_tokens(env.quote_vault, strike_total + 250_000); + .has_tokens(env.quote_vault, CALL_STRIKE_AMOUNT + 250_000); let market = test.read::(env.market); assert_eq!(u64::from(market.underlying_owed), 0); - assert_eq!(u64::from(market.quote_owed), strike_total); + assert_eq!(u64::from(market.quote_owed), CALL_STRIKE_AMOUNT); assert_eq!(test.read::(option).status, STATUS_EXERCISED); assert_vaults_match_ledger(test, &env); } @@ -417,7 +413,7 @@ fn collect_proceeds_pays_the_writer_and_closes_the_option(test: &mut Test) { .succeeds() .has_tokens( ALICE_USDC, - STANDARD_USDC + 900 * ONE_TOKEN + CALL_PREMIUM - 250_000, + STANDARD_USDC + CALL_STRIKE_AMOUNT + CALL_PREMIUM - 250_000, ) .has_tokens(ALICE_NVDAX, 0) .is_closed(option); @@ -440,7 +436,7 @@ fn collect_fees_pays_only_the_fees_owed(test: &mut Test) { collect_fees(test, &env, MARIA, MARIA_USDC) .succeeds() .has_tokens(MARIA_USDC, 250_000) - .has_tokens(env.quote_vault, 900 * ONE_TOKEN); + .has_tokens(env.quote_vault, CALL_STRIKE_AMOUNT); assert_eq!(u64::from(test.read::(env.market).fees_owed), 0); assert_vaults_match_ledger(test, &env); @@ -452,13 +448,13 @@ fn collect_fees_pays_only_the_fees_owed(test: &mut Test) { // The put, and the option that expires unexercised // =========================================================================== -/// Carol writes 5 cash-secured puts at a 150 strike: 750 USDC of collateral. -/// Dave buys them for 20 USDC, then delivers his 5 NVDAx for the 750 USDC. +/// Carol writes a cash-secured put on 5 NVDAx at a 150 strike: 750 USDC of +/// collateral. Dave buys it for 20 USDC, then delivers his 5 NVDAx for the 750 USDC. #[quasar_test] fn put_lifecycle_delivers_the_underlying_for_the_strike(test: &mut Test) { let env = setup(test); let option = write_put(test, &env); - let collateral = 750 * ONE_TOKEN; + let collateral = PUT_STRIKE_AMOUNT; assert_eq!(test.tokens(CAROL_USDC), STANDARD_USDC - collateral); assert_eq!(test.tokens(env.quote_vault), collateral); assert_vaults_match_ledger(test, &env); @@ -684,7 +680,7 @@ fn collect_proceeds_needs_an_exercised_option_and_the_writer(test: &mut Test) { // A non-writer's signature derives a different option PDA, so the // account check fails before the handler runs. assert!(collect_proceeds(test, &env, &BOB_P, CALL_ID).is_err()); - assert_eq!(test.tokens(env.quote_vault), 900 * ONE_TOKEN + 250_000); + assert_eq!(test.tokens(env.quote_vault), CALL_STRIKE_AMOUNT + 250_000); } /// An exercised option has no collateral left to reclaim, whatever the clock @@ -716,24 +712,22 @@ fn collect_fees_is_refused_for_anyone_but_the_admin(test: &mut Test) { // =========================================================================== #[quasar_test] -fn write_option_rejects_zero_quantities_and_a_free_premium(test: &mut Test) { +fn write_option_rejects_zero_amounts_and_a_free_premium(test: &mut Test) { let env = setup(test); let attempts = [ - (0, ONE_NVDAX_PER_CONTRACT, CALL_STRIKE, CALL_PREMIUM), - (CONTRACTS, 0, CALL_STRIKE, CALL_PREMIUM), - (CONTRACTS, ONE_NVDAX_PER_CONTRACT, 0, CALL_PREMIUM), - (CONTRACTS, ONE_NVDAX_PER_CONTRACT, CALL_STRIKE, 0), + (0, CALL_STRIKE_AMOUNT, CALL_PREMIUM), + (CALL_UNDERLYING, 0, CALL_PREMIUM), + (CALL_UNDERLYING, CALL_STRIKE_AMOUNT, 0), ]; - for (offset, (contracts, per_contract, strike, premium)) in attempts.into_iter().enumerate() { + for (offset, (underlying_amount, strike_amount, premium)) in attempts.into_iter().enumerate() { write_option( test, &env, &ALICE_P, 10 + offset as u64, KIND_CALL, - contracts, - per_contract, - strike, + underlying_amount, + strike_amount, premium, EXPIRY, ) @@ -751,9 +745,8 @@ fn write_option_rejects_an_unknown_kind(test: &mut Test) { &ALICE_P, 20, 2, - CONTRACTS, - ONE_NVDAX_PER_CONTRACT, - CALL_STRIKE, + CALL_UNDERLYING, + CALL_STRIKE_AMOUNT, CALL_PREMIUM, EXPIRY, ) @@ -771,9 +764,8 @@ fn write_option_rejects_an_expiry_that_has_passed(test: &mut Test) { &ALICE_P, 30, KIND_CALL, - CONTRACTS, - ONE_NVDAX_PER_CONTRACT, - CALL_STRIKE, + CALL_UNDERLYING, + CALL_STRIKE_AMOUNT, CALL_PREMIUM, expiry, ) @@ -781,25 +773,6 @@ fn write_option_rejects_an_expiry_that_has_passed(test: &mut Test) { } } -/// An option whose collateral would overflow is refused before anyone pays for it. -#[quasar_test] -fn write_option_rejects_a_lot_whose_collateral_overflows(test: &mut Test) { - let env = setup(test); - write_option( - test, - &env, - &ALICE_P, - 40, - KIND_CALL, - u64::MAX, - 2, - CALL_STRIKE, - CALL_PREMIUM, - EXPIRY, - ) - .fails_with(OptionsError::MathOverflow); -} - #[quasar_test] fn initialize_market_rejects_a_full_fee(test: &mut Test) { test.add(Wallet::new().at(MARIA));