diff --git a/.github/workflows/anchor.yml b/.github/workflows/anchor.yml index aa3430cc..1925ccee 100644 --- a/.github/workflows/anchor.yml +++ b/.github/workflows/anchor.yml @@ -300,6 +300,7 @@ jobs: case "$project" in ./basics/pyth/anchor \ |./finance/betting-market/anchor \ + |./finance/options/anchor \ |./finance/order-book/anchor \ |./finance/perpetual-futures/anchor \ |./finance/prop-amm/anchor \ diff --git a/.github/workflows/kani.yml b/.github/workflows/kani.yml index 32f30ead..b01b7bd6 100644 --- a/.github/workflows/kani.yml +++ b/.github/workflows/kani.yml @@ -61,6 +61,7 @@ jobs: - vault-strategy - token-fundraiser - prop-amm + - options steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable @@ -105,6 +106,7 @@ jobs: - vault-strategy - token-fundraiser - prop-amm + - options steps: - uses: actions/checkout@v5 - name: Run Kani diff --git a/CHANGELOG.md b/CHANGELOG.md index 773b599c..5adb01e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to this repository are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2026-09-04] - Options venue example + +### Added + +- `finance/options`: a fully collateralized, physically settled options venue, + in Anchor v2, Anchor v1, and Quasar, with a Kani proof crate. A writer posts + the whole obligation (the underlying for a call, the strike in the quote token + for a put) and lists an option at a premium; a buyer pays the premium and becomes + the holder; the holder may exercise before expiry; after expiry the writer + reclaims the collateral. Eight instruction handlers (`initialize_market`, + `write_option`, `buy_option`, `cancel_option`, `exercise_option`, + `collect_proceeds`, `reclaim_collateral`, `collect_fees`). Every settlement + amount is a product of two of the option's integers, so there is no division and + no rounding in settlement; the venue's fee on each premium is the only floor. + The market account keeps a ledger of what each vault owes, asserted against + the vault balances after every transfer, and the proof crate walks every path + through an option's life and shows the ledger returns to zero. No oracle: physical + settlement moves the tokens themselves, so the program never has to know the + price. Each option is one account, bought and exercised as a whole. +- The Anchor v2 copy joins the `--no-idl` list in `.github/workflows/anchor.yml` + (anchor#4947: its `OptionKind` and `OptionStatus` enums reach the IDL) and the + root Cargo workspace; the proof crate joins both matrices in + `.github/workflows/kani.yml`. + ## [2026-08-21] - Anchor v1 kept alongside Anchor v2 Anchor v1 is expected to stay on long-term support, and many deployed programs diff --git a/Cargo.lock b/Cargo.lock index 886b96fd..798084ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,12 +8,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "spl-discriminator 0.5.2", "spl-tlv-account-resolution 0.11.1", "spl-transfer-hook-interface 2.1.0", @@ -25,11 +23,9 @@ name = "account-data-anchor-program" version = "0.1.0" dependencies = [ "anchor-lang", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -285,11 +281,9 @@ name = "anchor-realloc" version = "0.1.0" dependencies = [ "anchor-lang", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -317,6 +311,20 @@ dependencies = [ "spl-token-metadata-interface", ] +[[package]] +name = "anchor-v2-testing" +version = "0.1.0" +source = "git+https://github.com/otter-sec/anchor.git?rev=3e38a121b969d1a77230baeb0056baf5cc9b3c1a#3e38a121b969d1a77230baeb0056baf5cc9b3c1a" +dependencies = [ + "litesvm", + "solana-keypair", + "solana-message 3.1.0", + "solana-program-runtime", + "solana-signer 3.0.0", + "solana-transaction", + "solana-transaction-context", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -641,12 +649,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "spl-associated-token-account", "spl-token", "wincode 0.5.5", @@ -661,6 +667,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.0" @@ -921,10 +933,8 @@ name = "carnival" version = "0.1.0" dependencies = [ "anchor-lang", - "litesvm", - "solana-keypair", + "anchor-v2-testing", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -980,11 +990,9 @@ name = "checking-accounts-anchor-program-example" version = "0.1.0" dependencies = [ "anchor-lang", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -1068,11 +1076,9 @@ name = "close-account-program" version = "0.1.0" dependencies = [ "anchor-lang", - "litesvm", + "anchor-v2-testing", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -1087,17 +1093,16 @@ name = "cnft-burn" version = "0.1.0" dependencies = [ "anchor-lang", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-account 3.4.0", "solana-address 2.6.1", "solana-instruction 3.2.0", "solana-keccak-hasher 3.1.0", - "solana-keypair", + "solana-kite", "solana-message 3.1.0", "solana-native-token 3.0.0", "solana-pubkey 3.0.0", - "solana-signer 3.0.0", "solana-transaction", "wincode 0.5.5", ] @@ -1107,17 +1112,16 @@ name = "cnft-vault" version = "0.1.0" dependencies = [ "anchor-lang", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-account 3.4.0", "solana-address 2.6.1", "solana-instruction 3.2.0", "solana-keccak-hasher 3.1.0", - "solana-keypair", + "solana-kite", "solana-message 3.1.0", "solana-native-token 3.0.0", "solana-pubkey 3.0.0", - "solana-signer 3.0.0", "solana-transaction", "wincode 0.5.5", ] @@ -1206,11 +1210,9 @@ name = "counter_anchor" version = "0.1.0" dependencies = [ "anchor-lang", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -1220,12 +1222,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -1296,10 +1296,8 @@ name = "create-system-account" version = "0.1.0" dependencies = [ "anchor-lang", - "litesvm", - "solana-keypair", + "anchor-v2-testing", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -1309,12 +1307,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -1460,18 +1456,17 @@ name = "cutils" version = "0.1.0" dependencies = [ "anchor-lang", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "sha3", "solana-account 3.4.0", "solana-address 2.6.1", "solana-instruction 3.2.0", "solana-keccak-hasher 3.1.0", - "solana-keypair", + "solana-kite", "solana-message 3.1.0", "solana-native-token 3.0.0", "solana-pubkey 3.0.0", - "solana-signer 3.0.0", "solana-transaction", "wincode 0.5.5", ] @@ -1516,12 +1511,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -1718,12 +1711,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -1733,14 +1724,12 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", "solana-instruction 3.2.0", - "solana-keypair", "solana-kite", "solana-pubkey 3.0.0", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -1750,15 +1739,13 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", "libsecp256k1 0.7.2", - "litesvm", "sha3", "solana-address 2.6.1", - "solana-keypair", "solana-kite", "solana-secp256k1-recover 2.2.1", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -1767,10 +1754,8 @@ name = "favorites" version = "0.1.0" dependencies = [ "anchor-lang", - "litesvm", - "solana-keypair", + "anchor-v2-testing", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -1896,13 +1881,11 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", "solana-clock 3.1.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "spl-associated-token-account", "spl-token", "wincode 0.5.5", @@ -1938,6 +1921,20 @@ dependencies = [ "slab", ] +[[package]] +name = "gdbstub" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e02bf1b1a624d96925c608f1b268d82a76cbc587ce9e59f7c755e9ea11c75c" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "log", + "managed", + "num-traits", + "paste", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1997,12 +1994,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -2024,10 +2019,8 @@ name = "hand" version = "0.1.0" dependencies = [ "anchor-lang", - "litesvm", - "solana-keypair", + "anchor-v2-testing", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -2078,9 +2071,8 @@ name = "hello-solana-anchor" version = "0.1.0" dependencies = [ "anchor-lang", - "litesvm", + "anchor-v2-testing", "solana-kite", - "solana-signer 3.0.0", "solana-transaction", "wincode 0.5.5", ] @@ -2131,6 +2123,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.8.1" @@ -2182,12 +2180,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -2216,12 +2212,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -2322,15 +2316,13 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "pinocchio 0.11.2", "solana-address 2.6.1", "solana-clock 3.1.1", - "solana-keypair", "solana-kite", "solana-pubkey 3.0.0", - "solana-signer 3.0.0", "solana-sysvar 3.1.1", "wincode 0.5.5", ] @@ -2340,10 +2332,8 @@ name = "lever" version = "0.1.0" dependencies = [ "anchor-lang", - "litesvm", - "solana-keypair", + "anchor-v2-testing", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -2482,10 +2472,13 @@ dependencies = [ "agave-syscalls", "ansi_term", "bincode", + "hex", "indexmap", "itertools 0.14.0", "log", + "nom", "serde", + "sha2 0.10.9", "solana-account 3.4.0", "solana-address 2.6.1", "solana-address-lookup-table-interface 3.0.1", @@ -2552,6 +2545,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "memchr" version = "2.8.2" @@ -2564,12 +2563,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -2600,12 +2597,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "spl-token-metadata-interface", "spl-type-length-value 0.9.1", "wincode 0.5.5", @@ -2617,12 +2612,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -2632,12 +2625,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -2695,12 +2686,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -2724,18 +2713,25 @@ dependencies = [ "spl-token-interface", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "non-transferable" version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -2891,18 +2887,32 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "options" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "anchor-v2-testing", + "borsh 1.7.0", + "solana-address 2.6.1", + "solana-clock 3.1.1", + "solana-kite", + "spl-associated-token-account", + "spl-token", + "wincode 0.5.5", +] + [[package]] name = "order_book" version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "bytemuck", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "static_assertions", "wincode 0.5.5", ] @@ -2966,13 +2976,11 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", "solana-program-pack 3.1.0", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -3001,10 +3009,8 @@ name = "pda-rent-payer" version = "0.1.0" dependencies = [ "anchor-lang", - "litesvm", - "solana-keypair", + "anchor-v2-testing", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -3062,12 +3068,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -3077,15 +3081,13 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "mock_switchboard", "pinocchio 0.11.2", "solana-address 2.6.1", "solana-clock 3.1.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "solana-sysvar 3.1.1", "spl-associated-token-account", "spl-token", @@ -3268,10 +3270,8 @@ name = "processing-instructions" version = "0.1.0" dependencies = [ "anchor-lang", - "litesvm", - "solana-keypair", + "anchor-v2-testing", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -3359,11 +3359,9 @@ name = "program-derived-addresses-program" version = "0.1.0" dependencies = [ "anchor-lang", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -3373,15 +3371,13 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "mock_switchboard_prop_amm", "pinocchio 0.11.2", "solana-address 2.6.1", "solana-clock 3.1.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "solana-sysvar 3.1.1", "spl-associated-token-account", "spl-token", @@ -3393,16 +3389,14 @@ name = "pythexample" version = "0.1.0" dependencies = [ "anchor-lang", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "pythexample", "sha2 0.10.9", "solana-account 3.4.0", "solana-address 2.6.1", "solana-clock 3.1.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -3594,7 +3588,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -3631,10 +3625,8 @@ name = "rent-example" version = "0.1.0" dependencies = [ "anchor-lang", - "litesvm", - "solana-keypair", + "anchor-v2-testing", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -4763,7 +4755,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0e85a6fad5c2d0c4f5b91d34b8ca47118fc593af706e523cdbedf846a954f57" dependencies = [ - "bitflags", + "bitflags 2.13.0", "solana-account-info 2.3.0", "solana-instruction 2.3.3", "solana-program-error 2.2.2", @@ -4780,7 +4772,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddf67876c541aa1e21ee1acae35c95c6fbc61119814bfef70579317a5e26955" dependencies = [ - "bitflags", + "bitflags 2.13.0", "solana-account-info 3.1.1", "solana-instruction 3.2.0", "solana-instruction-error", @@ -5108,7 +5100,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad62e1045c2347a0c0e219a6ceb0abfe904be622920996bfcac8d116fabe3c7" dependencies = [ - "bitflags", + "bitflags 2.13.0", "solana-pubkey 4.1.0", ] @@ -5579,6 +5571,7 @@ checksum = "733b3657a0fab205102b799dbe17f85d3972cf984232c1b0b108fa6ba438e382" dependencies = [ "byteorder", "combine", + "gdbstub", "hash32", "libc", "log", @@ -6968,12 +6961,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -7075,12 +7066,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -7177,12 +7166,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -7251,12 +7238,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -7266,12 +7251,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "spl-discriminator 0.4.1", "spl-tlv-account-resolution 0.9.0", "spl-transfer-hook-interface 0.9.0", @@ -7284,12 +7267,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "spl-discriminator 0.4.1", "spl-tlv-account-resolution 0.9.0", "spl-transfer-hook-interface 0.9.0", @@ -7302,12 +7283,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "spl-discriminator 0.4.1", "spl-tlv-account-resolution 0.9.0", "spl-transfer-hook-interface 0.9.0", @@ -7320,12 +7299,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "spl-discriminator 0.5.2", "spl-tlv-account-resolution 0.11.1", "spl-transfer-hook-interface 2.1.0", @@ -7338,12 +7315,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "spl-discriminator 0.4.1", "spl-tlv-account-resolution 0.9.0", "spl-transfer-hook-interface 0.9.0", @@ -7355,10 +7330,8 @@ name = "transfer-sol" version = "0.1.0" dependencies = [ "anchor-lang", - "litesvm", - "solana-keypair", + "anchor-v2-testing", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -7399,12 +7372,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "spl-discriminator 0.4.1", "spl-tlv-account-resolution 0.9.0", "spl-transfer-hook-interface 0.9.0", @@ -7417,12 +7388,10 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "solana-address 2.6.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] @@ -7499,15 +7468,13 @@ version = "0.1.0" dependencies = [ "anchor-lang", "anchor-spl", + "anchor-v2-testing", "borsh 1.7.0", - "litesvm", "mock-swap-router", "solana-account 3.4.0", "solana-address 2.6.1", "solana-clock 3.1.1", - "solana-keypair", "solana-kite", - "solana-signer 3.0.0", "wincode 0.5.5", ] diff --git a/Cargo.toml b/Cargo.toml index f3ca804b..5424047f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ members = [ "finance/betting-market/anchor/programs/betting-market", "finance/escrow/anchor/programs/escrow", "finance/lending/anchor/programs/lending", + "finance/options/anchor/programs/options", "finance/order-book/anchor/programs/order-book", "finance/perpetual-futures/anchor/programs/perpetual-futures", "finance/prop-amm/anchor/programs/prop-amm", diff --git a/README.md b/README.md index abbe8046..f44bce68 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,12 @@ Parimutuel (pooled) prediction market - an admin opens an event with multiple ou ๐ŸŽฌ Video: [How to build a PolyMarket/Kalshi style betting market on Solana](https://www.youtube.com/watch?v=jE3-IA1FBs0) +### Options + +A fully collateralized, physically settled **options** venue. A writer posts the whole of what a holder could ever claim (the underlying for a **call**, the strike in USDC for a **put**) and lists the option at a premium; a buyer pays the premium and becomes the holder; the holder may **exercise** at any time before expiry, paying the strike for the underlying (or delivering the underlying for the strike); after expiry the writer reclaims whatever was not exercised. Because the collateral is always in the vault, no position can be under water, so there is no margin, no liquidator, and no oracle. The venue earns a fee on every premium. + +[โš“ Anchor v2](./finance/options/anchor) [โš“ Anchor v1](./finance/options/anchor-v1) [๐Ÿ’ซ Quasar](./finance/options/quasar) + ### Perpetual Futures A perpetual futures exchange: a venue for making leveraged bets on an asset's price without ever owning the asset. Traders post collateral and open a **long** (betting the price rises) or **short** (betting it falls) sized up to several times their collateral; their profit or loss tracks the price move and is paid in the collateral token. Rather than matching buyers to sellers, every trade is against a shared **liquidity pool** that other users fund and that is the counterparty to all of it: the pool pays winners and keeps losers' collateral, and its providers earn the trading and funding fees in return. The price comes from an oracle, positions accrue a funding fee over time, and anyone can **liquidate** a position whose collateral can no longer cover its loss. This is the design behind venues like Jupiter Perpetuals and GMX. diff --git a/finance/options/anchor-v1/.gitignore b/finance/options/anchor-v1/.gitignore new file mode 100644 index 00000000..be06d3aa --- /dev/null +++ b/finance/options/anchor-v1/.gitignore @@ -0,0 +1,6 @@ +.anchor +target +**/*.rs.bk +node_modules +test-ledger +.DS_Store diff --git a/finance/options/anchor-v1/Anchor.toml b/finance/options/anchor-v1/Anchor.toml new file mode 100644 index 00000000..41e28d52 --- /dev/null +++ b/finance/options/anchor-v1/Anchor.toml @@ -0,0 +1,25 @@ +[toolchain] +# Match the repo package manager (pnpm-lock.yaml at root); avoids Anchor's yarn default. +package_manager = "pnpm" +solana_version = "3.1.8" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +options = "2gmMGMmipfYypLxWsvQ5GQJT5AGnMWmk4Rb9vQMRo6ig" + +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "cargo test" + +# Non-default: the LiteSVM Rust tests load the program's .so file, so it must +# be built before `cargo test` runs. CI calls `anchor build` first; these waits +# only matter for the legacy validator path. +[test] +startup_wait = 5000 +shutdown_wait = 2000 diff --git a/finance/options/anchor-v1/CHANGELOG.md b/finance/options/anchor-v1/CHANGELOG.md new file mode 100644 index 00000000..7f9a4c8e --- /dev/null +++ b/finance/options/anchor-v1/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 2026-09-04 + +Initial version: a fully collateralized, physically settled options venue. +A writer posts the whole obligation (the underlying for a call, the strike +in the quote token for a put) and lists an option at a premium; a buyer pays the +premium and becomes the holder; the holder may exercise before expiry; after +expiry the writer reclaims the collateral. Eight instruction handlers +(`initialize_market`, `write_option`, `buy_option`, `cancel_option`, +`exercise_option`, `collect_proceeds`, `reclaim_collateral`, +`collect_fees`), a custody ledger on the market account asserted after every +transfer, and a LiteSVM suite covering both kinds and all three exits. diff --git a/finance/options/anchor-v1/Cargo.toml b/finance/options/anchor-v1/Cargo.toml new file mode 100644 index 00000000..f3977048 --- /dev/null +++ b/finance/options/anchor-v1/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/finance/options/anchor-v1/README.md b/finance/options/anchor-v1/README.md new file mode 100644 index 00000000..0ad86a4f --- /dev/null +++ b/finance/options/anchor-v1/README.md @@ -0,0 +1,217 @@ +# Solana Options (Anchor) + +> [!NOTE] +> This is the **Anchor v1** copy of this example, kept for programs staying on the +> Anchor v1 LTS line. Every `anchor` command on this page needs the v1 CLI: +> `avm install 1.1.2 && avm use 1.1.2`. The Anchor v2 version of this example is in +> [`../anchor`](../anchor/). + +A Solana options venue is a program that lets one user sell another the right, +but not the obligation, to buy or sell an asset at a fixed price before a fixed +date. This one is **fully collateralized** and **physically settled**: the +writer of a call posts the whole of the underlying, the writer of a put posts +the whole of the strike in the quote token, a buyer pays a premium for the +right, and if the holder exercises before expiry the tokens themselves change +hands at the strike. Because everything a holder could ever claim is in the +vault from the moment the option exists, no position can be under water, so +there is no margin, no liquidator, and no oracle. The venue that took the +other road on Solana, cash settlement with margin and an oracle, is Zeta +Markets. + +[โš“ Anchor v2](../anchor) ยท [โš“ Anchor v1](.) ยท [๐Ÿ’ซ Quasar](../quasar) ยท [Kani proofs](../kani-proofs) + +## Programs + +- **`options`**: the venue. One admin, one underlying/quote pair, two vaults, + one account per option, eight instruction handlers. + +There is no mock oracle program, because nothing in the venue reads a price. + +## Key financial concepts + +### A call, a put, a strike, a premium, an expiry + +An **option** is a contract with five terms. Its **kind** is a **call** (the +right to buy the underlying) or a **put** (the right to sell it). Its +**strike** is the price the trade happens at if the holder exercises. Its +**expiry** is the last moment the holder can exercise. Its **premium** is what +the buyer pays the writer for the right, up front, and keeps paying nothing +after. The person who sells the right is the **writer**; the person who holds +it is the **holder**. + +The two sides have different shapes. The holder's loss is capped at the +premium, whatever happens. The writer keeps the premium whatever happens, and +in exchange takes on an obligation: to sell the underlying at the strike (a +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 +undercollateralized, which is why the program has no health check, no +liquidation, and no need to know the price. + +### Physical settlement needs no oracle + +When a call holder exercises, they pay the strike into the vault and take the +underlying out; a put holder delivers the underlying and takes the strike. The +tokens move; no price is computed. Whether exercising is worth it is the +holder's decision, made against whatever the market is doing offchain, and a +holder who exercises an out-of-the-money option only hurts themselves. The +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 + +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. + +### Expiry is one comparison and its complement + +The holder may exercise while `now < expiry`. The writer may reclaim the +collateral once `now >= expiry`. Those two conditions partition time, so there +is no instant at which both parties can claim the same collateral and none at +which neither can. Expiry is a unix timestamp because an option's expiry is a +calendar date the two parties agreed on, the same reason the fundraiser's +deadline is one. + +## Program flow + +### Participants + +- **Maria** operates the venue and earns 1% of every premium. +- **Alice** holds 5 NVDAx (tokenized NVIDIA stock, 6 decimals) she would be + happy to sell at $180, and wants to be paid while she waits. +- **Bob** thinks NVIDIA will rally past $180 within the week and wants that + upside for less than the price of 5 shares. +- **Carol** would like to own NVDAx at $150 and wants to be paid for + standing ready to buy. +- **Dave** holds 5 NVDAx and wants insurance against a fall below $150. + +Everyone starts with the standard wallet of one SOL and 1,000 USDC, plus the +NVDAx the story hands them. NVDAx is trading around $165 offchain. + +### Step 1: Maria opens the venue + +`initialize_market(fee_bps = 100)` creates the `Market` account (a PDA of the +two mints), a dataless vault-authority PDA, and the two vaults. Maria's key is +recorded as `admin`: it can sweep fees and do nothing else. + +### Step 2: Alice writes 5 covered calls + +`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 +`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. + +### Step 3: Bob buys the option + +`buy_option` takes 25 USDC from Bob: 0.25 USDC (the 1% fee) into the quote +vault, owed to Maria, and 24.75 USDC straight to Alice. The 5 NVDAx do not +move. The option's `holder` is now Bob and its status `Held`. Bob's downside +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 +`Exercised`, and the 900 USDC sits in the vault owed to Alice. + +### Step 5: Alice collects the strike + +`collect_proceeds` pays Alice the 900 USDC and closes the option, rent +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 + +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. + +### Step 7: The week passes above $150, and Carol reclaims her collateral + +Dave never exercises: selling at 150 when the market pays more would be a +gift. After the expiry, Carol's `reclaim_collateral` returns her 750 USDC and +closes the option. Her return is the 19.80 USDC premium; Dave's +insurance cost him 20 USDC and paid nothing, which is what insurance against a +fall that never came should do. + +### Step 8: Maria sweeps the fees + +`collect_fees` pays Maria the 0.45 USDC of accumulated fees. The vaults are +empty: every token that entered has left to the party it was owed to. + +Where everyone ended up: Alice earned a premium and sold her shares at her +price; Bob turned 25 USDC of premium into 5 NVDAx at a $20 discount to the +market; Carol was paid to wait for a purchase that never came; Dave bought +insurance he did not need; Maria earned 1% of every premium. + +## Custody + +The two vaults hold other people's money, so the `Market` account keeps a +ledger of what each vault owes: `underlying_locked` (call collateral, plus put +holders' deliveries awaiting collection), `quote_locked` (put collateral, plus +call holders' strike payments awaiting collection) and `fees_owed`. Every +handler that moves tokens updates the ledger before any transfer and then +asserts that each vault still covers what it owes (`CustodyInvariantViolated` +otherwise). The [Kani proofs](../kani-proofs) walk every path through an option's +life and show the ledger returns to zero. + +## Design notes and further reading + +- Some venues represent each option as two SPL tokens, an option token and a + writer token, so options can trade on any exchange and one writer's option can + be exercised in parts by many holders. This example keeps one account per + option, bought and exercised as a whole, which keeps the custody legible and + the state machine three states long. Adding secondary trading means + introducing those tokens. +- Cash-settled venues (Zeta Markets) let a writer post less than the full + obligation, which is what makes them capital-efficient and also what makes + them need margin, liquidation, and an oracle. The perpetual-futures example + in this repository has all three. + +## Limitations + +- An option is bought and exercised as a whole; there is no partial exercise and + no secondary sale of a held option. +- The writer sets the premium and a buyer takes it or leaves it. There is no + order book and no pricing model; a market maker would quote premiums from + a model offchain and write options at those prices. +- American exercise only. A European option, exercisable only at expiry, + would add an exercise window after `expiry` and a gap before it. + +## Setup + +```bash +anchor build +``` + +## Testing + +```bash +anchor build +cargo test +``` + +The LiteSVM suite (`programs/options/tests/test_options.rs`) walks the call +from write to collected strike and the put from write to exercise and to +expiry, pins every balance to the minor unit, checks the custody ledger +against the vault balances after every step, and proves every gate shuts: +the expiry boundary from both sides, cancel after sale, buy after sale or +expiry, exercise by a non-holder, collection by a non-writer or before +exercise, reclaim after exercise, fee collection by a non-admin, and the +parameter checks at write time. diff --git a/finance/options/anchor-v1/programs/options/Cargo.toml b/finance/options/anchor-v1/programs/options/Cargo.toml new file mode 100644 index 00000000..ce940bfa --- /dev/null +++ b/finance/options/anchor-v1/programs/options/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "options" +version = "0.1.0" +description = "Fully collateralized, physically settled options venue example" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "options" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +# init-if-needed: exercise_option and collect_proceeds create the receiving +# party's token account for the asset they are about to be paid in, so a call +# holder who has never held the underlying needs no separate setup transaction. +# The receiving party is always the payer, so nobody funds another's rent. +anchor-lang = { version = "1.1.2", features = ["init-if-needed"] } +anchor-spl = "1.1.2" +# Declared only so Cargo feature-unification turns on `no-entrypoint`; without +# these the test binary links two `entrypoint` symbols and fails to build. +spl-token = { version = "9.0.0", features = ["no-entrypoint"] } +spl-associated-token-account = { version = "8.0.0", features = ["no-entrypoint"] } + +[dev-dependencies] +litesvm = "0.13.1" +solana-signer = "3.0.0" +solana-keypair = "3.0.1" +solana-kite = "0.4.0" +borsh = "1.6.1" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/finance/options/anchor-v1/programs/options/src/constants.rs b/finance/options/anchor-v1/programs/options/src/constants.rs new file mode 100644 index 00000000..407b392c --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/constants.rs @@ -0,0 +1,21 @@ +use anchor_lang::prelude::*; + +/// Basis-point denominator: 100% = 10_000 bps. The venue's fee on each premium +/// is expressed in basis points and divided by this. +#[constant] +pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; + +#[constant] +pub const MARKET_SEED: &[u8] = b"market"; + +#[constant] +pub const AUTHORITY_SEED: &[u8] = b"authority"; + +#[constant] +pub const UNDERLYING_VAULT_SEED: &[u8] = b"underlying_vault"; + +#[constant] +pub const QUOTE_VAULT_SEED: &[u8] = b"quote_vault"; + +#[constant] +pub const OPTION_SEED: &[u8] = b"option"; diff --git a/finance/options/anchor-v1/programs/options/src/contract_math.rs b/finance/options/anchor-v1/programs/options/src/contract_math.rs new file mode 100644 index 00000000..aa56a958 --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/contract_math.rs @@ -0,0 +1,80 @@ +//! The pure arithmetic 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`. + +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 { + match kind { + OptionKind::Call => underlying_total(contracts, underlying_per_contract), + OptionKind::Put => strike_total(contracts, strike_per_contract), + } +} + +/// 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 { + match kind { + OptionKind::Call => strike_total(contracts, strike_per_contract), + OptionKind::Put => underlying_total(contracts, underlying_per_contract), + } +} + +/// Split a premium into the venue's fee and the writer's share. The fee +/// floors, so the writer receives the rounding minor unit; the venue gives up +/// at most one minor unit per sale, and a sale needs a real premium, so the +/// leak cannot be industrialized. +pub fn split_premium(premium: u64, fee_bps: u16) -> Option<(u64, u64)> { + let fee = (premium as u128) + .checked_mul(fee_bps as u128)? + .checked_div(BASIS_POINTS)?; + let fee = u64::try_from(fee).ok()?; + let to_writer = premium.checked_sub(fee)?; + Some((fee, to_writer)) +} + +/// The holder may exercise while the option has not expired. +pub fn may_exercise(now: i64, expiry: i64) -> bool { + now < expiry +} + +/// The writer may reclaim collateral once the option has expired: the exact +/// complement of `may_exercise`, so there is no instant at which both the +/// holder and the writer can claim the same collateral, and none at which +/// neither can. +pub fn may_reclaim(now: i64, expiry: i64) -> bool { + now >= expiry +} diff --git a/finance/options/anchor-v1/programs/options/src/errors.rs b/finance/options/anchor-v1/programs/options/src/errors.rs new file mode 100644 index 00000000..2f2b4655 --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/errors.rs @@ -0,0 +1,34 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum OptionsError { + #[msg("Market or option parameter is outside the allowed range")] + InvalidParameter, + + #[msg("Option expiry must be in the future")] + ExpiryInPast, + + #[msg("Arithmetic overflow")] + MathOverflow, + + #[msg("Option is not listed for sale: it has been sold or exercised")] + OptionNotListed, + + #[msg("Option has expired: it can no longer be bought or exercised")] + OptionExpired, + + #[msg("Option has no holder: it is unsold or already exercised")] + OptionNotHeld, + + #[msg("Option has not expired: the holder may still exercise it")] + OptionNotExpired, + + #[msg("Option has not been exercised: there are no proceeds to collect")] + OptionNotExercised, + + #[msg("No fees are available to collect")] + NothingToCollect, + + #[msg("Vault balance would fall below what the market owes")] + CustodyInvariantViolated, +} diff --git a/finance/options/anchor-v1/programs/options/src/instructions/buy_option.rs b/finance/options/anchor-v1/programs/options/src/instructions/buy_option.rs new file mode 100644 index 00000000..261c7e18 --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/instructions/buy_option.rs @@ -0,0 +1,125 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::constants::{MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED}; +use crate::contract_math; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_signer}; +use crate::state::{Market, OptionContract, OptionStatus}; + +/// Buy a listed option. The premium is the only money that changes hands: the +/// venue's fee comes out of it into the quote vault, and the rest goes +/// straight to the writer, whose money it is from this moment whatever the +/// holder later does. The collateral does not move. +pub fn handle_buy_option(context: Context) -> Result<()> { + let option = &mut context.accounts.option; + require!( + option.status == OptionStatus::Listed, + OptionsError::OptionNotListed + ); + // An option nobody can exercise any more is not for sale. + let now = Clock::get()?.unix_timestamp; + require!( + contract_math::may_exercise(now, option.expiry), + OptionsError::OptionExpired + ); + + let market = &mut context.accounts.market; + let (fee, to_writer) = contract_math::split_premium(option.premium, market.fee_bps) + .ok_or(OptionsError::MathOverflow)?; + + // Effects before the transfers. + option.holder = context.accounts.buyer.key(); + option.status = OptionStatus::Held; + market.fees_owed = market + .fees_owed + .checked_add(fee) + .ok_or(OptionsError::MathOverflow)?; + let quote_after = context + .accounts + .quote_vault + .amount + .checked_add(fee) + .ok_or(OptionsError::MathOverflow)?; + check_custody(market, market.underlying_locked, quote_after)?; + + transfer_from_signer( + &context.accounts.token_program, + &mut context.accounts.buyer_quote, + &context.accounts.quote_mint, + &mut context.accounts.writer_quote, + &context.accounts.buyer, + to_writer, + )?; + if fee > 0 { + transfer_from_signer( + &context.accounts.token_program, + &mut context.accounts.buyer_quote, + &context.accounts.quote_mint, + &mut context.accounts.quote_vault, + &context.accounts.buyer, + fee, + )?; + } + + Ok(()) +} + +#[derive(Accounts)] +pub struct BuyOptionAccountConstraints<'info> { + #[account(mut)] + pub buyer: Signer<'info>, + + /// CHECK: the writer, bound by `address = option.writer`; only used to + /// derive the token account the premium is paid into. + #[account(address = option.writer)] + pub writer: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + address = option.market, + )] + pub market: Box>, + + #[account( + mut, + seeds = [OPTION_SEED, market.key().as_ref(), writer.key().as_ref(), option.id.to_le_bytes().as_ref()], + bump = option.bump, + )] + pub option: Box>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = buyer, + associated_token::token_program = token_program, + )] + pub buyer_quote: Box>, + + // Created by `write_option`, at the writer's expense, so the buyer never + // pays rent on the writer's behalf. A writer buying their own option would + // put this account and `buyer_quote` in two mutable slots at once, which + // the loader rejects, so a writer cannot pay themselves a premium. + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_quote: Box>, + + pub token_program: Interface<'info, TokenInterface>, +} 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 new file mode 100644 index 00000000..2c8ce543 --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/instructions/cancel_option.rs @@ -0,0 +1,146 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::constants::{ + AUTHORITY_SEED, MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED, +}; +use crate::contract_math; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_vault}; +use crate::state::{Market, OptionContract, OptionKind, OptionStatus}; + +/// Withdraw an unsold option. Without this, an option nobody buys would hold the +/// writer's collateral forever. Any time is fine, including after expiry: an +/// unsold option has no holder whose rights could be cut short. +pub fn handle_cancel_option(context: Context) -> Result<()> { + let option = &context.accounts.option; + require!( + option.status == OptionStatus::Listed, + OptionsError::OptionNotListed + ); + + let collateral = contract_math::collateral_amount( + option.kind, + option.contracts, + option.underlying_per_contract, + option.strike_per_contract, + ) + .ok_or(OptionsError::MathOverflow)?; + let kind = option.kind; + + let market = &mut context.accounts.market; + let mut underlying_after = context.accounts.underlying_vault.amount; + let mut quote_after = context.accounts.quote_vault.amount; + match kind { + OptionKind::Call => { + market.underlying_locked = market + .underlying_locked + .checked_sub(collateral) + .ok_or(OptionsError::MathOverflow)?; + underlying_after = underlying_after + .checked_sub(collateral) + .ok_or(OptionsError::CustodyInvariantViolated)?; + } + OptionKind::Put => { + market.quote_locked = market + .quote_locked + .checked_sub(collateral) + .ok_or(OptionsError::MathOverflow)?; + quote_after = quote_after + .checked_sub(collateral) + .ok_or(OptionsError::CustodyInvariantViolated)?; + } + } + check_custody(market, underlying_after, quote_after)?; + + match kind { + OptionKind::Call => transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.underlying_vault, + &context.accounts.underlying_mint, + &mut context.accounts.writer_underlying, + &context.accounts.market_authority, + market, + collateral, + ), + OptionKind::Put => transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.quote_vault, + &context.accounts.quote_mint, + &mut context.accounts.writer_quote, + &context.accounts.market_authority, + market, + collateral, + ), + } + // The option closes to the writer through `close = writer`. +} + +#[derive(Accounts)] +pub struct CancelOptionAccountConstraints<'info> { + #[account(mut, address = option.writer)] + pub writer: Signer<'info>, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + address = option.market, + )] + pub market: Box>, + + #[account( + mut, + close = writer, + seeds = [OPTION_SEED, market.key().as_ref(), writer.key().as_ref(), option.id.to_le_bytes().as_ref()], + bump = option.bump, + )] + pub option: Box>, + + /// CHECK: PDA authority over both vaults; holds no data, only signs. + #[account( + seeds = [AUTHORITY_SEED, market.key().as_ref()], + bump = market.authority_bump, + )] + pub market_authority: UncheckedAccount<'info>, + + #[account(address = market.underlying_mint)] + pub underlying_mint: Box>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + #[account( + mut, + seeds = [UNDERLYING_VAULT_SEED, market.key().as_ref()], + bump, + address = market.underlying_vault, + )] + pub underlying_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + #[account( + mut, + associated_token::mint = underlying_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_underlying: Box>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_quote: Box>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/finance/options/anchor-v1/programs/options/src/instructions/collect_fees.rs b/finance/options/anchor-v1/programs/options/src/instructions/collect_fees.rs new file mode 100644 index 00000000..a1d91f99 --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/instructions/collect_fees.rs @@ -0,0 +1,91 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::constants::{AUTHORITY_SEED, MARKET_SEED, QUOTE_VAULT_SEED}; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_vault}; +use crate::state::Market; + +/// The admin sweeps the fees the venue has earned on premiums. `fees_owed` +/// is the only part of the quote vault the admin can reach: the collateral +/// and strike payments beside it are locked to their writers and holders. +pub fn handle_collect_fees(context: Context) -> Result<()> { + let market = &mut context.accounts.market; + let amount = market.fees_owed; + require!(amount > 0, OptionsError::NothingToCollect); + + // Effects before the transfer: zero the balance, then pay it out. + market.fees_owed = 0; + let quote_after = context + .accounts + .quote_vault + .amount + .checked_sub(amount) + .ok_or(OptionsError::CustodyInvariantViolated)?; + check_custody( + market, + context.accounts.underlying_vault.amount, + quote_after, + )?; + + transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.quote_vault, + &context.accounts.quote_mint, + &mut context.accounts.admin_quote, + &context.accounts.market_authority, + market, + amount, + ) +} + +#[derive(Accounts)] +pub struct CollectFeesAccountConstraints<'info> { + #[account(mut, address = market.admin)] + pub admin: Signer<'info>, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + )] + pub market: Box>, + + /// CHECK: PDA authority over both vaults; holds no data, only signs. + #[account( + seeds = [AUTHORITY_SEED, market.key().as_ref()], + bump = market.authority_bump, + )] + pub market_authority: UncheckedAccount<'info>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + // Read only, for the custody check: the invariant covers both vaults. + #[account(address = market.underlying_vault)] + pub underlying_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + #[account( + init_if_needed, + payer = admin, + associated_token::mint = quote_mint, + associated_token::authority = admin, + associated_token::token_program = token_program, + )] + pub admin_quote: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} 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 new file mode 100644 index 00000000..47ba6f0b --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/instructions/collect_proceeds.rs @@ -0,0 +1,156 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::constants::{ + AUTHORITY_SEED, MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED, +}; +use crate::contract_math; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_vault}; +use crate::state::{Market, OptionContract, OptionKind, OptionStatus}; + +/// The writer collects what the holder paid at exercise: the strike for a +/// call, the underlying for a put. The option closes, rent back to +/// the writer who paid it. +pub fn handle_collect_proceeds(context: Context) -> Result<()> { + let option = &context.accounts.option; + require!( + option.status == OptionStatus::Exercised, + OptionsError::OptionNotExercised + ); + + let kind = option.kind; + let proceeds = contract_math::exercise_payment( + kind, + option.contracts, + option.underlying_per_contract, + option.strike_per_contract, + ) + .ok_or(OptionsError::MathOverflow)?; + + let market = &mut context.accounts.market; + let mut underlying_after = context.accounts.underlying_vault.amount; + let mut quote_after = context.accounts.quote_vault.amount; + match kind { + // A call's proceeds are the strike, in the quote token. + OptionKind::Call => { + market.quote_locked = market + .quote_locked + .checked_sub(proceeds) + .ok_or(OptionsError::MathOverflow)?; + quote_after = quote_after + .checked_sub(proceeds) + .ok_or(OptionsError::CustodyInvariantViolated)?; + } + // A put's proceeds are the delivered underlying. + OptionKind::Put => { + market.underlying_locked = market + .underlying_locked + .checked_sub(proceeds) + .ok_or(OptionsError::MathOverflow)?; + underlying_after = underlying_after + .checked_sub(proceeds) + .ok_or(OptionsError::CustodyInvariantViolated)?; + } + } + check_custody(market, underlying_after, quote_after)?; + + match kind { + OptionKind::Call => transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.quote_vault, + &context.accounts.quote_mint, + &mut context.accounts.writer_quote, + &context.accounts.market_authority, + market, + proceeds, + ), + OptionKind::Put => transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.underlying_vault, + &context.accounts.underlying_mint, + &mut context.accounts.writer_underlying, + &context.accounts.market_authority, + market, + proceeds, + ), + } + // The option closes to the writer through `close = writer`. +} + +#[derive(Accounts)] +pub struct CollectProceedsAccountConstraints<'info> { + #[account(mut, address = option.writer)] + pub writer: Signer<'info>, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + address = option.market, + )] + pub market: Box>, + + #[account( + mut, + close = writer, + seeds = [OPTION_SEED, market.key().as_ref(), writer.key().as_ref(), option.id.to_le_bytes().as_ref()], + bump = option.bump, + )] + pub option: Box>, + + /// CHECK: PDA authority over both vaults; holds no data, only signs. + #[account( + seeds = [AUTHORITY_SEED, market.key().as_ref()], + bump = market.authority_bump, + )] + pub market_authority: UncheckedAccount<'info>, + + #[account(address = market.underlying_mint)] + pub underlying_mint: Box>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + #[account( + mut, + seeds = [UNDERLYING_VAULT_SEED, market.key().as_ref()], + bump, + address = market.underlying_vault, + )] + pub underlying_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + // A put writer is paid in the underlying, which they may never have held, + // so the account is created if needed, at the writer's expense. + #[account( + init_if_needed, + payer = writer, + associated_token::mint = underlying_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_underlying: Box>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_quote: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} diff --git a/finance/options/anchor-v1/programs/options/src/instructions/exercise_option.rs b/finance/options/anchor-v1/programs/options/src/instructions/exercise_option.rs new file mode 100644 index 00000000..bd7ec00b --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/instructions/exercise_option.rs @@ -0,0 +1,211 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::constants::{ + AUTHORITY_SEED, MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED, +}; +use crate::contract_math; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_signer, transfer_from_vault}; +use crate::state::{Market, OptionContract, OptionKind, OptionStatus}; + +/// Exercise a held option before expiry. A call holder pays the strike in the +/// quote token and takes the underlying; a put holder delivers the underlying +/// and takes the strike. The payment stays in the vault, owed to the writer, +/// until they call `collect_proceeds`; paying the writer directly would let +/// a writer who closed their token account block the exercise. +/// +/// No price is read. Whether exercising is worth it is the holder's decision, +/// made against whatever the market is doing offchain; the program only +/// enforces the terms. +pub fn handle_exercise_option(context: Context) -> Result<()> { + let option = &mut context.accounts.option; + require!( + option.status == OptionStatus::Held, + OptionsError::OptionNotHeld + ); + // The holder may exercise while now < expiry. + let now = Clock::get()?.unix_timestamp; + require!( + contract_math::may_exercise(now, option.expiry), + OptionsError::OptionExpired + ); + + 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)?; + + // Effects: the option is exercised, and the vault now owes the writer the + // payment instead of owing the holder the collateral. + option.status = OptionStatus::Exercised; + + let market = &mut context.accounts.market; + let underlying_before = context.accounts.underlying_vault.amount; + let quote_before = context.accounts.quote_vault.amount; + let (underlying_after, quote_after) = match kind { + OptionKind::Call => { + market.underlying_locked = market + .underlying_locked + .checked_sub(underlying_total) + .ok_or(OptionsError::MathOverflow)?; + market.quote_locked = market + .quote_locked + .checked_add(strike_total) + .ok_or(OptionsError::MathOverflow)?; + ( + underlying_before + .checked_sub(underlying_total) + .ok_or(OptionsError::CustodyInvariantViolated)?, + quote_before + .checked_add(strike_total) + .ok_or(OptionsError::MathOverflow)?, + ) + } + OptionKind::Put => { + market.quote_locked = market + .quote_locked + .checked_sub(strike_total) + .ok_or(OptionsError::MathOverflow)?; + market.underlying_locked = market + .underlying_locked + .checked_add(underlying_total) + .ok_or(OptionsError::MathOverflow)?; + ( + underlying_before + .checked_add(underlying_total) + .ok_or(OptionsError::MathOverflow)?, + quote_before + .checked_sub(strike_total) + .ok_or(OptionsError::CustodyInvariantViolated)?, + ) + } + }; + check_custody(market, underlying_after, quote_after)?; + + // The holder pays in, then the vault pays out, atomically or not at all. + match kind { + OptionKind::Call => { + transfer_from_signer( + &context.accounts.token_program, + &mut context.accounts.holder_quote, + &context.accounts.quote_mint, + &mut context.accounts.quote_vault, + &context.accounts.holder, + strike_total, + )?; + transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.underlying_vault, + &context.accounts.underlying_mint, + &mut context.accounts.holder_underlying, + &context.accounts.market_authority, + market, + underlying_total, + ) + } + OptionKind::Put => { + transfer_from_signer( + &context.accounts.token_program, + &mut context.accounts.holder_underlying, + &context.accounts.underlying_mint, + &mut context.accounts.underlying_vault, + &context.accounts.holder, + underlying_total, + )?; + transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.quote_vault, + &context.accounts.quote_mint, + &mut context.accounts.holder_quote, + &context.accounts.market_authority, + market, + strike_total, + ) + } + } +} + +#[derive(Accounts)] +pub struct ExerciseOptionAccountConstraints<'info> { + #[account(mut, address = option.holder)] + pub holder: Signer<'info>, + + /// CHECK: the writer, bound by `address = option.writer`; a seed of the + /// option PDA. + #[account(address = option.writer)] + pub writer: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + address = option.market, + )] + pub market: Box>, + + #[account( + mut, + seeds = [OPTION_SEED, market.key().as_ref(), writer.key().as_ref(), option.id.to_le_bytes().as_ref()], + bump = option.bump, + )] + pub option: Box>, + + /// CHECK: PDA authority over both vaults; holds no data, only signs. + #[account( + seeds = [AUTHORITY_SEED, market.key().as_ref()], + bump = market.authority_bump, + )] + pub market_authority: UncheckedAccount<'info>, + + #[account(address = market.underlying_mint)] + pub underlying_mint: Box>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + #[account( + mut, + seeds = [UNDERLYING_VAULT_SEED, market.key().as_ref()], + bump, + address = market.underlying_vault, + )] + pub underlying_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + // A call holder receives into this account and may never have held the + // underlying before, so it is created if needed, at the holder's expense. + #[account( + init_if_needed, + payer = holder, + associated_token::mint = underlying_mint, + associated_token::authority = holder, + associated_token::token_program = token_program, + )] + pub holder_underlying: Box>, + + #[account( + init_if_needed, + payer = holder, + associated_token::mint = quote_mint, + associated_token::authority = holder, + associated_token::token_program = token_program, + )] + pub holder_quote: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} diff --git a/finance/options/anchor-v1/programs/options/src/instructions/initialize_market.rs b/finance/options/anchor-v1/programs/options/src/instructions/initialize_market.rs new file mode 100644 index 00000000..d0e022e1 --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/instructions/initialize_market.rs @@ -0,0 +1,95 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::constants::{ + AUTHORITY_SEED, BASIS_POINTS_DENOMINATOR, MARKET_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED, +}; +use crate::errors::OptionsError; +use crate::state::Market; + +pub fn handle_initialize_market( + context: Context, + fee_bps: u16, +) -> Result<()> { + // Options on a token settled in the same token are meaningless. + require_keys_neq!( + context.accounts.underlying_mint.key(), + context.accounts.quote_mint.key(), + OptionsError::InvalidParameter + ); + // A fee of 100% or more would leave the writer nothing from a sale. Zero + // is allowed: a venue run at cost is a valid choice. + require!( + fee_bps < BASIS_POINTS_DENOMINATOR as u16, + OptionsError::InvalidParameter + ); + + let market = &mut context.accounts.market; + market.admin = context.accounts.admin.key(); + market.underlying_mint = context.accounts.underlying_mint.key(); + market.quote_mint = context.accounts.quote_mint.key(); + market.underlying_vault = context.accounts.underlying_vault.key(); + market.quote_vault = context.accounts.quote_vault.key(); + market.underlying_locked = 0; + market.quote_locked = 0; + market.fees_owed = 0; + market.fee_bps = fee_bps; + market.bump = context.bumps.market; + market.authority_bump = context.bumps.market_authority; + + Ok(()) +} + +#[derive(Accounts)] +pub struct InitializeMarketAccountConstraints<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + // One venue per pair, so every option on NVDAx settled in USDC shares the + // two vaults and the one fee schedule. + #[account( + init, + payer = admin, + space = Market::DISCRIMINATOR.len() + Market::INIT_SPACE, + seeds = [MARKET_SEED, underlying_mint.key().as_ref(), quote_mint.key().as_ref()], + bump, + )] + pub market: Box>, + + pub underlying_mint: Box>, + + pub quote_mint: Box>, + + /// CHECK: PDA that owns both vaults. Holds no data; used only to sign + /// vault CPIs. + #[account( + seeds = [AUTHORITY_SEED, market.key().as_ref()], + bump, + )] + pub market_authority: UncheckedAccount<'info>, + + #[account( + init, + payer = admin, + seeds = [UNDERLYING_VAULT_SEED, market.key().as_ref()], + bump, + token::mint = underlying_mint, + token::authority = market_authority, + token::token_program = token_program, + )] + pub underlying_vault: Box>, + + #[account( + init, + payer = admin, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + token::mint = quote_mint, + token::authority = market_authority, + token::token_program = token_program, + )] + pub quote_vault: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} diff --git a/finance/options/anchor-v1/programs/options/src/instructions/mod.rs b/finance/options/anchor-v1/programs/options/src/instructions/mod.rs new file mode 100644 index 00000000..f94ca097 --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/instructions/mod.rs @@ -0,0 +1,18 @@ +pub mod buy_option; +pub mod cancel_option; +pub mod collect_fees; +pub mod collect_proceeds; +pub mod exercise_option; +pub mod initialize_market; +pub mod reclaim_collateral; +pub mod shared; +pub mod write_option; + +pub use buy_option::*; +pub use cancel_option::*; +pub use collect_fees::*; +pub use collect_proceeds::*; +pub use exercise_option::*; +pub use initialize_market::*; +pub use reclaim_collateral::*; +pub use write_option::*; 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 new file mode 100644 index 00000000..0c7cb791 --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/instructions/reclaim_collateral.rs @@ -0,0 +1,155 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::constants::{ + AUTHORITY_SEED, MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED, +}; +use crate::contract_math; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_vault}; +use crate::state::{Market, OptionContract, OptionKind, OptionStatus}; + +/// The holder let the option expire, so the writer takes the collateral +/// back. The premium was the writer's the moment it was paid; this is the +/// other half of a writer's return. The option closes. +pub fn handle_reclaim_collateral( + context: Context, +) -> Result<()> { + let option = &context.accounts.option; + require!( + option.status == OptionStatus::Held, + OptionsError::OptionNotHeld + ); + // The writer may reclaim once now >= expiry: the complement of the + // holder's window, so the two can never both claim the collateral. + let now = Clock::get()?.unix_timestamp; + require!( + contract_math::may_reclaim(now, option.expiry), + OptionsError::OptionNotExpired + ); + + 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 market = &mut context.accounts.market; + let mut underlying_after = context.accounts.underlying_vault.amount; + let mut quote_after = context.accounts.quote_vault.amount; + match kind { + OptionKind::Call => { + market.underlying_locked = market + .underlying_locked + .checked_sub(collateral) + .ok_or(OptionsError::MathOverflow)?; + underlying_after = underlying_after + .checked_sub(collateral) + .ok_or(OptionsError::CustodyInvariantViolated)?; + } + OptionKind::Put => { + market.quote_locked = market + .quote_locked + .checked_sub(collateral) + .ok_or(OptionsError::MathOverflow)?; + quote_after = quote_after + .checked_sub(collateral) + .ok_or(OptionsError::CustodyInvariantViolated)?; + } + } + check_custody(market, underlying_after, quote_after)?; + + match kind { + OptionKind::Call => transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.underlying_vault, + &context.accounts.underlying_mint, + &mut context.accounts.writer_underlying, + &context.accounts.market_authority, + market, + collateral, + ), + OptionKind::Put => transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.quote_vault, + &context.accounts.quote_mint, + &mut context.accounts.writer_quote, + &context.accounts.market_authority, + market, + collateral, + ), + } + // The option closes to the writer through `close = writer`. +} + +#[derive(Accounts)] +pub struct ReclaimCollateralAccountConstraints<'info> { + #[account(mut, address = option.writer)] + pub writer: Signer<'info>, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + address = option.market, + )] + pub market: Box>, + + #[account( + mut, + close = writer, + seeds = [OPTION_SEED, market.key().as_ref(), writer.key().as_ref(), option.id.to_le_bytes().as_ref()], + bump = option.bump, + )] + pub option: Box>, + + /// CHECK: PDA authority over both vaults; holds no data, only signs. + #[account( + seeds = [AUTHORITY_SEED, market.key().as_ref()], + bump = market.authority_bump, + )] + pub market_authority: UncheckedAccount<'info>, + + #[account(address = market.underlying_mint)] + pub underlying_mint: Box>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + #[account( + mut, + seeds = [UNDERLYING_VAULT_SEED, market.key().as_ref()], + bump, + address = market.underlying_vault, + )] + pub underlying_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + #[account( + mut, + associated_token::mint = underlying_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_underlying: Box>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_quote: Box>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/finance/options/anchor-v1/programs/options/src/instructions/shared.rs b/finance/options/anchor-v1/programs/options/src/instructions/shared.rs new file mode 100644 index 00000000..2084e3bd --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/instructions/shared.rs @@ -0,0 +1,84 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::constants::AUTHORITY_SEED; +use crate::errors::OptionsError; +use crate::state::Market; + +/// The custody invariant, asserted after the math in every handler that moves +/// tokens: each vault covers what the market owes. `underlying_after` and +/// `quote_after` are the vault balances the handler's transfers will leave +/// behind, computed from the balances read before any CPI ran. +pub fn check_custody(market: &Market, underlying_after: u64, quote_after: u64) -> Result<()> { + require!( + underlying_after >= market.underlying_locked, + OptionsError::CustodyInvariantViolated + ); + let quote_owed = market + .quote_locked + .checked_add(market.fees_owed) + .ok_or(OptionsError::MathOverflow)?; + require!( + quote_after >= quote_owed, + OptionsError::CustodyInvariantViolated + ); + Ok(()) +} + +/// A signer-authorized transfer into one of the vaults, or from one party to +/// another (the premium goes straight from buyer to writer). +pub fn transfer_from_signer<'info>( + token_program: &Interface<'info, TokenInterface>, + from: &mut InterfaceAccount<'info, TokenAccount>, + mint: &InterfaceAccount<'info, Mint>, + to: &mut InterfaceAccount<'info, TokenAccount>, + signer: &Signer<'info>, + amount: u64, +) -> Result<()> { + transfer_checked( + CpiContext::new( + token_program.key(), + TransferChecked { + from: from.to_account_info(), + mint: mint.to_account_info(), + to: to.to_account_info(), + authority: signer.to_account_info(), + }, + ), + amount, + mint.decimals, + ) +} + +/// A transfer out of a vault, signed by the market's vault authority PDA. +/// Takes the market by reference for its address and authority bump, so the +/// caller must have finished mutating it (it has: effects come before CPIs). +pub fn transfer_from_vault<'info>( + token_program: &Interface<'info, TokenInterface>, + vault: &mut InterfaceAccount<'info, TokenAccount>, + mint: &InterfaceAccount<'info, Mint>, + to: &mut InterfaceAccount<'info, TokenAccount>, + market_authority: &UncheckedAccount<'info>, + market: &Account<'info, Market>, + amount: u64, +) -> Result<()> { + let market_key = market.key(); + let bump = [market.authority_bump]; + let authority_seeds: &[&[u8]] = &[AUTHORITY_SEED, market_key.as_ref(), &bump]; + transfer_checked( + CpiContext::new_with_signer( + token_program.key(), + TransferChecked { + from: vault.to_account_info(), + mint: mint.to_account_info(), + to: to.to_account_info(), + authority: market_authority.to_account_info(), + }, + &[authority_seeds], + ), + amount, + mint.decimals, + ) +} 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 new file mode 100644 index 00000000..9968345f --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs @@ -0,0 +1,209 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::constants::{MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED}; +use crate::contract_math; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_signer}; +use crate::state::{Market, OptionContract, OptionKind, OptionStatus}; + +/// The terms of an option, chosen by the writer. Bundled into one struct so the +/// instruction signature stays readable. +#[derive(Clone, Copy, AnchorSerialize, AnchorDeserialize)] +pub struct OptionTerms { + pub kind: OptionKind, + + /// 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, + + /// 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 the buyer pays the writer for the whole option. + pub premium: u64, + + /// Unix timestamp after which the holder can no longer exercise. + pub expiry: i64, +} + +/// Write an option. The writer posts the entire collateral up front: +/// the underlying for a call, the strike in the quote token for a put. From +/// this moment the vault holds everything a future holder could claim, which +/// is why nothing in this program ever has to be liquidated. +pub fn handle_write_option( + context: Context, + id: u64, + terms: OptionTerms, +) -> Result<()> { + let OptionTerms { + kind, + contracts, + underlying_per_contract, + strike_per_contract, + 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. + require!( + contracts > 0 && underlying_per_contract > 0 && strike_per_contract > 0 && premium > 0, + OptionsError::InvalidParameter + ); + // Written in words: the holder may exercise while now < expiry. An expiry + // at or before now would create an option nobody could ever exercise. + 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, + }; + + // Effects before the transfer: record the option and what the vault now owes. + let option = &mut context.accounts.option; + option.id = id; + option.market = context.accounts.market.key(); + option.writer = context.accounts.writer.key(); + 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.premium = premium; + option.expiry = expiry; + option.bump = context.bumps.option; + + let market = &mut context.accounts.market; + let mut underlying_after = context.accounts.underlying_vault.amount; + let mut quote_after = context.accounts.quote_vault.amount; + match kind { + OptionKind::Call => { + market.underlying_locked = market + .underlying_locked + .checked_add(collateral) + .ok_or(OptionsError::MathOverflow)?; + underlying_after = underlying_after + .checked_add(collateral) + .ok_or(OptionsError::MathOverflow)?; + } + OptionKind::Put => { + market.quote_locked = market + .quote_locked + .checked_add(collateral) + .ok_or(OptionsError::MathOverflow)?; + quote_after = quote_after + .checked_add(collateral) + .ok_or(OptionsError::MathOverflow)?; + } + } + check_custody(market, underlying_after, quote_after)?; + + match kind { + OptionKind::Call => transfer_from_signer( + &context.accounts.token_program, + &mut context.accounts.writer_underlying, + &context.accounts.underlying_mint, + &mut context.accounts.underlying_vault, + &context.accounts.writer, + collateral, + ), + OptionKind::Put => transfer_from_signer( + &context.accounts.token_program, + &mut context.accounts.writer_quote, + &context.accounts.quote_mint, + &mut context.accounts.quote_vault, + &context.accounts.writer, + collateral, + ), + } +} + +#[derive(Accounts)] +// The leading underscore is for rustc: `#[derive(Accounts)]` expands +// `_id` into a path that never reads it, so the plain name warns as +// unused. The `seeds` expression below is the real use. +#[instruction(_id: u64)] +pub struct WriteOptionAccountConstraints<'info> { + #[account(mut)] + pub writer: Signer<'info>, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + )] + pub market: Box>, + + #[account( + init, + payer = writer, + space = OptionContract::DISCRIMINATOR.len() + OptionContract::INIT_SPACE, + seeds = [OPTION_SEED, market.key().as_ref(), writer.key().as_ref(), _id.to_le_bytes().as_ref()], + bump, + )] + pub option: Box>, + + #[account(address = market.underlying_mint)] + pub underlying_mint: Box>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + #[account( + mut, + seeds = [UNDERLYING_VAULT_SEED, market.key().as_ref()], + bump, + address = market.underlying_vault, + )] + pub underlying_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + // A call writer pays collateral from this account; a put writer's copy + // is only validated. + #[account( + mut, + associated_token::mint = underlying_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_underlying: Box>, + + // A put writer pays collateral from this account, and every writer is + // paid their premium into it by `buy_option`, which requires it to exist. + // Created here if needed, paid for by the writer, so the party who chose + // to list carries the rent rather than the buyer. + #[account( + init_if_needed, + payer = writer, + associated_token::mint = quote_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_quote: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} diff --git a/finance/options/anchor-v1/programs/options/src/lib.rs b/finance/options/anchor-v1/programs/options/src/lib.rs new file mode 100644 index 00000000..1ed56f3b --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/lib.rs @@ -0,0 +1,85 @@ +use anchor_lang::prelude::*; + +mod constants; +mod errors; +// Public so the LiteSVM integration tests can build instruction arguments +// (`OptionTerms`, `OptionKind`) against the program's own types, and the +// proofs crate's README can point at the formulas it mirrors. +pub mod contract_math; +pub mod instructions; +pub mod state; + +use instructions::*; + +declare_id!("2gmMGMmipfYypLxWsvQ5GQJT5AGnMWmk4Rb9vQMRo6ig"); + +/// A fully collateralized, physically settled options venue. +/// +/// A writer posts the whole of what the holder could ever claim (the +/// underlying for a call, the strike in the quote token for a put) and names a +/// premium; a buyer pays the premium and becomes the holder; the holder may +/// exercise at any time before expiry, paying the other side of the trade and +/// taking the collateral; after expiry the writer reclaims whatever was not +/// exercised. Because the collateral is always in the vault, no position can +/// ever be under water, so there is no margin, no liquidator, and no oracle. +/// Each option is one account, bought and exercised as a whole. +#[program] +pub mod options { + use super::*; + + /// Create a venue for one underlying/quote pair. The signer becomes the + /// admin: the only party who can sweep the venue's fees, and a party who + /// can do nothing else. + pub fn initialize_market( + context: Context, + fee_bps: u16, + ) -> Result<()> { + instructions::handle_initialize_market(context, fee_bps) + } + + /// Write an option: post the full collateral and list it at + /// the premium in `terms`. `id` is chosen by the writer so they can have + /// many open. + pub fn write_option( + context: Context, + id: u64, + terms: OptionTerms, + ) -> Result<()> { + instructions::handle_write_option(context, id, terms) + } + + /// Buy a listed option: pay the premium (the venue's fee comes out of it, + /// the rest goes to the writer) and become the holder. + pub fn buy_option(context: Context) -> Result<()> { + instructions::handle_buy_option(context) + } + + /// Writer withdraws an unsold option: collateral back, account closed. + pub fn cancel_option(context: Context) -> Result<()> { + instructions::handle_cancel_option(context) + } + + /// Holder exercises before expiry: pays the other side of the trade into + /// the vault and takes the collateral. Physical settlement: the tokens + /// change hands, and no price feed is consulted. + pub fn exercise_option(context: Context) -> Result<()> { + instructions::handle_exercise_option(context) + } + + /// Writer collects what the holder paid at exercise, and the account + /// closes. + pub fn collect_proceeds(context: Context) -> Result<()> { + instructions::handle_collect_proceeds(context) + } + + /// Writer reclaims the collateral of a sold option the holder let expire, and + /// the account closes. The premium was theirs the moment it was paid. + pub fn reclaim_collateral(context: Context) -> Result<()> { + instructions::handle_reclaim_collateral(context) + } + + /// Admin sweeps the accumulated premium fees from the quote vault. + pub fn collect_fees(context: Context) -> Result<()> { + instructions::handle_collect_fees(context) + } +} diff --git a/finance/options/anchor-v1/programs/options/src/state/market.rs b/finance/options/anchor-v1/programs/options/src/state/market.rs new file mode 100644 index 00000000..8fc7397b --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/state/market.rs @@ -0,0 +1,49 @@ +use anchor_lang::prelude::*; + +/// One options venue: every option written here is on the same underlying +/// token, exercised in the same quote token, and collateralized in one of the +/// two vaults. +/// +/// The vaults hold other people's money (writers' collateral, and the strike +/// payments holders make at exercise), so the two `*_locked` fields say how +/// much of each vault the market owes and to whom it is owed in aggregate. +/// Every handler that moves tokens asserts, after its own arithmetic, that +/// each vault still covers what the market owes. +#[account] +#[derive(InitSpace)] +pub struct Market { + /// Operates the venue and collects the fee on every premium. Cannot touch + /// collateral or strike payments: no handler moves either to the admin. + pub admin: Pubkey, + + /// The asset the options are written on (NVDAx in the walkthrough). + pub underlying_mint: Pubkey, + + /// The asset premiums are paid in and strikes are settled in (USDC). + pub quote_mint: Pubkey, + + pub underlying_vault: Pubkey, + + pub quote_vault: Pubkey, + + /// Underlying minor units the vault owes: call writers' collateral, plus + /// put holders' deliveries awaiting the writer's `collect_proceeds`. + pub underlying_locked: u64, + + /// Quote minor units the vault owes: put writers' collateral, plus call + /// holders' strike payments awaiting the writer's `collect_proceeds`. + pub quote_locked: u64, + + /// Quote minor units held in the quote vault for the admin, accrued from + /// the fee on each premium and swept by `collect_fees`. + pub fees_owed: u64, + + /// Fee charged on each premium, in basis points. The venue's revenue. + pub fee_bps: u16, + + pub bump: u8, + + /// Bump for the vault authority PDA, stored so CPIs can sign without + /// re-deriving it. + pub authority_bump: u8, +} diff --git a/finance/options/anchor-v1/programs/options/src/state/mod.rs b/finance/options/anchor-v1/programs/options/src/state/mod.rs new file mode 100644 index 00000000..47cb590a --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod market; +pub mod option_contract; + +pub use market::*; +pub use option_contract::*; 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 new file mode 100644 index 00000000..623210b8 --- /dev/null +++ b/finance/options/anchor-v1/programs/options/src/state/option_contract.rs @@ -0,0 +1,76 @@ +use anchor_lang::prelude::*; + +/// Which right the holder buys. +#[derive(InitSpace, Clone, Copy, PartialEq, Eq, Debug, AnchorSerialize, AnchorDeserialize)] +pub enum OptionKind { + /// The right to buy the underlying at the strike. The writer's collateral + /// is the underlying itself, so the call is covered. + Call, + /// The right to sell the underlying at the strike. The writer's collateral + /// is the strike in the quote token, so the put is cash-secured. + Put, +} + +/// Where an option is in its life. Expiry is not a status: it is a comparison +/// of the clock against `expiry`, made by the handlers that care. +#[derive(InitSpace, Clone, Copy, PartialEq, Eq, Debug, AnchorSerialize, AnchorDeserialize)] +pub enum OptionStatus { + /// Written and collateralized, not yet sold. The writer may cancel. + Listed, + /// Sold. The holder may exercise before expiry; after expiry the writer + /// reclaims the collateral. + Held, + /// The holder has paid the strike and taken the collateral. The writer + /// collects the strike payment. + Exercised, +} + +/// One option: `contracts` identical contracts, 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). +#[account] +#[derive(InitSpace)] +pub struct OptionContract { + /// Chosen by the writer, so one writer can have many options open. + pub id: u64, + + pub market: Pubkey, + + pub writer: Pubkey, + + /// The buyer, once there is one. `Pubkey::default()` while `Listed`. + pub holder: Pubkey, + + pub kind: OptionKind, + + 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, + + /// 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 the buyer pays the writer for the whole option. + 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 + /// expiry is a calendar date the two parties agreed on, the same reason + /// the fundraiser's deadline is a timestamp; the program reads no oracle, + /// so slot-measured freshness never enters into it. + pub expiry: i64, + + pub bump: u8, +} diff --git a/finance/options/anchor-v1/programs/options/tests/test_options.rs b/finance/options/anchor-v1/programs/options/tests/test_options.rs new file mode 100644 index 00000000..0239e907 --- /dev/null +++ b/finance/options/anchor-v1/programs/options/tests/test_options.rs @@ -0,0 +1,1014 @@ +use { + anchor_lang::{ + solana_program::{clock::Clock, instruction::Instruction, pubkey::Pubkey, system_program}, + AccountDeserialize, InstructionData, ToAccountMetas, + }, + litesvm::LiteSVM, + options::{ + instructions::write_option::OptionTerms, + state::{Market as MarketState, OptionContract, OptionKind, OptionStatus}, + }, + solana_keypair::Keypair, + solana_kite::{ + create_associated_token_account, create_token_mint, create_wallet, + get_token_account_balance, mint_tokens_to_token_account, + send_transaction_from_instructions, + }, + solana_signer::Signer, +}; + +// Both tokens have 6 decimals: the underlying is NVDAx (tokenized NVIDIA +// stock) and the quote is USDC, so one whole unit of either is 1_000_000 +// minor units. +const ONE_TOKEN: u64 = 1_000_000; +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; +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; +const PUT_PREMIUM: u64 = 20 * ONE_TOKEN; + +const SECONDS_PER_DAY: i64 = 24 * 60 * 60; +const ONE_WEEK: i64 = 7 * SECONDS_PER_DAY; + +// Every character starts with the standard wallet of 1,000 USDC; the story +// hands the writers 5 NVDAx. +const STANDARD_USDC: u64 = 1_000 * ONE_TOKEN; +const FIVE_NVDAX: u64 = 5 * ONE_TOKEN; + +fn token_program_id() -> Pubkey { + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + .parse() + .unwrap() +} + +fn ata_program_id() -> Pubkey { + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + .parse() + .unwrap() +} + +fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[wallet.as_ref(), token_program_id().as_ref(), mint.as_ref()], + &ata_program_id(), + ) + .0 +} + +/// The walkthrough's call, expiring at `expiry`. +fn call_terms(expiry: i64) -> OptionTerms { + OptionTerms { + kind: OptionKind::Call, + contracts: CONTRACTS, + underlying_per_contract: ONE_NVDAX_PER_CONTRACT, + strike_per_contract: CALL_STRIKE, + premium: CALL_PREMIUM, + expiry, + } +} + +/// One deployed venue plus the keys needed to drive it. +struct Venue { + svm: LiteSVM, + payer: Keypair, + admin: Keypair, + underlying_mint: Pubkey, + quote_mint: Pubkey, + market: Pubkey, + market_authority: Pubkey, + underlying_vault: Pubkey, + quote_vault: Pubkey, +} + +/// A character with a wallet and both token accounts. +struct Person { + keypair: Keypair, + underlying: Pubkey, + quote: Pubkey, +} + +impl Person { + fn pubkey(&self) -> Pubkey { + self.keypair.pubkey() + } +} + +impl Venue { + fn new() -> Venue { + Venue::try_new(FEE_BPS, false).expect("market initialization should succeed") + } + + /// Like `new`, but surfaces an `initialize_market` rejection instead of + /// panicking, so tests can probe the parameter validation. `same_mint` + /// passes the underlying mint as the quote mint too. + fn try_new(fee_bps: u16, same_mint: bool) -> Result { + let mut svm = LiteSVM::new(); + svm.add_program( + options::id(), + include_bytes!("../../../target/deploy/options.so"), + ) + .unwrap(); + + let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let admin = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let underlying_mint = create_token_mint(&mut svm, &admin, DECIMALS, None).unwrap(); + let quote_mint = if same_mint { + underlying_mint + } else { + create_token_mint(&mut svm, &admin, DECIMALS, None).unwrap() + }; + + let market = Pubkey::find_program_address( + &[b"market", underlying_mint.as_ref(), quote_mint.as_ref()], + &options::id(), + ) + .0; + let market_authority = + Pubkey::find_program_address(&[b"authority", market.as_ref()], &options::id()).0; + let underlying_vault = + Pubkey::find_program_address(&[b"underlying_vault", market.as_ref()], &options::id()).0; + let quote_vault = + Pubkey::find_program_address(&[b"quote_vault", market.as_ref()], &options::id()).0; + + let initialize_market = Instruction::new_with_bytes( + options::id(), + &options::instruction::InitializeMarket { fee_bps }.data(), + options::accounts::InitializeMarketAccountConstraints { + admin: admin.pubkey(), + market, + underlying_mint, + quote_mint, + market_authority, + underlying_vault, + quote_vault, + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut svm, + vec![initialize_market], + &[&admin], + &admin.pubkey(), + ) + .map_err(|_| ())?; + + Ok(Venue { + svm, + payer, + admin, + underlying_mint, + quote_mint, + market, + market_authority, + underlying_vault, + quote_vault, + }) + } + + fn market_state(&self) -> MarketState { + let account = self.svm.get_account(&self.market).unwrap(); + MarketState::try_deserialize(&mut account.data.as_slice()).unwrap() + } + + fn option_pda(&self, writer: &Pubkey, id: u64) -> Pubkey { + Pubkey::find_program_address( + &[ + b"option", + self.market.as_ref(), + writer.as_ref(), + &id.to_le_bytes(), + ], + &options::id(), + ) + .0 + } + + fn option_state(&self, option: &Pubkey) -> OptionContract { + let account = self.svm.get_account(option).unwrap(); + OptionContract::try_deserialize(&mut account.data.as_slice()).unwrap() + } + + fn option_exists(&self, option: &Pubkey) -> bool { + self.svm + .get_account(option) + .map(|account| !account.data.is_empty()) + .unwrap_or(false) + } + + fn now(&self) -> i64 { + self.svm.get_sysvar::().unix_timestamp + } + + /// Move the clock to `unix_timestamp`. Also expires the blockhash, so a + /// retried instruction after the warp is not dropped as a duplicate. + fn warp_to(&mut self, unix_timestamp: i64) { + let mut clock: Clock = self.svm.get_sysvar(); + clock.unix_timestamp = unix_timestamp; + self.svm.set_sysvar(&clock); + self.svm.expire_blockhash(); + } + + /// A character holding `underlying` and `quote` minor units in their + /// associated token accounts. Every character gets one SOL's worth of + /// lamports and change for rent and fees. + fn person(&mut self, underlying: u64, quote: u64) -> Person { + let keypair = create_wallet(&mut self.svm, 10_000_000_000).unwrap(); + let underlying_account = create_associated_token_account( + &mut self.svm, + &keypair.pubkey(), + &self.underlying_mint, + &self.payer, + ) + .unwrap(); + let quote_account = create_associated_token_account( + &mut self.svm, + &keypair.pubkey(), + &self.quote_mint, + &self.payer, + ) + .unwrap(); + if underlying > 0 { + mint_tokens_to_token_account( + &mut self.svm, + &self.underlying_mint, + &underlying_account, + underlying, + &self.admin, + ) + .unwrap(); + } + if quote > 0 { + mint_tokens_to_token_account( + &mut self.svm, + &self.quote_mint, + "e_account, + quote, + &self.admin, + ) + .unwrap(); + } + Person { + keypair, + underlying: underlying_account, + quote: quote_account, + } + } + + fn balance(&self, token_account: &Pubkey) -> u64 { + get_token_account_balance(&self.svm, token_account).unwrap() + } + + fn send(&mut self, instruction: Instruction, signer: &Keypair) -> Result<(), ()> { + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[signer], + &signer.pubkey(), + ) + .map(|_| ()) + .map_err(|_| ()) + } + + fn write_option(&mut self, writer: &Person, id: u64, terms: OptionTerms) -> Result { + let option = self.option_pda(&writer.pubkey(), id); + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::WriteOption { id, terms }.data(), + options::accounts::WriteOptionAccountConstraints { + writer: writer.pubkey(), + market: self.market, + option, + underlying_mint: self.underlying_mint, + quote_mint: self.quote_mint, + underlying_vault: self.underlying_vault, + quote_vault: self.quote_vault, + writer_underlying: writer.underlying, + writer_quote: writer.quote, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + self.send(instruction, &writer.keypair).map(|_| option) + } + + /// The walkthrough's call, written by `writer`, expiring one week out. + fn write_call(&mut self, writer: &Person) -> Pubkey { + let expiry = self.now() + ONE_WEEK; + self.write_option(writer, 1, call_terms(expiry)) + .expect("writing the call should succeed") + } + + /// The walkthrough's put, written by `writer`, expiring one week out. + fn write_put(&mut self, writer: &Person) -> Pubkey { + let expiry = self.now() + ONE_WEEK; + self.write_option( + writer, + 2, + OptionTerms { + kind: OptionKind::Put, + contracts: CONTRACTS, + underlying_per_contract: ONE_NVDAX_PER_CONTRACT, + strike_per_contract: PUT_STRIKE, + premium: PUT_PREMIUM, + expiry, + }, + ) + .expect("writing the put should succeed") + } + + fn buy_option(&mut self, buyer: &Person, writer: &Pubkey, option: &Pubkey) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::BuyOption {}.data(), + options::accounts::BuyOptionAccountConstraints { + buyer: buyer.pubkey(), + writer: *writer, + market: self.market, + option: *option, + quote_mint: self.quote_mint, + quote_vault: self.quote_vault, + buyer_quote: buyer.quote, + writer_quote: derive_ata(writer, &self.quote_mint), + token_program: token_program_id(), + } + .to_account_metas(None), + ); + self.send(instruction, &buyer.keypair) + } + + fn cancel_option(&mut self, writer: &Person, option: &Pubkey) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::CancelOption {}.data(), + options::accounts::CancelOptionAccountConstraints { + writer: writer.pubkey(), + market: self.market, + option: *option, + market_authority: self.market_authority, + underlying_mint: self.underlying_mint, + quote_mint: self.quote_mint, + underlying_vault: self.underlying_vault, + quote_vault: self.quote_vault, + writer_underlying: writer.underlying, + writer_quote: writer.quote, + token_program: token_program_id(), + } + .to_account_metas(None), + ); + self.send(instruction, &writer.keypair) + } + + fn exercise_option( + &mut self, + holder: &Person, + writer: &Pubkey, + option: &Pubkey, + ) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::ExerciseOption {}.data(), + options::accounts::ExerciseOptionAccountConstraints { + holder: holder.pubkey(), + writer: *writer, + market: self.market, + option: *option, + market_authority: self.market_authority, + underlying_mint: self.underlying_mint, + quote_mint: self.quote_mint, + underlying_vault: self.underlying_vault, + quote_vault: self.quote_vault, + holder_underlying: holder.underlying, + holder_quote: holder.quote, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + self.send(instruction, &holder.keypair) + } + + fn collect_proceeds(&mut self, writer: &Person, option: &Pubkey) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::CollectProceeds {}.data(), + options::accounts::CollectProceedsAccountConstraints { + writer: writer.pubkey(), + market: self.market, + option: *option, + market_authority: self.market_authority, + underlying_mint: self.underlying_mint, + quote_mint: self.quote_mint, + underlying_vault: self.underlying_vault, + quote_vault: self.quote_vault, + writer_underlying: writer.underlying, + writer_quote: writer.quote, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + self.send(instruction, &writer.keypair) + } + + fn reclaim_collateral(&mut self, writer: &Person, option: &Pubkey) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::ReclaimCollateral {}.data(), + options::accounts::ReclaimCollateralAccountConstraints { + writer: writer.pubkey(), + market: self.market, + option: *option, + market_authority: self.market_authority, + underlying_mint: self.underlying_mint, + quote_mint: self.quote_mint, + underlying_vault: self.underlying_vault, + quote_vault: self.quote_vault, + writer_underlying: writer.underlying, + writer_quote: writer.quote, + token_program: token_program_id(), + } + .to_account_metas(None), + ); + self.send(instruction, &writer.keypair) + } + + fn collect_fees_as(&mut self, signer: &Keypair) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::CollectFees {}.data(), + options::accounts::CollectFeesAccountConstraints { + admin: signer.pubkey(), + market: self.market, + market_authority: self.market_authority, + quote_mint: self.quote_mint, + underlying_vault: self.underlying_vault, + quote_vault: self.quote_vault, + admin_quote: derive_ata(&signer.pubkey(), &self.quote_mint), + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + self.send(instruction, signer) + } + + fn collect_fees(&mut self) -> Result<(), ()> { + let admin = self.admin.insecure_clone(); + self.collect_fees_as(&admin) + } + + /// The custody invariant: each vault holds exactly what the market owes. + /// Nothing in these tests donates to a vault, so equality holds rather + /// than the `>=` the program enforces. + fn assert_vaults_match_ledger(&self) { + let market = self.market_state(); + assert_eq!( + self.balance(&self.underlying_vault), + market.underlying_locked, + "underlying vault must hold exactly the locked underlying" + ); + assert_eq!( + self.balance(&self.quote_vault), + market.quote_locked + market.fees_owed, + "quote vault must hold exactly the locked quote plus the fees owed" + ); + } +} + +// =========================================================================== +// The call: write, buy, exercise, collect +// =========================================================================== + +/// Alice writes 5 covered calls 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_locks_the_underlying() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + + let option = venue.write_call(&alice); + + assert_eq!(venue.balance(&alice.underlying), 0); + assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); + let state = venue.option_state(&option); + assert_eq!(state.writer, alice.pubkey()); + 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.premium, CALL_PREMIUM); + assert_eq!(venue.market_state().underlying_locked, FIVE_NVDAX); + venue.assert_vaults_match_ledger(); +} + +/// Bob buys the option. He pays 25 USDC: 1% (0.25 USDC) to the venue, the rest +/// straight to Alice. The 5 NVDAx do not move. +#[test] +fn test_buy_option_pays_the_premium_minus_the_fee() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + let fee = 250_000; // 0.25 USDC + assert_eq!(venue.balance(&bob.quote), STANDARD_USDC - CALL_PREMIUM); + assert_eq!( + venue.balance(&alice.quote), + STANDARD_USDC + CALL_PREMIUM - fee + ); + assert_eq!(venue.balance(&venue.quote_vault), fee); + assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); + let state = venue.option_state(&option); + assert_eq!(state.holder, bob.pubkey()); + assert_eq!(state.status, OptionStatus::Held); + assert_eq!(venue.market_state().fees_owed, fee); + venue.assert_vaults_match_ledger(); +} + +/// NVIDIA rallies past the strike offchain, so Bob exercises: he pays the +/// strike, 5 x 180 = 900 USDC, into the vault and takes the 5 NVDAx. The 900 +/// USDC is now owed to Alice, and the underlying is no longer owed to anyone. +#[test] +fn test_exercise_call_swaps_the_strike_for_the_underlying() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + venue + .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 + ); + assert_eq!(venue.balance(&venue.underlying_vault), 0); + assert_eq!(venue.balance(&venue.quote_vault), strike_total + 250_000); + let market = venue.market_state(); + assert_eq!(market.underlying_locked, 0); + assert_eq!(market.quote_locked, strike_total); + assert_eq!(venue.option_state(&option).status, OptionStatus::Exercised); + venue.assert_vaults_match_ledger(); +} + +/// Alice collects the 900 USDC Bob paid, and the option closes with +/// its rent back to her. She sold 5 NVDAx for 900 USDC plus the 24.75 USDC +/// premium she already had. +#[test] +fn test_collect_proceeds_pays_the_writer_and_closes_the_option() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + venue + .exercise_option(&bob, &alice.pubkey(), &option) + .unwrap(); + let alice_lamports_before = venue.svm.get_balance(&alice.pubkey()).unwrap(); + + venue.collect_proceeds(&alice, &option).unwrap(); + + assert_eq!( + venue.balance(&alice.quote), + STANDARD_USDC + 900 * ONE_TOKEN + CALL_PREMIUM - 250_000 + ); + assert_eq!(venue.balance(&alice.underlying), 0); + assert!(!venue.option_exists(&option)); + assert!( + venue.svm.get_balance(&alice.pubkey()).unwrap() > alice_lamports_before, + "the option's rent must return to the writer" + ); + let market = venue.market_state(); + assert_eq!(market.quote_locked, 0); + assert_eq!(market.fees_owed, 250_000); + venue.assert_vaults_match_ledger(); +} + +/// Maria sweeps the venue's fee. Only the 0.25 USDC of fees leaves the +/// vault; the strike payment sitting beside it stays locked to Alice. +#[test] +fn test_collect_fees_pays_only_the_fees_owed() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + venue + .exercise_option(&bob, &alice.pubkey(), &option) + .unwrap(); + + venue.collect_fees().unwrap(); + + let admin_quote = derive_ata(&venue.admin.pubkey(), &venue.quote_mint); + assert_eq!(venue.balance(&admin_quote), 250_000); + assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_TOKEN); + assert_eq!(venue.market_state().fees_owed, 0); + venue.assert_vaults_match_ledger(); + + // Nothing left to sweep. + assert!(venue.collect_fees().is_err()); +} + +// =========================================================================== +// 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. +#[test] +fn test_put_lifecycle_delivers_the_underlying_for_the_strike() { + let mut venue = Venue::new(); + let carol = venue.person(0, STANDARD_USDC); + let dave = venue.person(FIVE_NVDAX, STANDARD_USDC); + + let option = venue.write_put(&carol); + let collateral = 750 * ONE_TOKEN; + assert_eq!(venue.balance(&carol.quote), STANDARD_USDC - collateral); + assert_eq!(venue.balance(&venue.quote_vault), collateral); + assert_eq!(venue.market_state().quote_locked, collateral); + venue.assert_vaults_match_ledger(); + + venue.buy_option(&dave, &carol.pubkey(), &option).unwrap(); + let fee = 200_000; // 1% of 20 USDC + assert_eq!( + venue.balance(&carol.quote), + STANDARD_USDC - collateral + PUT_PREMIUM - fee + ); + assert_eq!(venue.balance(&dave.quote), STANDARD_USDC - PUT_PREMIUM); + venue.assert_vaults_match_ledger(); + + venue + .exercise_option(&dave, &carol.pubkey(), &option) + .unwrap(); + assert_eq!(venue.balance(&dave.underlying), 0); + assert_eq!( + venue.balance(&dave.quote), + STANDARD_USDC - PUT_PREMIUM + collateral + ); + assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); + assert_eq!(venue.balance(&venue.quote_vault), fee); + let market = venue.market_state(); + assert_eq!(market.underlying_locked, FIVE_NVDAX); + assert_eq!(market.quote_locked, 0); + venue.assert_vaults_match_ledger(); + + venue.collect_proceeds(&carol, &option).unwrap(); + assert_eq!(venue.balance(&carol.underlying), FIVE_NVDAX); + assert_eq!(venue.market_state().underlying_locked, 0); + assert!(!venue.option_exists(&option)); + venue.assert_vaults_match_ledger(); +} + +/// Bob never exercises. Once the expiry passes, Alice takes her 5 NVDAx back +/// and keeps the premium: the writer's whole return on an option that expires +/// out of the money. Bob is left with nothing to claim. +#[test] +fn test_reclaim_collateral_after_expiry_returns_it_to_the_writer() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + let expiry = venue.option_state(&option).expiry; + + venue.warp_to(expiry); + venue.reclaim_collateral(&alice, &option).unwrap(); + + assert_eq!(venue.balance(&alice.underlying), FIVE_NVDAX); + assert_eq!( + venue.balance(&alice.quote), + STANDARD_USDC + CALL_PREMIUM - 250_000 + ); + assert_eq!(venue.balance(&bob.quote), STANDARD_USDC - CALL_PREMIUM); + assert!(!venue.option_exists(&option)); + assert_eq!(venue.market_state().underlying_locked, 0); + venue.assert_vaults_match_ledger(); +} + +// =========================================================================== +// The expiry boundary, from both sides +// =========================================================================== + +/// The holder may exercise while now < expiry. One second before expiry the +/// exercise goes through; at expiry it is refused. +#[test] +fn test_exercise_is_allowed_up_to_but_not_at_expiry() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + let expiry = venue.option_state(&option).expiry; + + venue.warp_to(expiry); + assert!(venue + .exercise_option(&bob, &alice.pubkey(), &option) + .is_err()); + + venue.warp_to(expiry - 1); + venue + .exercise_option(&bob, &alice.pubkey(), &option) + .expect("exercise one second before expiry must succeed"); +} + +/// The writer may reclaim once now >= expiry, and not one second earlier. +#[test] +fn test_reclaim_is_refused_before_expiry() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + let expiry = venue.option_state(&option).expiry; + + venue.warp_to(expiry - 1); + assert!(venue.reclaim_collateral(&alice, &option).is_err()); + + venue.warp_to(expiry); + venue + .reclaim_collateral(&alice, &option) + .expect("reclaim at expiry must succeed"); +} + +/// An expired option cannot be bought: nobody can pay for a right that can no +/// longer be exercised. +#[test] +fn test_buy_is_refused_after_expiry() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + let expiry = venue.option_state(&option).expiry; + + venue.warp_to(expiry); + assert!(venue.buy_option(&bob, &alice.pubkey(), &option).is_err()); +} + +// =========================================================================== +// Cancel: the writer's exit from an unsold option +// =========================================================================== + +/// An unsold option can be withdrawn at any time, collateral back, account +/// closed. Without this, an option nobody buys would lock the writer's tokens +/// forever. +#[test] +fn test_cancel_unsold_option_returns_the_collateral() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let option = venue.write_call(&alice); + + venue.cancel_option(&alice, &option).unwrap(); + + assert_eq!(venue.balance(&alice.underlying), FIVE_NVDAX); + assert_eq!(venue.balance(&venue.underlying_vault), 0); + assert!(!venue.option_exists(&option)); + venue.assert_vaults_match_ledger(); +} + +/// An unsold option that expired is still the writer's to cancel: there is no +/// holder whose rights the cancel would cut short. +#[test] +fn test_cancel_unsold_option_works_after_expiry() { + let mut venue = Venue::new(); + let carol = venue.person(0, STANDARD_USDC); + let option = venue.write_put(&carol); + let expiry = venue.option_state(&option).expiry; + + venue.warp_to(expiry + SECONDS_PER_DAY); + venue.cancel_option(&carol, &option).unwrap(); + + assert_eq!(venue.balance(&carol.quote), STANDARD_USDC); + assert_eq!(venue.market_state().quote_locked, 0); + venue.assert_vaults_match_ledger(); +} + +/// Once sold, the collateral belongs to the deal: the writer cannot pull it +/// out from under the holder. +#[test] +fn test_cancel_is_refused_once_sold() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + assert!(venue.cancel_option(&alice, &option).is_err()); + assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); +} + +// =========================================================================== +// Who may do what +// =========================================================================== + +#[test] +fn test_buy_is_refused_once_sold() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let carol = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + assert!(venue.buy_option(&carol, &alice.pubkey(), &option).is_err()); + assert_eq!(venue.option_state(&option).holder, bob.pubkey()); +} + +/// A writer cannot buy their own option: the premium's source and destination +/// would be the same token account in two mutable slots, which the loader +/// rejects before the handler runs. +#[test] +fn test_writer_cannot_buy_their_own_option() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let option = venue.write_call(&alice); + + assert!(venue.buy_option(&alice, &alice.pubkey(), &option).is_err()); + assert_eq!(venue.option_state(&option).status, OptionStatus::Listed); +} + +/// Only the holder can exercise: an unsold option has no holder, and a stranger +/// is not the holder of a sold one. +#[test] +fn test_exercise_is_refused_for_anyone_but_the_holder() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let mallory = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + + assert!(venue + .exercise_option(&mallory, &alice.pubkey(), &option) + .is_err()); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + assert!(venue + .exercise_option(&mallory, &alice.pubkey(), &option) + .is_err()); + assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); +} + +/// A sold, unexercised, unexpired option has no proceeds to collect, and after +/// exercise only the writer may collect them. +#[test] +fn test_collect_proceeds_needs_an_exercised_option_and_the_writer() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let mallory = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + assert!(venue.collect_proceeds(&alice, &option).is_err()); + + venue + .exercise_option(&bob, &alice.pubkey(), &option) + .unwrap(); + assert!(venue.collect_proceeds(&mallory, &option).is_err()); + assert!(venue.collect_proceeds(&bob, &option).is_err()); + assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_TOKEN + 250_000); +} + +/// An exercised option has no collateral left to reclaim, whatever the clock +/// says: the holder took it. +#[test] +fn test_reclaim_is_refused_after_exercise() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + venue + .exercise_option(&bob, &alice.pubkey(), &option) + .unwrap(); + let expiry = venue.option_state(&option).expiry; + + venue.warp_to(expiry + 1); + assert!(venue.reclaim_collateral(&alice, &option).is_err()); +} + +#[test] +fn test_collect_fees_is_refused_for_anyone_but_the_admin() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let mallory = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + assert!(venue.collect_fees_as(&mallory.keypair).is_err()); + assert_eq!(venue.market_state().fees_owed, 250_000); +} + +// =========================================================================== +// Parameter validation +// =========================================================================== + +#[test] +fn test_write_option_rejects_zero_quantities_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), + ]; + for (id, (contracts, underlying_per_contract, strike_per_contract, premium)) in + attempts.into_iter().enumerate() + { + let terms = OptionTerms { + kind: OptionKind::Call, + contracts, + underlying_per_contract, + strike_per_contract, + premium, + expiry, + }; + assert!( + venue.write_option(&alice, id as u64 + 10, terms).is_err(), + "a zero in any term must be refused" + ); + } + assert_eq!(venue.balance(&alice.underlying), FIVE_NVDAX); +} + +/// The holder may exercise while now < expiry, so an expiry at or before now +/// would be an option nobody could ever exercise. +#[test] +fn test_write_option_rejects_an_expiry_that_has_passed() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let now = venue.now(); + + for expiry in [now, now - SECONDS_PER_DAY] { + assert!(venue.write_option(&alice, 20, call_terms(expiry)).is_err()); + } +} + +/// 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()); +} + +#[test] +fn test_initialize_market_rejects_the_same_mint_on_both_sides() { + assert!(Venue::try_new(FEE_BPS, true).is_err()); +} + +/// A venue run at cost is a valid choice: with a zero fee the writer +/// receives the whole premium and no fee transfer is attempted. +#[test] +fn test_zero_fee_venue_pays_the_writer_the_whole_premium() { + let mut venue = Venue::try_new(0, false).unwrap(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + assert_eq!(venue.balance(&alice.quote), STANDARD_USDC + CALL_PREMIUM); + assert_eq!(venue.market_state().fees_owed, 0); + venue.assert_vaults_match_ledger(); +} diff --git a/finance/options/anchor/.gitignore b/finance/options/anchor/.gitignore new file mode 100644 index 00000000..be06d3aa --- /dev/null +++ b/finance/options/anchor/.gitignore @@ -0,0 +1,6 @@ +.anchor +target +**/*.rs.bk +node_modules +test-ledger +.DS_Store diff --git a/finance/options/anchor/Anchor.toml b/finance/options/anchor/Anchor.toml new file mode 100644 index 00000000..41e28d52 --- /dev/null +++ b/finance/options/anchor/Anchor.toml @@ -0,0 +1,25 @@ +[toolchain] +# Match the repo package manager (pnpm-lock.yaml at root); avoids Anchor's yarn default. +package_manager = "pnpm" +solana_version = "3.1.8" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +options = "2gmMGMmipfYypLxWsvQ5GQJT5AGnMWmk4Rb9vQMRo6ig" + +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "cargo test" + +# Non-default: the LiteSVM Rust tests load the program's .so file, so it must +# be built before `cargo test` runs. CI calls `anchor build` first; these waits +# only matter for the legacy validator path. +[test] +startup_wait = 5000 +shutdown_wait = 2000 diff --git a/finance/options/anchor/CHANGELOG.md b/finance/options/anchor/CHANGELOG.md new file mode 100644 index 00000000..7f9a4c8e --- /dev/null +++ b/finance/options/anchor/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 2026-09-04 + +Initial version: a fully collateralized, physically settled options venue. +A writer posts the whole obligation (the underlying for a call, the strike +in the quote token for a put) and lists an option at a premium; a buyer pays the +premium and becomes the holder; the holder may exercise before expiry; after +expiry the writer reclaims the collateral. Eight instruction handlers +(`initialize_market`, `write_option`, `buy_option`, `cancel_option`, +`exercise_option`, `collect_proceeds`, `reclaim_collateral`, +`collect_fees`), a custody ledger on the market account asserted after every +transfer, and a LiteSVM suite covering both kinds and all three exits. diff --git a/finance/options/anchor/Cargo.toml b/finance/options/anchor/Cargo.toml new file mode 100644 index 00000000..f3977048 --- /dev/null +++ b/finance/options/anchor/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/finance/options/anchor/README.md b/finance/options/anchor/README.md new file mode 100644 index 00000000..8d3abfa1 --- /dev/null +++ b/finance/options/anchor/README.md @@ -0,0 +1,250 @@ +# Solana Options (Anchor) + +> [!NOTE] +> This is the **Anchor v2** copy of this example. Every `anchor` command on this page +> needs the v2 CLI: `cargo install anchor-cli --version 2.0.0-rc.1 --locked` (avm has +> no prebuilt binary for this pre-release). The Anchor v1 version of this example is in +> [`../anchor-v1`](../anchor-v1/). + +A Solana options venue is a program that lets one user sell another the right, +but not the obligation, to buy or sell an asset at a fixed price before a fixed +date. This one is **fully collateralized** and **physically settled**: the +writer of a call posts the whole of the underlying, the writer of a put posts +the whole of the strike in the quote token, a buyer pays a premium for the +right, and if the holder exercises before expiry the tokens themselves change +hands at the strike. Because everything a holder could ever claim is in the +vault from the moment the option exists, no position can be under water, so +there is no margin, no liquidator, and no oracle. The venue that took the +other road on Solana, cash settlement with margin and an oracle, is Zeta +Markets. + +[โš“ Anchor v2](.) ยท [โš“ Anchor v1](../anchor-v1) ยท [๐Ÿ’ซ Quasar](../quasar) ยท [Kani proofs](../kani-proofs) + +## Programs + +- **`options`**: the venue. One admin, one underlying/quote pair, two vaults, + one account per option, eight instruction handlers. + +There is no mock oracle program, because nothing in the venue reads a price. + +## Key financial concepts + +### A call, a put, a strike, a premium, an expiry + +An **option** is a contract with five terms. Its **kind** is a **call** (the +right to buy the underlying) or a **put** (the right to sell it). Its +**strike** is the price the trade happens at if the holder exercises. Its +**expiry** is the last moment the holder can exercise. Its **premium** is what +the buyer pays the writer for the right, up front, and keeps paying nothing +after. The person who sells the right is the **writer**; the person who holds +it is the **holder**. + +The two sides have different shapes. The holder's loss is capped at the +premium, whatever happens. The writer keeps the premium whatever happens, and +in exchange takes on an obligation: to sell the underlying at the strike (a +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 +undercollateralized, which is why the program has no health check, no +liquidation, and no need to know the price. + +### Physical settlement needs no oracle + +When a call holder exercises, they pay the strike into the vault and take the +underlying out; a put holder delivers the underlying and takes the strike. The +tokens move; no price is computed. Whether exercising is worth it is the +holder's decision, made against whatever the market is doing offchain, and a +holder who exercises an out-of-the-money option only hurts themselves. The +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 + +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. + +### Expiry is one comparison and its complement + +The holder may exercise while `now < expiry`. The writer may reclaim the +collateral once `now >= expiry`. Those two conditions partition time, so there +is no instant at which both parties can claim the same collateral and none at +which neither can. Expiry is a unix timestamp because an option's expiry is a +calendar date the two parties agreed on, the same reason the fundraiser's +deadline is one. + +## Program flow + +### Participants + +- **Maria** operates the venue and earns 1% of every premium. +- **Alice** holds 5 NVDAx (tokenized NVIDIA stock, 6 decimals) she would be + happy to sell at $180, and wants to be paid while she waits. +- **Bob** thinks NVIDIA will rally past $180 within the week and wants that + upside for less than the price of 5 shares. +- **Carol** would like to own NVDAx at $150 and wants to be paid for + standing ready to buy. +- **Dave** holds 5 NVDAx and wants insurance against a fall below $150. + +Everyone starts with the standard wallet of one SOL and 1,000 USDC, plus the +NVDAx the story hands them. NVDAx is trading around $165 offchain. + +### Step 1: Maria opens the venue + +`initialize_market(fee_bps = 100)` creates the `Market` account (a PDA of the +two mints), a dataless vault-authority PDA, and the two vaults. Maria's key is +recorded as `admin`: it can sweep fees and do nothing else. + +### Step 2: Alice writes 5 covered calls + +`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 +`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. + +### Step 3: Bob buys the option + +`buy_option` takes 25 USDC from Bob: 0.25 USDC (the 1% fee) into the quote +vault, owed to Maria, and 24.75 USDC straight to Alice. The 5 NVDAx do not +move. The option's `holder` is now Bob and its status `Held`. Bob's downside +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 +`Exercised`, and the 900 USDC sits in the vault owed to Alice. + +### Step 5: Alice collects the strike + +`collect_proceeds` pays Alice the 900 USDC and closes the option, rent +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 + +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. + +### Step 7: The week passes above $150, and Carol reclaims her collateral + +Dave never exercises: selling at 150 when the market pays more would be a +gift. After the expiry, Carol's `reclaim_collateral` returns her 750 USDC and +closes the option. Her return is the 19.80 USDC premium; Dave's +insurance cost him 20 USDC and paid nothing, which is what insurance against a +fall that never came should do. + +### Step 8: Maria sweeps the fees + +`collect_fees` pays Maria the 0.45 USDC of accumulated fees. The vaults are +empty: every token that entered has left to the party it was owed to. + +Where everyone ended up: Alice earned a premium and sold her shares at her +price; Bob turned 25 USDC of premium into 5 NVDAx at a $20 discount to the +market; Carol was paid to wait for a purchase that never came; Dave bought +insurance he did not need; Maria earned 1% of every premium. + +## Custody + +The two vaults hold other people's money, so the `Market` account keeps a +ledger of what each vault owes: `underlying_locked` (call collateral, plus put +holders' deliveries awaiting collection), `quote_locked` (put collateral, plus +call holders' strike payments awaiting collection) and `fees_owed`. Every +handler that moves tokens updates the ledger before any transfer and then +asserts that each vault still covers what it owes (`CustodyInvariantViolated` +otherwise). The [Kani proofs](../kani-proofs) walk every path through an option's +life and show the ledger returns to zero. + +## Design notes and further reading + +- Some venues represent each option as two SPL tokens, an option token and a + writer token, so options can trade on any exchange and one writer's option can + be exercised in parts by many holders. This example keeps one account per + option, bought and exercised as a whole, which keeps the custody legible and + the state machine three states long. Adding secondary trading means + introducing those tokens. +- Cash-settled venues (Zeta Markets) let a writer post less than the full + obligation, which is what makes them capital-efficient and also what makes + them need margin, liquidation, and an oracle. The perpetual-futures example + in this repository has all three. + +## Limitations + +- An option is bought and exercised as a whole; there is no partial exercise and + no secondary sale of a held option. +- The writer sets the premium and a buyer takes it or leaves it. There is no + order book and no pricing model; a market maker would quote premiums from + a model offchain and write options at those prices. +- American exercise only. A European option, exercisable only at expiry, + would add an exercise window after `expiry` and a gap before it. + +## Setup + +```bash +anchor build +``` + +## Testing + +```bash +anchor build +cargo test +``` + +The LiteSVM suite (`programs/options/tests/test_options.rs`) walks the call +from write to collected strike and the put from write to exercise and to +expiry, pins every balance to the minor unit, checks the custody ledger +against the vault balances after every step, and proves every gate shuts: +the expiry boundary from both sides, cancel after sale, buy after sale or +expiry, exercise by a non-holder, collection by a non-writer or before +exercise, reclaim after exercise, fee collection by a non-admin, and the +parameter checks at write time. + +## FAQ + +### How do options work on Solana? + +A writer calls `write_option`, posting the full collateral (the underlying for +a call, the strike in USDC for a put) and naming a premium. A buyer calls +`buy_option`, pays the premium, and becomes the holder. Before expiry the +holder may call `exercise_option` to trade at the strike; after expiry the +writer calls `reclaim_collateral` to take back what was not exercised. + +### Why does this options program need no oracle? + +Because it settles physically. Exercising moves the underlying one way and +the strike the other; the program never has to know what the underlying is +worth, only that the holder chose to trade. A cash-settled option pays the +difference between the market price and the strike, and that difference is a +number only a price feed can supply. + +### What stops a writer from defaulting? + +They cannot: `write_option` takes the entire obligation into the vault up +front, so a call is covered and a put is cash-secured. The writer can get the +collateral back only through `cancel_option` (before anyone buys) or +`reclaim_collateral` (after expiry), and never while a holder could still +exercise. + +### How does the venue make money? + +`buy_option` takes `fee_bps` of every premium into the quote vault, and the +admin sweeps it with `collect_fees`. The fee is the admin's only reach into +the vault; collateral and strike payments are locked to their writers and +holders. diff --git a/finance/options/anchor/programs/options/Cargo.toml b/finance/options/anchor/programs/options/Cargo.toml new file mode 100644 index 00000000..0ce66525 --- /dev/null +++ b/finance/options/anchor/programs/options/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "options" +version = "0.1.0" +description = "Fully collateralized, physically settled options venue example" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "options" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] +# Forwards to the harness so `anchor test --profile` can turn tracing on. +profile = ["anchor-v2-testing/profile"] + +[dependencies] +# init-if-needed: exercise_option and collect_proceeds create the receiving +# party's token account for the asset they are about to be paid in, so a call +# holder who has never held the underlying needs no separate setup transaction. +# The receiving party is always the payer, so nobody funds another's rent. +anchor-lang = "2.0.0-rc.1" +# The `#[program]` macro expands to `wincode` paths for instruction-data +# (de)serialization, so the crate has to be a direct dependency. +wincode = { version = "0.5", features = ["derive"] } +# anchor-lang 2.0.0-rc.1 is built against wincode 0.5, but solana-address 2.7 +# moved to wincode 0.6. With both in the graph, `Address`'s wincode impls +# belong to the version the account derives are not using and every +# `SchemaRead`/`SchemaWrite` bound fails. 2.6.1 is the last 0.5-line release. +solana-address = ">=2.6, <2.7" +anchor-spl = "2.0.0-rc.1" +# Declared only so Cargo feature-unification turns on `no-entrypoint`; without +# these the test binary links two `entrypoint` symbols and fails to build. +spl-token = { version = "9.0.0", features = ["no-entrypoint"] } +spl-associated-token-account = { version = "8.0.0", features = ["no-entrypoint"] } + +[dev-dependencies] +# Anchor v2's own LiteSVM harness. `svm()` is `LiteSVM::new()` until the +# `profile` feature is on, when it also records the SBF register traces that +# `anchor test --profile`, `anchor debugger` and `anchor coverage` read. Tests +# that call `LiteSVM::new()` directly still pass but produce no traces, so +# those three tools see nothing. +# +# Published only from git: crates.io has no `anchor-v2-testing`. Pinned to a +# revision rather than tracking `anchor-next`, so a CI run cannot change +# behaviour because upstream moved between builds. +# +# Bump the rev deliberately, never automatically: `anchor-next` is where v2 +# development happens, so a later revision can carry breakage as easily as +# fixes. When `anchor-v2-testing` reaches crates.io, drop the git source for +# an ordinary version. +anchor-v2-testing = { git = "https://github.com/otter-sec/anchor.git", rev = "3e38a121b969d1a77230baeb0056baf5cc9b3c1a" } +# LiteSVM's `get_sysvar` / `set_sysvar` want the host-side Clock type, not +# pinocchio's on-chain one; the tests move `unix_timestamp` past an expiry. +solana-clock = "3.0.1" +solana-kite = "0.4.0" +borsh = "1.6.1" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/finance/options/anchor/programs/options/src/constants.rs b/finance/options/anchor/programs/options/src/constants.rs new file mode 100644 index 00000000..407b392c --- /dev/null +++ b/finance/options/anchor/programs/options/src/constants.rs @@ -0,0 +1,21 @@ +use anchor_lang::prelude::*; + +/// Basis-point denominator: 100% = 10_000 bps. The venue's fee on each premium +/// is expressed in basis points and divided by this. +#[constant] +pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; + +#[constant] +pub const MARKET_SEED: &[u8] = b"market"; + +#[constant] +pub const AUTHORITY_SEED: &[u8] = b"authority"; + +#[constant] +pub const UNDERLYING_VAULT_SEED: &[u8] = b"underlying_vault"; + +#[constant] +pub const QUOTE_VAULT_SEED: &[u8] = b"quote_vault"; + +#[constant] +pub const OPTION_SEED: &[u8] = b"option"; diff --git a/finance/options/anchor/programs/options/src/contract_math.rs b/finance/options/anchor/programs/options/src/contract_math.rs new file mode 100644 index 00000000..aa56a958 --- /dev/null +++ b/finance/options/anchor/programs/options/src/contract_math.rs @@ -0,0 +1,80 @@ +//! The pure arithmetic 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`. + +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 { + match kind { + OptionKind::Call => underlying_total(contracts, underlying_per_contract), + OptionKind::Put => strike_total(contracts, strike_per_contract), + } +} + +/// 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 { + match kind { + OptionKind::Call => strike_total(contracts, strike_per_contract), + OptionKind::Put => underlying_total(contracts, underlying_per_contract), + } +} + +/// Split a premium into the venue's fee and the writer's share. The fee +/// floors, so the writer receives the rounding minor unit; the venue gives up +/// at most one minor unit per sale, and a sale needs a real premium, so the +/// leak cannot be industrialized. +pub fn split_premium(premium: u64, fee_bps: u16) -> Option<(u64, u64)> { + let fee = (premium as u128) + .checked_mul(fee_bps as u128)? + .checked_div(BASIS_POINTS)?; + let fee = u64::try_from(fee).ok()?; + let to_writer = premium.checked_sub(fee)?; + Some((fee, to_writer)) +} + +/// The holder may exercise while the option has not expired. +pub fn may_exercise(now: i64, expiry: i64) -> bool { + now < expiry +} + +/// The writer may reclaim collateral once the option has expired: the exact +/// complement of `may_exercise`, so there is no instant at which both the +/// holder and the writer can claim the same collateral, and none at which +/// neither can. +pub fn may_reclaim(now: i64, expiry: i64) -> bool { + now >= expiry +} diff --git a/finance/options/anchor/programs/options/src/errors.rs b/finance/options/anchor/programs/options/src/errors.rs new file mode 100644 index 00000000..2f2b4655 --- /dev/null +++ b/finance/options/anchor/programs/options/src/errors.rs @@ -0,0 +1,34 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum OptionsError { + #[msg("Market or option parameter is outside the allowed range")] + InvalidParameter, + + #[msg("Option expiry must be in the future")] + ExpiryInPast, + + #[msg("Arithmetic overflow")] + MathOverflow, + + #[msg("Option is not listed for sale: it has been sold or exercised")] + OptionNotListed, + + #[msg("Option has expired: it can no longer be bought or exercised")] + OptionExpired, + + #[msg("Option has no holder: it is unsold or already exercised")] + OptionNotHeld, + + #[msg("Option has not expired: the holder may still exercise it")] + OptionNotExpired, + + #[msg("Option has not been exercised: there are no proceeds to collect")] + OptionNotExercised, + + #[msg("No fees are available to collect")] + NothingToCollect, + + #[msg("Vault balance would fall below what the market owes")] + CustodyInvariantViolated, +} diff --git a/finance/options/anchor/programs/options/src/instructions/buy_option.rs b/finance/options/anchor/programs/options/src/instructions/buy_option.rs new file mode 100644 index 00000000..b7b88c2b --- /dev/null +++ b/finance/options/anchor/programs/options/src/instructions/buy_option.rs @@ -0,0 +1,125 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::constants::{MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED}; +use crate::contract_math; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_signer}; +use crate::state::{Market, OptionContract, OptionStatus}; + +/// Buy a listed option. The premium is the only money that changes hands: the +/// venue's fee comes out of it into the quote vault, and the rest goes +/// straight to the writer, whose money it is from this moment whatever the +/// holder later does. The collateral does not move. +pub fn handle_buy_option(context: &mut Context) -> Result<()> { + let option = &mut context.accounts.option; + require!( + option.status == OptionStatus::Listed, + OptionsError::OptionNotListed + ); + // An option nobody can exercise any more is not for sale. + let now = Clock::get()?.unix_timestamp; + require!( + contract_math::may_exercise(now, option.expiry), + OptionsError::OptionExpired + ); + + let market = &mut context.accounts.market; + let (fee, to_writer) = contract_math::split_premium(option.premium, market.fee_bps) + .ok_or(OptionsError::MathOverflow)?; + + // Effects before the transfers. + option.holder = *context.accounts.buyer.address(); + option.status = OptionStatus::Held; + market.fees_owed = market + .fees_owed + .checked_add(fee) + .ok_or(OptionsError::MathOverflow)?; + let quote_after = context + .accounts + .quote_vault + .amount() + .checked_add(fee) + .ok_or(OptionsError::MathOverflow)?; + check_custody(market, market.underlying_locked, quote_after)?; + + transfer_from_signer( + &context.accounts.token_program, + &mut context.accounts.buyer_quote, + &context.accounts.quote_mint, + &mut context.accounts.writer_quote, + &context.accounts.buyer, + to_writer, + )?; + if fee > 0 { + transfer_from_signer( + &context.accounts.token_program, + &mut context.accounts.buyer_quote, + &context.accounts.quote_mint, + &mut context.accounts.quote_vault, + &context.accounts.buyer, + fee, + )?; + } + + Ok(()) +} + +#[derive(Accounts)] +pub struct BuyOptionAccountConstraints { + #[account(mut)] + pub buyer: Signer, + + /// CHECK: the writer, bound by `address = option.writer`; only used to + /// derive the token account the premium is paid into. + #[account(address = option.writer)] + pub writer: UncheckedAccount, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + address = option.market, + )] + pub market: Box>, + + #[account( + mut, + seeds = [OPTION_SEED, market.address().as_ref(), writer.address().as_ref(), option.id.to_le_bytes()], + bump = option.bump, + )] + pub option: Box>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.address().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = buyer, + associated_token::token_program = token_program, + )] + pub buyer_quote: Box>, + + // Created by `write_option`, at the writer's expense, so the buyer never + // pays rent on the writer's behalf. A writer buying their own option would + // put this account and `buyer_quote` in two mutable slots at once, which + // the loader rejects, so a writer cannot pay themselves a premium. + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_quote: Box>, + + pub token_program: Interface<'static, TokenInterface>, +} diff --git a/finance/options/anchor/programs/options/src/instructions/cancel_option.rs b/finance/options/anchor/programs/options/src/instructions/cancel_option.rs new file mode 100644 index 00000000..b03ecf20 --- /dev/null +++ b/finance/options/anchor/programs/options/src/instructions/cancel_option.rs @@ -0,0 +1,146 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::constants::{ + AUTHORITY_SEED, MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED, +}; +use crate::contract_math; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_vault}; +use crate::state::{Market, OptionContract, OptionKind, OptionStatus}; + +/// Withdraw an unsold option. Without this, an option nobody buys would hold the +/// writer's collateral forever. Any time is fine, including after expiry: an +/// unsold option has no holder whose rights could be cut short. +pub fn handle_cancel_option(context: &mut Context) -> Result<()> { + let option = &context.accounts.option; + require!( + option.status == OptionStatus::Listed, + OptionsError::OptionNotListed + ); + + let collateral = contract_math::collateral_amount( + option.kind, + option.contracts, + option.underlying_per_contract, + option.strike_per_contract, + ) + .ok_or(OptionsError::MathOverflow)?; + let kind = option.kind; + + let market = &mut context.accounts.market; + let mut underlying_after = context.accounts.underlying_vault.amount(); + let mut quote_after = context.accounts.quote_vault.amount(); + match kind { + OptionKind::Call => { + market.underlying_locked = market + .underlying_locked + .checked_sub(collateral) + .ok_or(OptionsError::MathOverflow)?; + underlying_after = underlying_after + .checked_sub(collateral) + .ok_or(OptionsError::CustodyInvariantViolated)?; + } + OptionKind::Put => { + market.quote_locked = market + .quote_locked + .checked_sub(collateral) + .ok_or(OptionsError::MathOverflow)?; + quote_after = quote_after + .checked_sub(collateral) + .ok_or(OptionsError::CustodyInvariantViolated)?; + } + } + check_custody(market, underlying_after, quote_after)?; + + match kind { + OptionKind::Call => transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.underlying_vault, + &context.accounts.underlying_mint, + &mut context.accounts.writer_underlying, + &context.accounts.market_authority, + market, + collateral, + ), + OptionKind::Put => transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.quote_vault, + &context.accounts.quote_mint, + &mut context.accounts.writer_quote, + &context.accounts.market_authority, + market, + collateral, + ), + } + // The option closes to the writer through `close = writer`. +} + +#[derive(Accounts)] +pub struct CancelOptionAccountConstraints { + #[account(mut, address = option.writer)] + pub writer: Signer, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + address = option.market, + )] + pub market: Box>, + + #[account( + mut, + close = writer, + seeds = [OPTION_SEED, market.address().as_ref(), writer.address().as_ref(), option.id.to_le_bytes()], + bump = option.bump, + )] + pub option: Box>, + + /// CHECK: PDA authority over both vaults; holds no data, only signs. + #[account( + seeds = [AUTHORITY_SEED, market.address().as_ref()], + bump = market.authority_bump, + )] + pub market_authority: UncheckedAccount, + + #[account(address = market.underlying_mint)] + pub underlying_mint: Box>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + #[account( + mut, + seeds = [UNDERLYING_VAULT_SEED, market.address().as_ref()], + bump, + address = market.underlying_vault, + )] + pub underlying_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.address().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + #[account( + mut, + associated_token::mint = underlying_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_underlying: Box>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_quote: Box>, + + pub token_program: Interface<'static, TokenInterface>, +} diff --git a/finance/options/anchor/programs/options/src/instructions/collect_fees.rs b/finance/options/anchor/programs/options/src/instructions/collect_fees.rs new file mode 100644 index 00000000..bc482efd --- /dev/null +++ b/finance/options/anchor/programs/options/src/instructions/collect_fees.rs @@ -0,0 +1,91 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::constants::{AUTHORITY_SEED, MARKET_SEED, QUOTE_VAULT_SEED}; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_vault}; +use crate::state::Market; + +/// The admin sweeps the fees the venue has earned on premiums. `fees_owed` +/// is the only part of the quote vault the admin can reach: the collateral +/// and strike payments beside it are locked to their writers and holders. +pub fn handle_collect_fees(context: &mut Context) -> Result<()> { + let market = &mut context.accounts.market; + let amount = market.fees_owed; + require!(amount > 0, OptionsError::NothingToCollect); + + // Effects before the transfer: zero the balance, then pay it out. + market.fees_owed = 0; + let quote_after = context + .accounts + .quote_vault + .amount() + .checked_sub(amount) + .ok_or(OptionsError::CustodyInvariantViolated)?; + check_custody( + market, + context.accounts.underlying_vault.amount(), + quote_after, + )?; + + transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.quote_vault, + &context.accounts.quote_mint, + &mut context.accounts.admin_quote, + &context.accounts.market_authority, + market, + amount, + ) +} + +#[derive(Accounts)] +pub struct CollectFeesAccountConstraints { + #[account(mut, address = market.admin)] + pub admin: Signer, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + )] + pub market: Box>, + + /// CHECK: PDA authority over both vaults; holds no data, only signs. + #[account( + seeds = [AUTHORITY_SEED, market.address().as_ref()], + bump = market.authority_bump, + )] + pub market_authority: UncheckedAccount, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + // Read only, for the custody check: the invariant covers both vaults. + #[account(address = market.underlying_vault)] + pub underlying_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.address().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + #[account( + init_if_needed, + payer = admin, + associated_token::mint = quote_mint, + associated_token::authority = admin, + associated_token::token_program = token_program, + )] + pub admin_quote: Box>, + + pub token_program: Interface<'static, TokenInterface>, + pub associated_token_program: Program, + pub system_program: Program, +} diff --git a/finance/options/anchor/programs/options/src/instructions/collect_proceeds.rs b/finance/options/anchor/programs/options/src/instructions/collect_proceeds.rs new file mode 100644 index 00000000..8c891cad --- /dev/null +++ b/finance/options/anchor/programs/options/src/instructions/collect_proceeds.rs @@ -0,0 +1,158 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::constants::{ + AUTHORITY_SEED, MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED, +}; +use crate::contract_math; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_vault}; +use crate::state::{Market, OptionContract, OptionKind, OptionStatus}; + +/// The writer collects what the holder paid at exercise: the strike for a +/// call, the underlying for a put. The option closes, rent back to +/// the writer who paid it. +pub fn handle_collect_proceeds( + context: &mut Context, +) -> Result<()> { + let option = &context.accounts.option; + require!( + option.status == OptionStatus::Exercised, + OptionsError::OptionNotExercised + ); + + let kind = option.kind; + let proceeds = contract_math::exercise_payment( + kind, + option.contracts, + option.underlying_per_contract, + option.strike_per_contract, + ) + .ok_or(OptionsError::MathOverflow)?; + + let market = &mut context.accounts.market; + let mut underlying_after = context.accounts.underlying_vault.amount(); + let mut quote_after = context.accounts.quote_vault.amount(); + match kind { + // A call's proceeds are the strike, in the quote token. + OptionKind::Call => { + market.quote_locked = market + .quote_locked + .checked_sub(proceeds) + .ok_or(OptionsError::MathOverflow)?; + quote_after = quote_after + .checked_sub(proceeds) + .ok_or(OptionsError::CustodyInvariantViolated)?; + } + // A put's proceeds are the delivered underlying. + OptionKind::Put => { + market.underlying_locked = market + .underlying_locked + .checked_sub(proceeds) + .ok_or(OptionsError::MathOverflow)?; + underlying_after = underlying_after + .checked_sub(proceeds) + .ok_or(OptionsError::CustodyInvariantViolated)?; + } + } + check_custody(market, underlying_after, quote_after)?; + + match kind { + OptionKind::Call => transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.quote_vault, + &context.accounts.quote_mint, + &mut context.accounts.writer_quote, + &context.accounts.market_authority, + market, + proceeds, + ), + OptionKind::Put => transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.underlying_vault, + &context.accounts.underlying_mint, + &mut context.accounts.writer_underlying, + &context.accounts.market_authority, + market, + proceeds, + ), + } + // The option closes to the writer through `close = writer`. +} + +#[derive(Accounts)] +pub struct CollectProceedsAccountConstraints { + #[account(mut, address = option.writer)] + pub writer: Signer, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + address = option.market, + )] + pub market: Box>, + + #[account( + mut, + close = writer, + seeds = [OPTION_SEED, market.address().as_ref(), writer.address().as_ref(), option.id.to_le_bytes()], + bump = option.bump, + )] + pub option: Box>, + + /// CHECK: PDA authority over both vaults; holds no data, only signs. + #[account( + seeds = [AUTHORITY_SEED, market.address().as_ref()], + bump = market.authority_bump, + )] + pub market_authority: UncheckedAccount, + + #[account(address = market.underlying_mint)] + pub underlying_mint: Box>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + #[account( + mut, + seeds = [UNDERLYING_VAULT_SEED, market.address().as_ref()], + bump, + address = market.underlying_vault, + )] + pub underlying_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.address().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + // A put writer is paid in the underlying, which they may never have held, + // so the account is created if needed, at the writer's expense. + #[account( + init_if_needed, + payer = writer, + associated_token::mint = underlying_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_underlying: Box>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_quote: Box>, + + pub token_program: Interface<'static, TokenInterface>, + pub associated_token_program: Program, + pub system_program: Program, +} diff --git a/finance/options/anchor/programs/options/src/instructions/exercise_option.rs b/finance/options/anchor/programs/options/src/instructions/exercise_option.rs new file mode 100644 index 00000000..6daaa3d0 --- /dev/null +++ b/finance/options/anchor/programs/options/src/instructions/exercise_option.rs @@ -0,0 +1,213 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::constants::{ + AUTHORITY_SEED, MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED, +}; +use crate::contract_math; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_signer, transfer_from_vault}; +use crate::state::{Market, OptionContract, OptionKind, OptionStatus}; + +/// Exercise a held option before expiry. A call holder pays the strike in the +/// quote token and takes the underlying; a put holder delivers the underlying +/// and takes the strike. The payment stays in the vault, owed to the writer, +/// until they call `collect_proceeds`; paying the writer directly would let +/// a writer who closed their token account block the exercise. +/// +/// No price is read. Whether exercising is worth it is the holder's decision, +/// made against whatever the market is doing offchain; the program only +/// enforces the terms. +pub fn handle_exercise_option( + context: &mut Context, +) -> Result<()> { + let option = &mut context.accounts.option; + require!( + option.status == OptionStatus::Held, + OptionsError::OptionNotHeld + ); + // The holder may exercise while now < expiry. + let now = Clock::get()?.unix_timestamp; + require!( + contract_math::may_exercise(now, option.expiry), + OptionsError::OptionExpired + ); + + 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)?; + + // Effects: the option is exercised, and the vault now owes the writer the + // payment instead of owing the holder the collateral. + option.status = OptionStatus::Exercised; + + let market = &mut context.accounts.market; + let underlying_before = context.accounts.underlying_vault.amount(); + let quote_before = context.accounts.quote_vault.amount(); + let (underlying_after, quote_after) = match kind { + OptionKind::Call => { + market.underlying_locked = market + .underlying_locked + .checked_sub(underlying_total) + .ok_or(OptionsError::MathOverflow)?; + market.quote_locked = market + .quote_locked + .checked_add(strike_total) + .ok_or(OptionsError::MathOverflow)?; + ( + underlying_before + .checked_sub(underlying_total) + .ok_or(OptionsError::CustodyInvariantViolated)?, + quote_before + .checked_add(strike_total) + .ok_or(OptionsError::MathOverflow)?, + ) + } + OptionKind::Put => { + market.quote_locked = market + .quote_locked + .checked_sub(strike_total) + .ok_or(OptionsError::MathOverflow)?; + market.underlying_locked = market + .underlying_locked + .checked_add(underlying_total) + .ok_or(OptionsError::MathOverflow)?; + ( + underlying_before + .checked_add(underlying_total) + .ok_or(OptionsError::MathOverflow)?, + quote_before + .checked_sub(strike_total) + .ok_or(OptionsError::CustodyInvariantViolated)?, + ) + } + }; + check_custody(market, underlying_after, quote_after)?; + + // The holder pays in, then the vault pays out, atomically or not at all. + match kind { + OptionKind::Call => { + transfer_from_signer( + &context.accounts.token_program, + &mut context.accounts.holder_quote, + &context.accounts.quote_mint, + &mut context.accounts.quote_vault, + &context.accounts.holder, + strike_total, + )?; + transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.underlying_vault, + &context.accounts.underlying_mint, + &mut context.accounts.holder_underlying, + &context.accounts.market_authority, + market, + underlying_total, + ) + } + OptionKind::Put => { + transfer_from_signer( + &context.accounts.token_program, + &mut context.accounts.holder_underlying, + &context.accounts.underlying_mint, + &mut context.accounts.underlying_vault, + &context.accounts.holder, + underlying_total, + )?; + transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.quote_vault, + &context.accounts.quote_mint, + &mut context.accounts.holder_quote, + &context.accounts.market_authority, + market, + strike_total, + ) + } + } +} + +#[derive(Accounts)] +pub struct ExerciseOptionAccountConstraints { + #[account(mut, address = option.holder)] + pub holder: Signer, + + /// CHECK: the writer, bound by `address = option.writer`; a seed of the + /// option PDA. + #[account(address = option.writer)] + pub writer: UncheckedAccount, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + address = option.market, + )] + pub market: Box>, + + #[account( + mut, + seeds = [OPTION_SEED, market.address().as_ref(), writer.address().as_ref(), option.id.to_le_bytes()], + bump = option.bump, + )] + pub option: Box>, + + /// CHECK: PDA authority over both vaults; holds no data, only signs. + #[account( + seeds = [AUTHORITY_SEED, market.address().as_ref()], + bump = market.authority_bump, + )] + pub market_authority: UncheckedAccount, + + #[account(address = market.underlying_mint)] + pub underlying_mint: Box>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + #[account( + mut, + seeds = [UNDERLYING_VAULT_SEED, market.address().as_ref()], + bump, + address = market.underlying_vault, + )] + pub underlying_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.address().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + // A call holder receives into this account and may never have held the + // underlying before, so it is created if needed, at the holder's expense. + #[account( + init_if_needed, + payer = holder, + associated_token::mint = underlying_mint, + associated_token::authority = holder, + associated_token::token_program = token_program, + )] + pub holder_underlying: Box>, + + #[account( + init_if_needed, + payer = holder, + associated_token::mint = quote_mint, + associated_token::authority = holder, + associated_token::token_program = token_program, + )] + pub holder_quote: Box>, + + pub token_program: Interface<'static, TokenInterface>, + pub associated_token_program: Program, + pub system_program: Program, +} diff --git a/finance/options/anchor/programs/options/src/instructions/initialize_market.rs b/finance/options/anchor/programs/options/src/instructions/initialize_market.rs new file mode 100644 index 00000000..0ede75d7 --- /dev/null +++ b/finance/options/anchor/programs/options/src/instructions/initialize_market.rs @@ -0,0 +1,96 @@ +use anchor_lang::prelude::*; +use anchor_spl::token; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::constants::{ + AUTHORITY_SEED, BASIS_POINTS_DENOMINATOR, MARKET_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED, +}; +use crate::errors::OptionsError; +use crate::state::Market; + +pub fn handle_initialize_market( + context: &mut Context, + fee_bps: u16, +) -> Result<()> { + // Options on a token settled in the same token are meaningless. + require_keys_neq!( + context.accounts.underlying_mint.address(), + context.accounts.quote_mint.address(), + OptionsError::InvalidParameter + ); + // A fee of 100% or more would leave the writer nothing from a sale. Zero + // is allowed: a venue run at cost is a valid choice. + require!( + fee_bps < BASIS_POINTS_DENOMINATOR as u16, + OptionsError::InvalidParameter + ); + + let market = &mut context.accounts.market; + market.admin = *context.accounts.admin.address(); + market.underlying_mint = *context.accounts.underlying_mint.address(); + market.quote_mint = *context.accounts.quote_mint.address(); + market.underlying_vault = *context.accounts.underlying_vault.address(); + market.quote_vault = *context.accounts.quote_vault.address(); + market.underlying_locked = 0; + market.quote_locked = 0; + market.fees_owed = 0; + market.fee_bps = fee_bps; + market.bump = context.bumps.market; + market.authority_bump = context.bumps.market_authority; + + Ok(()) +} + +#[derive(Accounts)] +pub struct InitializeMarketAccountConstraints { + #[account(mut)] + pub admin: Signer, + + // One venue per pair, so every option on NVDAx settled in USDC shares the + // two vaults and the one fee schedule. + #[account( + init, + payer = admin, + space = Market::DISCRIMINATOR.len() + Market::INIT_SPACE, + seeds = [MARKET_SEED, underlying_mint.address().as_ref(), quote_mint.address().as_ref()], + bump, + )] + pub market: Box>, + + pub underlying_mint: Box>, + + pub quote_mint: Box>, + + /// CHECK: PDA that owns both vaults. Holds no data; used only to sign + /// vault CPIs. + #[account( + seeds = [AUTHORITY_SEED, market.address().as_ref()], + bump, + )] + pub market_authority: UncheckedAccount, + + #[account( + init, + payer = admin, + seeds = [UNDERLYING_VAULT_SEED, market.address().as_ref()], + bump, + token::mint = underlying_mint, + token::authority = market_authority, + token::token_program = token_program, + )] + pub underlying_vault: Box>, + + #[account( + init, + payer = admin, + seeds = [QUOTE_VAULT_SEED, market.address().as_ref()], + bump, + token::mint = quote_mint, + token::authority = market_authority, + token::token_program = token_program, + )] + pub quote_vault: Box>, + + pub token_program: Interface<'static, TokenInterface>, + pub system_program: Program, +} diff --git a/finance/options/anchor/programs/options/src/instructions/mod.rs b/finance/options/anchor/programs/options/src/instructions/mod.rs new file mode 100644 index 00000000..f94ca097 --- /dev/null +++ b/finance/options/anchor/programs/options/src/instructions/mod.rs @@ -0,0 +1,18 @@ +pub mod buy_option; +pub mod cancel_option; +pub mod collect_fees; +pub mod collect_proceeds; +pub mod exercise_option; +pub mod initialize_market; +pub mod reclaim_collateral; +pub mod shared; +pub mod write_option; + +pub use buy_option::*; +pub use cancel_option::*; +pub use collect_fees::*; +pub use collect_proceeds::*; +pub use exercise_option::*; +pub use initialize_market::*; +pub use reclaim_collateral::*; +pub use write_option::*; diff --git a/finance/options/anchor/programs/options/src/instructions/reclaim_collateral.rs b/finance/options/anchor/programs/options/src/instructions/reclaim_collateral.rs new file mode 100644 index 00000000..23ef799f --- /dev/null +++ b/finance/options/anchor/programs/options/src/instructions/reclaim_collateral.rs @@ -0,0 +1,155 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::constants::{ + AUTHORITY_SEED, MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED, +}; +use crate::contract_math; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_vault}; +use crate::state::{Market, OptionContract, OptionKind, OptionStatus}; + +/// The holder let the option expire, so the writer takes the collateral +/// back. The premium was the writer's the moment it was paid; this is the +/// other half of a writer's return. The option closes. +pub fn handle_reclaim_collateral( + context: &mut Context, +) -> Result<()> { + let option = &context.accounts.option; + require!( + option.status == OptionStatus::Held, + OptionsError::OptionNotHeld + ); + // The writer may reclaim once now >= expiry: the complement of the + // holder's window, so the two can never both claim the collateral. + let now = Clock::get()?.unix_timestamp; + require!( + contract_math::may_reclaim(now, option.expiry), + OptionsError::OptionNotExpired + ); + + 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 market = &mut context.accounts.market; + let mut underlying_after = context.accounts.underlying_vault.amount(); + let mut quote_after = context.accounts.quote_vault.amount(); + match kind { + OptionKind::Call => { + market.underlying_locked = market + .underlying_locked + .checked_sub(collateral) + .ok_or(OptionsError::MathOverflow)?; + underlying_after = underlying_after + .checked_sub(collateral) + .ok_or(OptionsError::CustodyInvariantViolated)?; + } + OptionKind::Put => { + market.quote_locked = market + .quote_locked + .checked_sub(collateral) + .ok_or(OptionsError::MathOverflow)?; + quote_after = quote_after + .checked_sub(collateral) + .ok_or(OptionsError::CustodyInvariantViolated)?; + } + } + check_custody(market, underlying_after, quote_after)?; + + match kind { + OptionKind::Call => transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.underlying_vault, + &context.accounts.underlying_mint, + &mut context.accounts.writer_underlying, + &context.accounts.market_authority, + market, + collateral, + ), + OptionKind::Put => transfer_from_vault( + &context.accounts.token_program, + &mut context.accounts.quote_vault, + &context.accounts.quote_mint, + &mut context.accounts.writer_quote, + &context.accounts.market_authority, + market, + collateral, + ), + } + // The option closes to the writer through `close = writer`. +} + +#[derive(Accounts)] +pub struct ReclaimCollateralAccountConstraints { + #[account(mut, address = option.writer)] + pub writer: Signer, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + address = option.market, + )] + pub market: Box>, + + #[account( + mut, + close = writer, + seeds = [OPTION_SEED, market.address().as_ref(), writer.address().as_ref(), option.id.to_le_bytes()], + bump = option.bump, + )] + pub option: Box>, + + /// CHECK: PDA authority over both vaults; holds no data, only signs. + #[account( + seeds = [AUTHORITY_SEED, market.address().as_ref()], + bump = market.authority_bump, + )] + pub market_authority: UncheckedAccount, + + #[account(address = market.underlying_mint)] + pub underlying_mint: Box>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + #[account( + mut, + seeds = [UNDERLYING_VAULT_SEED, market.address().as_ref()], + bump, + address = market.underlying_vault, + )] + pub underlying_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.address().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + #[account( + mut, + associated_token::mint = underlying_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_underlying: Box>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_quote: Box>, + + pub token_program: Interface<'static, TokenInterface>, +} diff --git a/finance/options/anchor/programs/options/src/instructions/shared.rs b/finance/options/anchor/programs/options/src/instructions/shared.rs new file mode 100644 index 00000000..47438da9 --- /dev/null +++ b/finance/options/anchor/programs/options/src/instructions/shared.rs @@ -0,0 +1,84 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::constants::AUTHORITY_SEED; +use crate::errors::OptionsError; +use crate::state::Market; + +/// The custody invariant, asserted after the math in every handler that moves +/// tokens: each vault covers what the market owes. `underlying_after` and +/// `quote_after` are the vault balances the handler's transfers will leave +/// behind, computed from the balances read before any CPI ran. +pub fn check_custody(market: &Market, underlying_after: u64, quote_after: u64) -> Result<()> { + require!( + underlying_after >= market.underlying_locked, + OptionsError::CustodyInvariantViolated + ); + let quote_owed = market + .quote_locked + .checked_add(market.fees_owed) + .ok_or(OptionsError::MathOverflow)?; + require!( + quote_after >= quote_owed, + OptionsError::CustodyInvariantViolated + ); + Ok(()) +} + +/// A signer-authorized transfer into one of the vaults, or from one party to +/// another (the premium goes straight from buyer to writer). +pub fn transfer_from_signer( + token_program: &Interface<'static, TokenInterface>, + from: &mut InterfaceAccount, + mint: &InterfaceAccount, + to: &mut InterfaceAccount, + signer: &Signer, + amount: u64, +) -> Result<()> { + transfer_checked( + CpiContext::new( + token_program.address(), + TransferChecked { + from: from.to_cpi_handle_mut(), + mint: mint.to_cpi_handle(), + to: to.to_cpi_handle_mut(), + authority: signer.cpi_handle(), + }, + ), + amount, + mint.decimals(), + ) +} + +/// A transfer out of a vault, signed by the market's vault authority PDA. +/// Takes the market by reference for its address and authority bump, so the +/// caller must have finished mutating it (it has: effects come before CPIs). +pub fn transfer_from_vault( + token_program: &Interface<'static, TokenInterface>, + vault: &mut InterfaceAccount, + mint: &InterfaceAccount, + to: &mut InterfaceAccount, + market_authority: &UncheckedAccount, + market: &BorshAccount, + amount: u64, +) -> Result<()> { + let market_key = market.address(); + let bump = [market.authority_bump]; + let authority_seeds: &[&[u8]] = &[AUTHORITY_SEED, market_key.as_ref(), &bump]; + transfer_checked( + CpiContext::new_with_signer( + token_program.address(), + TransferChecked { + from: vault.to_cpi_handle_mut(), + mint: mint.to_cpi_handle(), + to: to.to_cpi_handle_mut(), + authority: market_authority.cpi_handle(), + }, + &[authority_seeds], + ), + amount, + mint.decimals(), + ) +} diff --git a/finance/options/anchor/programs/options/src/instructions/write_option.rs b/finance/options/anchor/programs/options/src/instructions/write_option.rs new file mode 100644 index 00000000..92dbb690 --- /dev/null +++ b/finance/options/anchor/programs/options/src/instructions/write_option.rs @@ -0,0 +1,209 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::constants::{MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED}; +use crate::contract_math; +use crate::errors::OptionsError; +use crate::instructions::shared::{check_custody, transfer_from_signer}; +use crate::state::{Market, OptionContract, OptionKind, OptionStatus}; + +/// The terms of an option, chosen by the writer. Bundled into one struct so the +/// instruction signature stays readable. +#[derive(Clone, Copy, IdlType, wincode::SchemaRead, wincode::SchemaWrite)] +pub struct OptionTerms { + pub kind: OptionKind, + + /// 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, + + /// 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 the buyer pays the writer for the whole option. + pub premium: u64, + + /// Unix timestamp after which the holder can no longer exercise. + pub expiry: i64, +} + +/// Write an option. The writer posts the entire collateral up front: +/// the underlying for a call, the strike in the quote token for a put. From +/// this moment the vault holds everything a future holder could claim, which +/// is why nothing in this program ever has to be liquidated. +pub fn handle_write_option( + context: &mut Context, + id: u64, + terms: OptionTerms, +) -> Result<()> { + let OptionTerms { + kind, + contracts, + underlying_per_contract, + strike_per_contract, + 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. + require!( + contracts > 0 && underlying_per_contract > 0 && strike_per_contract > 0 && premium > 0, + OptionsError::InvalidParameter + ); + // Written in words: the holder may exercise while now < expiry. An expiry + // at or before now would create an option nobody could ever exercise. + 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, + }; + + // Effects before the transfer: record the option and what the vault now owes. + let option = &mut context.accounts.option; + option.id = id; + option.market = *context.accounts.market.address(); + option.writer = *context.accounts.writer.address(); + 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.premium = premium; + option.expiry = expiry; + option.bump = context.bumps.option; + + let market = &mut context.accounts.market; + let mut underlying_after = context.accounts.underlying_vault.amount(); + let mut quote_after = context.accounts.quote_vault.amount(); + match kind { + OptionKind::Call => { + market.underlying_locked = market + .underlying_locked + .checked_add(collateral) + .ok_or(OptionsError::MathOverflow)?; + underlying_after = underlying_after + .checked_add(collateral) + .ok_or(OptionsError::MathOverflow)?; + } + OptionKind::Put => { + market.quote_locked = market + .quote_locked + .checked_add(collateral) + .ok_or(OptionsError::MathOverflow)?; + quote_after = quote_after + .checked_add(collateral) + .ok_or(OptionsError::MathOverflow)?; + } + } + check_custody(market, underlying_after, quote_after)?; + + match kind { + OptionKind::Call => transfer_from_signer( + &context.accounts.token_program, + &mut context.accounts.writer_underlying, + &context.accounts.underlying_mint, + &mut context.accounts.underlying_vault, + &context.accounts.writer, + collateral, + ), + OptionKind::Put => transfer_from_signer( + &context.accounts.token_program, + &mut context.accounts.writer_quote, + &context.accounts.quote_mint, + &mut context.accounts.quote_vault, + &context.accounts.writer, + collateral, + ), + } +} + +#[derive(Accounts)] +// The leading underscore is for rustc: `#[derive(Accounts)]` expands +// `_id` into a path that never reads it, so the plain name warns as +// unused. The `seeds` expression below is the real use. +#[instruction(_id: u64)] +pub struct WriteOptionAccountConstraints { + #[account(mut)] + pub writer: Signer, + + #[account( + mut, + seeds = [MARKET_SEED, market.underlying_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + )] + pub market: Box>, + + #[account( + init, + payer = writer, + space = OptionContract::DISCRIMINATOR.len() + OptionContract::INIT_SPACE, + seeds = [OPTION_SEED, market.address().as_ref(), writer.address().as_ref(), _id.to_le_bytes()], + bump, + )] + pub option: Box>, + + #[account(address = market.underlying_mint)] + pub underlying_mint: Box>, + + #[account(address = market.quote_mint)] + pub quote_mint: Box>, + + #[account( + mut, + seeds = [UNDERLYING_VAULT_SEED, market.address().as_ref()], + bump, + address = market.underlying_vault, + )] + pub underlying_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.address().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + // A call writer pays collateral from this account; a put writer's copy + // is only validated. + #[account( + mut, + associated_token::mint = underlying_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_underlying: Box>, + + // A put writer pays collateral from this account, and every writer is + // paid their premium into it by `buy_option`, which requires it to exist. + // Created here if needed, paid for by the writer, so the party who chose + // to list carries the rent rather than the buyer. + #[account( + init_if_needed, + payer = writer, + associated_token::mint = quote_mint, + associated_token::authority = writer, + associated_token::token_program = token_program, + )] + pub writer_quote: Box>, + + pub token_program: Interface<'static, TokenInterface>, + pub associated_token_program: Program, + pub system_program: Program, +} diff --git a/finance/options/anchor/programs/options/src/lib.rs b/finance/options/anchor/programs/options/src/lib.rs new file mode 100644 index 00000000..5557c6ab --- /dev/null +++ b/finance/options/anchor/programs/options/src/lib.rs @@ -0,0 +1,89 @@ +use anchor_lang::prelude::*; + +mod constants; +mod errors; +// Public so the LiteSVM integration tests can build instruction arguments +// (`OptionTerms`, `OptionKind`) against the program's own types, and the +// proofs crate's README can point at the formulas it mirrors. +pub mod contract_math; +pub mod instructions; +pub mod state; + +use instructions::*; + +declare_id!("2gmMGMmipfYypLxWsvQ5GQJT5AGnMWmk4Rb9vQMRo6ig"); + +/// A fully collateralized, physically settled options venue. +/// +/// A writer posts the whole of what the holder could ever claim (the +/// underlying for a call, the strike in the quote token for a put) and names a +/// premium; a buyer pays the premium and becomes the holder; the holder may +/// exercise at any time before expiry, paying the other side of the trade and +/// taking the collateral; after expiry the writer reclaims whatever was not +/// exercised. Because the collateral is always in the vault, no position can +/// ever be under water, so there is no margin, no liquidator, and no oracle. +/// Each option is one account, bought and exercised as a whole. +#[program] +pub mod options { + use super::*; + + /// Create a venue for one underlying/quote pair. The signer becomes the + /// admin: the only party who can sweep the venue's fees, and a party who + /// can do nothing else. + pub fn initialize_market( + context: &mut Context, + fee_bps: u16, + ) -> Result<()> { + instructions::handle_initialize_market(context, fee_bps) + } + + /// Write an option: post the full collateral and list it at + /// the premium in `terms`. `id` is chosen by the writer so they can have + /// many open. + pub fn write_option( + context: &mut Context, + id: u64, + terms: OptionTerms, + ) -> Result<()> { + instructions::handle_write_option(context, id, terms) + } + + /// Buy a listed option: pay the premium (the venue's fee comes out of it, + /// the rest goes to the writer) and become the holder. + pub fn buy_option(context: &mut Context) -> Result<()> { + instructions::handle_buy_option(context) + } + + /// Writer withdraws an unsold option: collateral back, account closed. + pub fn cancel_option(context: &mut Context) -> Result<()> { + instructions::handle_cancel_option(context) + } + + /// Holder exercises before expiry: pays the other side of the trade into + /// the vault and takes the collateral. Physical settlement: the tokens + /// change hands, and no price feed is consulted. + pub fn exercise_option(context: &mut Context) -> Result<()> { + instructions::handle_exercise_option(context) + } + + /// Writer collects what the holder paid at exercise, and the account + /// closes. + pub fn collect_proceeds( + context: &mut Context, + ) -> Result<()> { + instructions::handle_collect_proceeds(context) + } + + /// Writer reclaims the collateral of a sold option the holder let expire, and + /// the account closes. The premium was theirs the moment it was paid. + pub fn reclaim_collateral( + context: &mut Context, + ) -> Result<()> { + instructions::handle_reclaim_collateral(context) + } + + /// Admin sweeps the accumulated premium fees from the quote vault. + pub fn collect_fees(context: &mut Context) -> Result<()> { + instructions::handle_collect_fees(context) + } +} diff --git a/finance/options/anchor/programs/options/src/state/market.rs b/finance/options/anchor/programs/options/src/state/market.rs new file mode 100644 index 00000000..cb251cda --- /dev/null +++ b/finance/options/anchor/programs/options/src/state/market.rs @@ -0,0 +1,49 @@ +use anchor_lang::prelude::*; + +/// One options venue: every option written here is on the same underlying +/// token, exercised in the same quote token, and collateralized in one of the +/// two vaults. +/// +/// The vaults hold other people's money (writers' collateral, and the strike +/// payments holders make at exercise), so the two `*_locked` fields say how +/// much of each vault the market owes and to whom it is owed in aggregate. +/// Every handler that moves tokens asserts, after its own arithmetic, that +/// each vault still covers what the market owes. +#[account(borsh)] +#[derive(InitSpace)] +pub struct Market { + /// Operates the venue and collects the fee on every premium. Cannot touch + /// collateral or strike payments: no handler moves either to the admin. + pub admin: Address, + + /// The asset the options are written on (NVDAx in the walkthrough). + pub underlying_mint: Address, + + /// The asset premiums are paid in and strikes are settled in (USDC). + pub quote_mint: Address, + + pub underlying_vault: Address, + + pub quote_vault: Address, + + /// Underlying minor units the vault owes: call writers' collateral, plus + /// put holders' deliveries awaiting the writer's `collect_proceeds`. + pub underlying_locked: u64, + + /// Quote minor units the vault owes: put writers' collateral, plus call + /// holders' strike payments awaiting the writer's `collect_proceeds`. + pub quote_locked: u64, + + /// Quote minor units held in the quote vault for the admin, accrued from + /// the fee on each premium and swept by `collect_fees`. + pub fees_owed: u64, + + /// Fee charged on each premium, in basis points. The venue's revenue. + pub fee_bps: u16, + + pub bump: u8, + + /// Bump for the vault authority PDA, stored so CPIs can sign without + /// re-deriving it. + pub authority_bump: u8, +} diff --git a/finance/options/anchor/programs/options/src/state/mod.rs b/finance/options/anchor/programs/options/src/state/mod.rs new file mode 100644 index 00000000..47cb590a --- /dev/null +++ b/finance/options/anchor/programs/options/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod market; +pub mod option_contract; + +pub use market::*; +pub use option_contract::*; diff --git a/finance/options/anchor/programs/options/src/state/option_contract.rs b/finance/options/anchor/programs/options/src/state/option_contract.rs new file mode 100644 index 00000000..d25d77a6 --- /dev/null +++ b/finance/options/anchor/programs/options/src/state/option_contract.rs @@ -0,0 +1,80 @@ +use anchor_lang::prelude::*; + +/// Which right the holder buys. +#[derive( + InitSpace, Clone, Copy, PartialEq, Eq, Debug, IdlType, wincode::SchemaRead, wincode::SchemaWrite, +)] +pub enum OptionKind { + /// The right to buy the underlying at the strike. The writer's collateral + /// is the underlying itself, so the call is covered. + Call, + /// The right to sell the underlying at the strike. The writer's collateral + /// is the strike in the quote token, so the put is cash-secured. + Put, +} + +/// Where an option is in its life. Expiry is not a status: it is a comparison +/// of the clock against `expiry`, made by the handlers that care. +#[derive( + InitSpace, Clone, Copy, PartialEq, Eq, Debug, IdlType, wincode::SchemaRead, wincode::SchemaWrite, +)] +pub enum OptionStatus { + /// Written and collateralized, not yet sold. The writer may cancel. + Listed, + /// Sold. The holder may exercise before expiry; after expiry the writer + /// reclaims the collateral. + Held, + /// The holder has paid the strike and taken the collateral. The writer + /// collects the strike payment. + Exercised, +} + +/// One option: `contracts` identical contracts, 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). +#[account(borsh)] +#[derive(InitSpace)] +pub struct OptionContract { + /// Chosen by the writer, so one writer can have many options open. + pub id: u64, + + pub market: Address, + + pub writer: Address, + + /// The buyer, once there is one. `Address::default()` while `Listed`. + pub holder: Address, + + pub kind: OptionKind, + + 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, + + /// 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 the buyer pays the writer for the whole option. + 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 + /// expiry is a calendar date the two parties agreed on, the same reason + /// the fundraiser's deadline is a timestamp; the program reads no oracle, + /// so slot-measured freshness never enters into it. + pub expiry: i64, + + pub bump: u8, +} diff --git a/finance/options/anchor/programs/options/tests/test_options.rs b/finance/options/anchor/programs/options/tests/test_options.rs new file mode 100644 index 00000000..4ef39f6e --- /dev/null +++ b/finance/options/anchor/programs/options/tests/test_options.rs @@ -0,0 +1,1021 @@ +use { + anchor_lang::{ + solana_program::instruction::Instruction, system_program, AccountDeserialize, Address, + InstructionData, ToAccountMetas, + }, + anchor_v2_testing::{Keypair, LiteSVM, Signer}, + options::{ + instructions::write_option::OptionTerms, + state::{Market as MarketState, OptionContract, OptionKind, OptionStatus}, + }, + // LiteSVM's get_sysvar / set_sysvar want the host-side Clock, not + // pinocchio's on-chain one. + solana_clock::Clock, + solana_kite::{ + create_associated_token_account, create_token_mint, create_wallet, + get_token_account_balance, mint_tokens_to_token_account, + send_transaction_from_instructions, + }, +}; + +// Both tokens have 6 decimals: the underlying is NVDAx (tokenized NVIDIA +// stock) and the quote is USDC, so one whole unit of either is 1_000_000 +// minor units. +const ONE_TOKEN: u64 = 1_000_000; +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; +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; +const PUT_PREMIUM: u64 = 20 * ONE_TOKEN; + +const SECONDS_PER_DAY: i64 = 24 * 60 * 60; +const ONE_WEEK: i64 = 7 * SECONDS_PER_DAY; + +// Every character starts with the standard wallet of 1,000 USDC; the story +// hands the writers 5 NVDAx. +const STANDARD_USDC: u64 = 1_000 * ONE_TOKEN; +const FIVE_NVDAX: u64 = 5 * ONE_TOKEN; + +fn token_program_id() -> Address { + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + .parse() + .unwrap() +} + +fn ata_program_id() -> Address { + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + .parse() + .unwrap() +} + +fn derive_ata(wallet: &Address, mint: &Address) -> Address { + Address::find_program_address( + &[wallet.as_ref(), token_program_id().as_ref(), mint.as_ref()], + &ata_program_id(), + ) + .0 +} + +/// The walkthrough's call, expiring at `expiry`. +fn call_terms(expiry: i64) -> OptionTerms { + OptionTerms { + kind: OptionKind::Call, + contracts: CONTRACTS, + underlying_per_contract: ONE_NVDAX_PER_CONTRACT, + strike_per_contract: CALL_STRIKE, + premium: CALL_PREMIUM, + expiry, + } +} + +/// One deployed venue plus the keys needed to drive it. +struct Venue { + svm: LiteSVM, + payer: Keypair, + admin: Keypair, + underlying_mint: Address, + quote_mint: Address, + market: Address, + market_authority: Address, + underlying_vault: Address, + quote_vault: Address, +} + +/// A character with a wallet and both token accounts. +struct Person { + keypair: Keypair, + underlying: Address, + quote: Address, +} + +impl Person { + fn pubkey(&self) -> Address { + self.keypair.pubkey() + } +} + +impl Venue { + fn new() -> Venue { + Venue::try_new(FEE_BPS, false).expect("market initialization should succeed") + } + + /// Like `new`, but surfaces an `initialize_market` rejection instead of + /// panicking, so tests can probe the parameter validation. `same_mint` + /// passes the underlying mint as the quote mint too. + fn try_new(fee_bps: u16, same_mint: bool) -> Result { + let mut svm = anchor_v2_testing::svm(); + svm.add_program( + options::id(), + include_bytes!("../../../target/deploy/options.so"), + ) + .unwrap(); + + let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let admin = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let underlying_mint = create_token_mint(&mut svm, &admin, DECIMALS, None).unwrap(); + let quote_mint = if same_mint { + underlying_mint + } else { + create_token_mint(&mut svm, &admin, DECIMALS, None).unwrap() + }; + + let market = Address::find_program_address( + &[b"market", underlying_mint.as_ref(), quote_mint.as_ref()], + &options::id(), + ) + .0; + let market_authority = + Address::find_program_address(&[b"authority", market.as_ref()], &options::id()).0; + let underlying_vault = + Address::find_program_address(&[b"underlying_vault", market.as_ref()], &options::id()) + .0; + let quote_vault = + Address::find_program_address(&[b"quote_vault", market.as_ref()], &options::id()).0; + + let initialize_market = Instruction::new_with_bytes( + options::id(), + &options::instruction::InitializeMarket { fee_bps }.data(), + options::accounts::InitializeMarketAccountConstraints { + admin: admin.pubkey(), + market, + underlying_mint, + quote_mint, + market_authority, + underlying_vault, + quote_vault, + token_program: token_program_id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut svm, + vec![initialize_market], + &[&admin], + &admin.pubkey(), + ) + .map_err(|_| ())?; + + Ok(Venue { + svm, + payer, + admin, + underlying_mint, + quote_mint, + market, + market_authority, + underlying_vault, + quote_vault, + }) + } + + fn market_state(&self) -> MarketState { + let account = self.svm.get_account(&self.market).unwrap(); + MarketState::try_deserialize(&mut account.data.as_slice()).unwrap() + } + + fn option_pda(&self, writer: &Address, id: u64) -> Address { + Address::find_program_address( + &[ + b"option", + self.market.as_ref(), + writer.as_ref(), + &id.to_le_bytes(), + ], + &options::id(), + ) + .0 + } + + fn option_state(&self, option: &Address) -> OptionContract { + let account = self.svm.get_account(option).unwrap(); + OptionContract::try_deserialize(&mut account.data.as_slice()).unwrap() + } + + fn option_exists(&self, option: &Address) -> bool { + self.svm + .get_account(option) + .map(|account| !account.data.is_empty()) + .unwrap_or(false) + } + + fn now(&self) -> i64 { + self.svm.get_sysvar::().unix_timestamp + } + + /// Move the clock to `unix_timestamp`. Also expires the blockhash, so a + /// retried instruction after the warp is not dropped as a duplicate. + fn warp_to(&mut self, unix_timestamp: i64) { + let mut clock: Clock = self.svm.get_sysvar(); + clock.unix_timestamp = unix_timestamp; + self.svm.set_sysvar(&clock); + self.svm.expire_blockhash(); + } + + /// A character holding `underlying` and `quote` minor units in their + /// associated token accounts. Every character gets one SOL's worth of + /// lamports and change for rent and fees. + fn person(&mut self, underlying: u64, quote: u64) -> Person { + let keypair = create_wallet(&mut self.svm, 10_000_000_000).unwrap(); + let underlying_account = create_associated_token_account( + &mut self.svm, + &keypair.pubkey(), + &self.underlying_mint, + &self.payer, + ) + .unwrap(); + let quote_account = create_associated_token_account( + &mut self.svm, + &keypair.pubkey(), + &self.quote_mint, + &self.payer, + ) + .unwrap(); + if underlying > 0 { + mint_tokens_to_token_account( + &mut self.svm, + &self.underlying_mint, + &underlying_account, + underlying, + &self.admin, + ) + .unwrap(); + } + if quote > 0 { + mint_tokens_to_token_account( + &mut self.svm, + &self.quote_mint, + "e_account, + quote, + &self.admin, + ) + .unwrap(); + } + Person { + keypair, + underlying: underlying_account, + quote: quote_account, + } + } + + fn balance(&self, token_account: &Address) -> u64 { + get_token_account_balance(&self.svm, token_account).unwrap() + } + + fn send(&mut self, instruction: Instruction, signer: &Keypair) -> Result<(), ()> { + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[signer], + &signer.pubkey(), + ) + .map(|_| ()) + .map_err(|_| ()) + } + + fn write_option( + &mut self, + writer: &Person, + id: u64, + terms: OptionTerms, + ) -> Result { + let option = self.option_pda(&writer.pubkey(), id); + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::WriteOption { id, terms }.data(), + options::accounts::WriteOptionAccountConstraints { + writer: writer.pubkey(), + market: self.market, + option, + underlying_mint: self.underlying_mint, + quote_mint: self.quote_mint, + underlying_vault: self.underlying_vault, + quote_vault: self.quote_vault, + writer_underlying: writer.underlying, + writer_quote: writer.quote, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ); + self.send(instruction, &writer.keypair).map(|_| option) + } + + /// The walkthrough's call, written by `writer`, expiring one week out. + fn write_call(&mut self, writer: &Person) -> Address { + let expiry = self.now() + ONE_WEEK; + self.write_option(writer, 1, call_terms(expiry)) + .expect("writing the call should succeed") + } + + /// The walkthrough's put, written by `writer`, expiring one week out. + fn write_put(&mut self, writer: &Person) -> Address { + let expiry = self.now() + ONE_WEEK; + self.write_option( + writer, + 2, + OptionTerms { + kind: OptionKind::Put, + contracts: CONTRACTS, + underlying_per_contract: ONE_NVDAX_PER_CONTRACT, + strike_per_contract: PUT_STRIKE, + premium: PUT_PREMIUM, + expiry, + }, + ) + .expect("writing the put should succeed") + } + + fn buy_option(&mut self, buyer: &Person, writer: &Address, option: &Address) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::BuyOption {}.data(), + options::accounts::BuyOptionAccountConstraints { + buyer: buyer.pubkey(), + writer: *writer, + market: self.market, + option: *option, + quote_mint: self.quote_mint, + quote_vault: self.quote_vault, + buyer_quote: buyer.quote, + writer_quote: derive_ata(writer, &self.quote_mint), + token_program: token_program_id(), + } + .to_account_metas(None), + ); + self.send(instruction, &buyer.keypair) + } + + fn cancel_option(&mut self, writer: &Person, option: &Address) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::CancelOption {}.data(), + options::accounts::CancelOptionAccountConstraints { + writer: writer.pubkey(), + market: self.market, + option: *option, + market_authority: self.market_authority, + underlying_mint: self.underlying_mint, + quote_mint: self.quote_mint, + underlying_vault: self.underlying_vault, + quote_vault: self.quote_vault, + writer_underlying: writer.underlying, + writer_quote: writer.quote, + token_program: token_program_id(), + } + .to_account_metas(None), + ); + self.send(instruction, &writer.keypair) + } + + fn exercise_option( + &mut self, + holder: &Person, + writer: &Address, + option: &Address, + ) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::ExerciseOption {}.data(), + options::accounts::ExerciseOptionAccountConstraints { + holder: holder.pubkey(), + writer: *writer, + market: self.market, + option: *option, + market_authority: self.market_authority, + underlying_mint: self.underlying_mint, + quote_mint: self.quote_mint, + underlying_vault: self.underlying_vault, + quote_vault: self.quote_vault, + holder_underlying: holder.underlying, + holder_quote: holder.quote, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ); + self.send(instruction, &holder.keypair) + } + + fn collect_proceeds(&mut self, writer: &Person, option: &Address) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::CollectProceeds {}.data(), + options::accounts::CollectProceedsAccountConstraints { + writer: writer.pubkey(), + market: self.market, + option: *option, + market_authority: self.market_authority, + underlying_mint: self.underlying_mint, + quote_mint: self.quote_mint, + underlying_vault: self.underlying_vault, + quote_vault: self.quote_vault, + writer_underlying: writer.underlying, + writer_quote: writer.quote, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ); + self.send(instruction, &writer.keypair) + } + + fn reclaim_collateral(&mut self, writer: &Person, option: &Address) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::ReclaimCollateral {}.data(), + options::accounts::ReclaimCollateralAccountConstraints { + writer: writer.pubkey(), + market: self.market, + option: *option, + market_authority: self.market_authority, + underlying_mint: self.underlying_mint, + quote_mint: self.quote_mint, + underlying_vault: self.underlying_vault, + quote_vault: self.quote_vault, + writer_underlying: writer.underlying, + writer_quote: writer.quote, + token_program: token_program_id(), + } + .to_account_metas(None), + ); + self.send(instruction, &writer.keypair) + } + + fn collect_fees_as(&mut self, signer: &Keypair) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + options::id(), + &options::instruction::CollectFees {}.data(), + options::accounts::CollectFeesAccountConstraints { + admin: signer.pubkey(), + market: self.market, + market_authority: self.market_authority, + quote_mint: self.quote_mint, + underlying_vault: self.underlying_vault, + quote_vault: self.quote_vault, + admin_quote: derive_ata(&signer.pubkey(), &self.quote_mint), + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ); + self.send(instruction, signer) + } + + fn collect_fees(&mut self) -> Result<(), ()> { + let admin = self.admin.insecure_clone(); + self.collect_fees_as(&admin) + } + + /// The custody invariant: each vault holds exactly what the market owes. + /// Nothing in these tests donates to a vault, so equality holds rather + /// than the `>=` the program enforces. + fn assert_vaults_match_ledger(&self) { + let market = self.market_state(); + assert_eq!( + self.balance(&self.underlying_vault), + market.underlying_locked, + "underlying vault must hold exactly the locked underlying" + ); + assert_eq!( + self.balance(&self.quote_vault), + market.quote_locked + market.fees_owed, + "quote vault must hold exactly the locked quote plus the fees owed" + ); + } +} + +// =========================================================================== +// The call: write, buy, exercise, collect +// =========================================================================== + +/// Alice writes 5 covered calls 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_locks_the_underlying() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + + let option = venue.write_call(&alice); + + assert_eq!(venue.balance(&alice.underlying), 0); + assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); + let state = venue.option_state(&option); + assert_eq!(state.writer, alice.pubkey()); + 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.premium, CALL_PREMIUM); + assert_eq!(venue.market_state().underlying_locked, FIVE_NVDAX); + venue.assert_vaults_match_ledger(); +} + +/// Bob buys the option. He pays 25 USDC: 1% (0.25 USDC) to the venue, the rest +/// straight to Alice. The 5 NVDAx do not move. +#[test] +fn test_buy_option_pays_the_premium_minus_the_fee() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + let fee = 250_000; // 0.25 USDC + assert_eq!(venue.balance(&bob.quote), STANDARD_USDC - CALL_PREMIUM); + assert_eq!( + venue.balance(&alice.quote), + STANDARD_USDC + CALL_PREMIUM - fee + ); + assert_eq!(venue.balance(&venue.quote_vault), fee); + assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); + let state = venue.option_state(&option); + assert_eq!(state.holder, bob.pubkey()); + assert_eq!(state.status, OptionStatus::Held); + assert_eq!(venue.market_state().fees_owed, fee); + venue.assert_vaults_match_ledger(); +} + +/// NVIDIA rallies past the strike offchain, so Bob exercises: he pays the +/// strike, 5 x 180 = 900 USDC, into the vault and takes the 5 NVDAx. The 900 +/// USDC is now owed to Alice, and the underlying is no longer owed to anyone. +#[test] +fn test_exercise_call_swaps_the_strike_for_the_underlying() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + venue + .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 + ); + assert_eq!(venue.balance(&venue.underlying_vault), 0); + assert_eq!(venue.balance(&venue.quote_vault), strike_total + 250_000); + let market = venue.market_state(); + assert_eq!(market.underlying_locked, 0); + assert_eq!(market.quote_locked, strike_total); + assert_eq!(venue.option_state(&option).status, OptionStatus::Exercised); + venue.assert_vaults_match_ledger(); +} + +/// Alice collects the 900 USDC Bob paid, and the option closes with +/// its rent back to her. She sold 5 NVDAx for 900 USDC plus the 24.75 USDC +/// premium she already had. +#[test] +fn test_collect_proceeds_pays_the_writer_and_closes_the_option() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + venue + .exercise_option(&bob, &alice.pubkey(), &option) + .unwrap(); + let alice_lamports_before = venue.svm.get_balance(&alice.pubkey()).unwrap(); + + venue.collect_proceeds(&alice, &option).unwrap(); + + assert_eq!( + venue.balance(&alice.quote), + STANDARD_USDC + 900 * ONE_TOKEN + CALL_PREMIUM - 250_000 + ); + assert_eq!(venue.balance(&alice.underlying), 0); + assert!(!venue.option_exists(&option)); + assert!( + venue.svm.get_balance(&alice.pubkey()).unwrap() > alice_lamports_before, + "the option's rent must return to the writer" + ); + let market = venue.market_state(); + assert_eq!(market.quote_locked, 0); + assert_eq!(market.fees_owed, 250_000); + venue.assert_vaults_match_ledger(); +} + +/// Maria sweeps the venue's fee. Only the 0.25 USDC of fees leaves the +/// vault; the strike payment sitting beside it stays locked to Alice. +#[test] +fn test_collect_fees_pays_only_the_fees_owed() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + venue + .exercise_option(&bob, &alice.pubkey(), &option) + .unwrap(); + + venue.collect_fees().unwrap(); + + let admin_quote = derive_ata(&venue.admin.pubkey(), &venue.quote_mint); + assert_eq!(venue.balance(&admin_quote), 250_000); + assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_TOKEN); + assert_eq!(venue.market_state().fees_owed, 0); + venue.assert_vaults_match_ledger(); + + // Nothing left to sweep. + assert!(venue.collect_fees().is_err()); +} + +// =========================================================================== +// 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. +#[test] +fn test_put_lifecycle_delivers_the_underlying_for_the_strike() { + let mut venue = Venue::new(); + let carol = venue.person(0, STANDARD_USDC); + let dave = venue.person(FIVE_NVDAX, STANDARD_USDC); + + let option = venue.write_put(&carol); + let collateral = 750 * ONE_TOKEN; + assert_eq!(venue.balance(&carol.quote), STANDARD_USDC - collateral); + assert_eq!(venue.balance(&venue.quote_vault), collateral); + assert_eq!(venue.market_state().quote_locked, collateral); + venue.assert_vaults_match_ledger(); + + venue.buy_option(&dave, &carol.pubkey(), &option).unwrap(); + let fee = 200_000; // 1% of 20 USDC + assert_eq!( + venue.balance(&carol.quote), + STANDARD_USDC - collateral + PUT_PREMIUM - fee + ); + assert_eq!(venue.balance(&dave.quote), STANDARD_USDC - PUT_PREMIUM); + venue.assert_vaults_match_ledger(); + + venue + .exercise_option(&dave, &carol.pubkey(), &option) + .unwrap(); + assert_eq!(venue.balance(&dave.underlying), 0); + assert_eq!( + venue.balance(&dave.quote), + STANDARD_USDC - PUT_PREMIUM + collateral + ); + assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); + assert_eq!(venue.balance(&venue.quote_vault), fee); + let market = venue.market_state(); + assert_eq!(market.underlying_locked, FIVE_NVDAX); + assert_eq!(market.quote_locked, 0); + venue.assert_vaults_match_ledger(); + + venue.collect_proceeds(&carol, &option).unwrap(); + assert_eq!(venue.balance(&carol.underlying), FIVE_NVDAX); + assert_eq!(venue.market_state().underlying_locked, 0); + assert!(!venue.option_exists(&option)); + venue.assert_vaults_match_ledger(); +} + +/// Bob never exercises. Once the expiry passes, Alice takes her 5 NVDAx back +/// and keeps the premium: the writer's whole return on an option that expires +/// out of the money. Bob is left with nothing to claim. +#[test] +fn test_reclaim_collateral_after_expiry_returns_it_to_the_writer() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + let expiry = venue.option_state(&option).expiry; + + venue.warp_to(expiry); + venue.reclaim_collateral(&alice, &option).unwrap(); + + assert_eq!(venue.balance(&alice.underlying), FIVE_NVDAX); + assert_eq!( + venue.balance(&alice.quote), + STANDARD_USDC + CALL_PREMIUM - 250_000 + ); + assert_eq!(venue.balance(&bob.quote), STANDARD_USDC - CALL_PREMIUM); + assert!(!venue.option_exists(&option)); + assert_eq!(venue.market_state().underlying_locked, 0); + venue.assert_vaults_match_ledger(); +} + +// =========================================================================== +// The expiry boundary, from both sides +// =========================================================================== + +/// The holder may exercise while now < expiry. One second before expiry the +/// exercise goes through; at expiry it is refused. +#[test] +fn test_exercise_is_allowed_up_to_but_not_at_expiry() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + let expiry = venue.option_state(&option).expiry; + + venue.warp_to(expiry); + assert!(venue + .exercise_option(&bob, &alice.pubkey(), &option) + .is_err()); + + venue.warp_to(expiry - 1); + venue + .exercise_option(&bob, &alice.pubkey(), &option) + .expect("exercise one second before expiry must succeed"); +} + +/// The writer may reclaim once now >= expiry, and not one second earlier. +#[test] +fn test_reclaim_is_refused_before_expiry() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + let expiry = venue.option_state(&option).expiry; + + venue.warp_to(expiry - 1); + assert!(venue.reclaim_collateral(&alice, &option).is_err()); + + venue.warp_to(expiry); + venue + .reclaim_collateral(&alice, &option) + .expect("reclaim at expiry must succeed"); +} + +/// An expired option cannot be bought: nobody can pay for a right that can no +/// longer be exercised. +#[test] +fn test_buy_is_refused_after_expiry() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + let expiry = venue.option_state(&option).expiry; + + venue.warp_to(expiry); + assert!(venue.buy_option(&bob, &alice.pubkey(), &option).is_err()); +} + +// =========================================================================== +// Cancel: the writer's exit from an unsold option +// =========================================================================== + +/// An unsold option can be withdrawn at any time, collateral back, account +/// closed. Without this, an option nobody buys would lock the writer's tokens +/// forever. +#[test] +fn test_cancel_unsold_option_returns_the_collateral() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let option = venue.write_call(&alice); + + venue.cancel_option(&alice, &option).unwrap(); + + assert_eq!(venue.balance(&alice.underlying), FIVE_NVDAX); + assert_eq!(venue.balance(&venue.underlying_vault), 0); + assert!(!venue.option_exists(&option)); + venue.assert_vaults_match_ledger(); +} + +/// An unsold option that expired is still the writer's to cancel: there is no +/// holder whose rights the cancel would cut short. +#[test] +fn test_cancel_unsold_option_works_after_expiry() { + let mut venue = Venue::new(); + let carol = venue.person(0, STANDARD_USDC); + let option = venue.write_put(&carol); + let expiry = venue.option_state(&option).expiry; + + venue.warp_to(expiry + SECONDS_PER_DAY); + venue.cancel_option(&carol, &option).unwrap(); + + assert_eq!(venue.balance(&carol.quote), STANDARD_USDC); + assert_eq!(venue.market_state().quote_locked, 0); + venue.assert_vaults_match_ledger(); +} + +/// Once sold, the collateral belongs to the deal: the writer cannot pull it +/// out from under the holder. +#[test] +fn test_cancel_is_refused_once_sold() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + assert!(venue.cancel_option(&alice, &option).is_err()); + assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); +} + +// =========================================================================== +// Who may do what +// =========================================================================== + +#[test] +fn test_buy_is_refused_once_sold() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let carol = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + assert!(venue.buy_option(&carol, &alice.pubkey(), &option).is_err()); + assert_eq!(venue.option_state(&option).holder, bob.pubkey()); +} + +/// A writer cannot buy their own option: the premium's source and destination +/// would be the same token account in two mutable slots, which the loader +/// rejects before the handler runs. +#[test] +fn test_writer_cannot_buy_their_own_option() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let option = venue.write_call(&alice); + + assert!(venue.buy_option(&alice, &alice.pubkey(), &option).is_err()); + assert_eq!(venue.option_state(&option).status, OptionStatus::Listed); +} + +/// Only the holder can exercise: an unsold option has no holder, and a stranger +/// is not the holder of a sold one. +#[test] +fn test_exercise_is_refused_for_anyone_but_the_holder() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let mallory = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + + assert!(venue + .exercise_option(&mallory, &alice.pubkey(), &option) + .is_err()); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + assert!(venue + .exercise_option(&mallory, &alice.pubkey(), &option) + .is_err()); + assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); +} + +/// A sold, unexercised, unexpired option has no proceeds to collect, and after +/// exercise only the writer may collect them. +#[test] +fn test_collect_proceeds_needs_an_exercised_option_and_the_writer() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let mallory = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + assert!(venue.collect_proceeds(&alice, &option).is_err()); + + venue + .exercise_option(&bob, &alice.pubkey(), &option) + .unwrap(); + assert!(venue.collect_proceeds(&mallory, &option).is_err()); + assert!(venue.collect_proceeds(&bob, &option).is_err()); + assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_TOKEN + 250_000); +} + +/// An exercised option has no collateral left to reclaim, whatever the clock +/// says: the holder took it. +#[test] +fn test_reclaim_is_refused_after_exercise() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + venue + .exercise_option(&bob, &alice.pubkey(), &option) + .unwrap(); + let expiry = venue.option_state(&option).expiry; + + venue.warp_to(expiry + 1); + assert!(venue.reclaim_collateral(&alice, &option).is_err()); +} + +#[test] +fn test_collect_fees_is_refused_for_anyone_but_the_admin() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let mallory = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + assert!(venue.collect_fees_as(&mallory.keypair).is_err()); + assert_eq!(venue.market_state().fees_owed, 250_000); +} + +// =========================================================================== +// Parameter validation +// =========================================================================== + +#[test] +fn test_write_option_rejects_zero_quantities_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), + ]; + for (id, (contracts, underlying_per_contract, strike_per_contract, premium)) in + attempts.into_iter().enumerate() + { + let terms = OptionTerms { + kind: OptionKind::Call, + contracts, + underlying_per_contract, + strike_per_contract, + premium, + expiry, + }; + assert!( + venue.write_option(&alice, id as u64 + 10, terms).is_err(), + "a zero in any term must be refused" + ); + } + assert_eq!(venue.balance(&alice.underlying), FIVE_NVDAX); +} + +/// The holder may exercise while now < expiry, so an expiry at or before now +/// would be an option nobody could ever exercise. +#[test] +fn test_write_option_rejects_an_expiry_that_has_passed() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let now = venue.now(); + + for expiry in [now, now - SECONDS_PER_DAY] { + assert!(venue.write_option(&alice, 20, call_terms(expiry)).is_err()); + } +} + +/// 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()); +} + +#[test] +fn test_initialize_market_rejects_the_same_mint_on_both_sides() { + assert!(Venue::try_new(FEE_BPS, true).is_err()); +} + +/// A venue run at cost is a valid choice: with a zero fee the writer +/// receives the whole premium and no fee transfer is attempted. +#[test] +fn test_zero_fee_venue_pays_the_writer_the_whole_premium() { + let mut venue = Venue::try_new(0, false).unwrap(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + assert_eq!(venue.balance(&alice.quote), STANDARD_USDC + CALL_PREMIUM); + assert_eq!(venue.market_state().fees_owed, 0); + venue.assert_vaults_match_ledger(); +} diff --git a/finance/options/kani-proofs/Cargo.toml b/finance/options/kani-proofs/Cargo.toml new file mode 100644 index 00000000..ed2a6e47 --- /dev/null +++ b/finance/options/kani-proofs/Cargo.toml @@ -0,0 +1,21 @@ +# Standalone workspace - intentionally NOT part of the root program-examples +# workspace. Kani (https://github.com/model-checking/kani) proof harnesses that +# model the options venue's pure settlement arithmetic (collateral, exercise +# payments, the premium split, the expiry window) so the model checker can +# verify the invariants without the Solana / SPL-token CPI machinery, which +# Kani cannot symbolically execute. +[workspace] + +[package] +name = "options-kani-proofs" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" + +[lints.rust] +unexpected_cfgs = { level = "allow", check-cfg = ['cfg(kani)'] } + +[dependencies] diff --git a/finance/options/kani-proofs/README.md b/finance/options/kani-proofs/README.md new file mode 100644 index 00000000..78a539c0 --- /dev/null +++ b/finance/options/kani-proofs/README.md @@ -0,0 +1,73 @@ +# Options: Kani proofs + +Formal-verification harnesses for the fully collateralized options venue, in +the spirit of [`aeyakovenko/percolator`](https://github.com/aeyakovenko/percolator), +which uses the [Kani](https://github.com/model-checking/kani) model checker to +prove the mathematical correctness of a DeFi engine. + +## What is verified + +The onchain instructions hand token movement to the SPL token program through +CPIs that Kani cannot symbolically execute, but the arithmetic they rely on is +pure integer math, and small: every settlement amount is a product of two +integers the writer chose, the only rounding in the program is the floor in +the fee split, and the expiry window is one comparison and its complement. +This crate reproduces those formulas (mirroring `options::contract_math`) and +the handlers' custody accounting (mirroring the `underlying_locked`, +`quote_locked` and `fees_owed` counters on the `Market` account) and proves: + +- `proof_exercise_moves_exactly_the_posted_terms`: for every option the program + would accept, physical settlement hands the holder exactly the collateral + the writer posted and hands the writer exactly the mirrored payment, both + positive, with a call's payment equal to a put's collateral on the same + terms. No division means no rounding gap between posted and delivered. +- `proof_premium_split_conserves_the_premium`: fee plus the writer's share is + exactly the premium, the fee never exceeds it, the writer always receives + something while the fee is under 100%, and the fee is the exact floor of + `premium * fee_bps / 10_000`. +- `proof_exercise_and_reclaim_windows_partition_time`: at every instant + exactly one of the holder (exercise) and the writer (reclaim) can claim a + held option's collateral. Never both, never neither. +- `proof_vault_ledger_stays_consistent_across_every_lifecycle`: **the core + custody property.** Two options of either kind are written into the shared + vaults and each takes one of its three exits (cancel; buy then reclaim; buy, + exercise, collect), and after every step each vault holds exactly what the + market owes. With every option closed and the fees swept, both vaults are + empty: no token is created or lost on any path. + +## Bounded model checking + +Every product in these harnesses multiplies two symbolic values, which is +nonlinear arithmetic and the worst case for a bit-precise model checker. +Following percolator's practice, the terms are bounded and the identities are +argued to be independent of the bound: + +- `proof_exercise_moves_exactly_the_posted_terms`: contracts and per-contract + amounts at most `0xFF`. An 8-bit multiplication exercises every carry + pattern and finishes in under a minute, where 16-bit terms run for tens of + minutes; larger terms add magnitude, not behavior. Within the bound no + product overflows, so the overflow refusal is pinned by a unit test. +- `proof_premium_split_conserves_the_premium`: premium and fee rate each at + most `0xFF`. The split is a 128-bit multiply followed by a 128-bit division, + and proving a divider exact against a multiplier is the hardest shape of + problem a SAT solver sees: a 16-bit premium against the full fee range runs + for hours. Eight bits on each side finish in about a second, exercise the + floor on both sides of every carry, and the fee-equals-premium edge at the + 99.99% ceiling is pinned by a unit test. +- `proof_exercise_and_reclaim_windows_partition_time`: fully symbolic; it is + one comparison. +- `proof_vault_ledger_stays_consistent_across_every_lifecycle`: each option's + terms at most 15, premiums and fee rates at most 255. The ledger arithmetic + it exercises is additions and subtractions whose behavior does not depend on + the magnitudes. + +## Running + +```bash +# Plain unit tests (no Kani needed), which also pin the exact numbers the +# LiteSVM tests and the book chapter use: +cargo test + +# Full verification (requires cargo-kani): +cargo kani +``` diff --git a/finance/options/kani-proofs/src/lib.rs b/finance/options/kani-proofs/src/lib.rs new file mode 100644 index 00000000..d98f59ef --- /dev/null +++ b/finance/options/kani-proofs/src/lib.rs @@ -0,0 +1,518 @@ +//! Kani proof harnesses for the options venue (`finance/options`). +//! +//! Inspired by aeyakovenko/percolator, which uses the Kani model checker to +//! prove the mathematical correctness of a DeFi engine's pure numeric core. +//! +//! The on-chain instructions hand the actual token movement to the SPL token +//! program via CPIs that Kani cannot symbolically execute. The arithmetic +//! underneath is small and is reproduced here faithfully, mirroring +//! `options::contract_math`: every settlement amount is a product of two +//! integers, the only rounding is the floor in the fee split, and the expiry +//! window is one comparison and its complement. The harnesses prove the +//! invariants the program's custody accounting depends on, plus a bounded +//! model of the vault ledger across an option's whole life. + +#![cfg_attr(kani, allow(dead_code))] + +/// Basis-points denominator (`constants::BASIS_POINTS_DENOMINATOR`). +pub const BASIS_POINTS: u128 = 10_000; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum OptionKind { + Call, + Put, +} + +// =========================================================================== +// 1. Settlement amounts (contract_math.rs) +// =========================================================================== + +/// `contracts * underlying_per_contract`. Mirrors `contract_math::underlying_total`. +pub fn underlying_total(contracts: u64, underlying_per_contract: u64) -> Option { + contracts.checked_mul(underlying_per_contract) +} + +/// `contracts * strike_per_contract`. Mirrors `contract_math::strike_total`. +pub fn strike_total(contracts: u64, strike_per_contract: u64) -> Option { + contracts.checked_mul(strike_per_contract) +} + +/// What the writer posts. Mirrors `contract_math::collateral_amount`. +pub fn collateral_amount( + kind: OptionKind, + contracts: u64, + underlying_per_contract: u64, + strike_per_contract: u64, +) -> Option { + match kind { + OptionKind::Call => underlying_total(contracts, underlying_per_contract), + OptionKind::Put => strike_total(contracts, strike_per_contract), + } +} + +/// What the holder pays at exercise and the writer later collects. Mirrors +/// `contract_math::exercise_payment`. +pub fn exercise_payment( + kind: OptionKind, + contracts: u64, + underlying_per_contract: u64, + strike_per_contract: u64, +) -> Option { + match kind { + OptionKind::Call => strike_total(contracts, strike_per_contract), + OptionKind::Put => underlying_total(contracts, underlying_per_contract), + } +} + +/// Physical settlement moves exactly the posted collateral to the holder and +/// exactly the mirrored payment to the writer, for every option the program +/// would accept: the holder's payment for a call is a put's collateral on the +/// same terms, and the other way round. There is no division in either +/// formula, so no rounding can open a gap between what was posted and what +/// is delivered. +#[cfg(kani)] +#[kani::proof] +fn proof_exercise_moves_exactly_the_posted_terms() { + let contracts: u64 = kani::any(); + let underlying_per_contract: u64 = kani::any(); + let strike_per_contract: u64 = kani::any(); + // write_option refuses a zero in any term. Bounded model checking: the + // two products multiply symbolic values, nonlinear arithmetic that the + // bit-precise solver pays for exponentially by the bit (16-bit terms run + // for tens of minutes; 8-bit terms finish in under a minute). 8-bit terms + // exercise every carry pattern of the multiplication, and larger terms + // add magnitude rather than new behavior. Within the bound no product + // overflows, so the overflow refusal is pinned by the unit test below. + kani::assume(contracts >= 1 && contracts <= 0xFF); + kani::assume(underlying_per_contract >= 1 && underlying_per_contract <= 0xFF); + kani::assume(strike_per_contract >= 1 && strike_per_contract <= 0xFF); + let underlying = + underlying_total(contracts, underlying_per_contract).expect("within the bound"); + let strike = strike_total(contracts, strike_per_contract).expect("within the bound"); + + for kind in [OptionKind::Call, OptionKind::Put] { + let collateral = collateral_amount( + kind, + contracts, + underlying_per_contract, + strike_per_contract, + ) + .expect("write_option checked both totals"); + let payment = exercise_payment( + kind, + contracts, + underlying_per_contract, + strike_per_contract, + ) + .expect("write_option checked both totals"); + let (expected_collateral, expected_payment) = match kind { + OptionKind::Call => (underlying, strike), + OptionKind::Put => (strike, underlying), + }; + assert_eq!(collateral, expected_collateral); + assert_eq!(payment, expected_payment); + // Both legs are positive: an option that delivers nothing or costs + // nothing to exercise cannot exist. + assert!(collateral > 0 && payment > 0); + // The two kinds are mirror images: a call's payment is a put's + // collateral on the same terms. + let mirror = match kind { + OptionKind::Call => OptionKind::Put, + OptionKind::Put => OptionKind::Call, + }; + assert_eq!( + payment, + collateral_amount( + mirror, + contracts, + underlying_per_contract, + strike_per_contract + ) + .unwrap() + ); + } +} + +// =========================================================================== +// 2. The premium split (contract_math.rs) +// =========================================================================== + +/// Fee floors; the writer takes the remainder. Mirrors +/// `contract_math::split_premium`. +pub fn split_premium(premium: u64, fee_bps: u16) -> Option<(u64, u64)> { + let fee = (premium as u128) + .checked_mul(fee_bps as u128)? + .checked_div(BASIS_POINTS)?; + let fee = u64::try_from(fee).ok()?; + let to_writer = premium.checked_sub(fee)?; + Some((fee, to_writer)) +} + +/// The premium is conserved: fee plus the writer's share is exactly the +/// premium, the fee never exceeds the premium, and the writer always gets +/// something. Also proves the fee is the exact floor of `premium * bps / +/// 10_000`, so a refactor that rounds up against the writer, or drops below +/// the floor against the venue, fails the proof. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_premium_split_conserves_the_premium() { + let premium: u64 = kani::any(); + let fee_bps: u16 = kani::any(); + // initialize_market accepts 0 <= fee_bps < 10_000; write_option requires + // a positive premium. Bounded model checking: `premium * fee_bps` is + // symbolic times symbolic and the quotient is a 128-bit division, the + // worst case for the bit-precise solver (a 16-bit premium against the + // full fee range runs for hours). The floor's behavior depends only on + // the product's residue mod 10_000; a 12-bit premium against a 10-bit + // fee already reaches every residue and the fee-equals-premium edge, and + // larger operands add magnitude rather than new behavior. The full fee + // range, including the 99.99% ceiling, is pinned by the unit tests. + kani::assume(premium >= 1 && premium <= 0xFF); + kani::assume(fee_bps <= 0xFF); + + let (fee, to_writer) = split_premium(premium, fee_bps).expect("split computes"); + + assert_eq!(fee as u128 + to_writer as u128, premium as u128); + assert!(fee <= premium); + // With the fee under 100%, the writer is always paid something. + assert!(to_writer > 0); + // Exact floor: fee * 10_000 <= premium * bps < (fee + 1) * 10_000. + let target = (premium as u128) * (fee_bps as u128); + assert!((fee as u128) * BASIS_POINTS <= target); + assert!((fee as u128 + 1) * BASIS_POINTS > target); +} + +// =========================================================================== +// 3. The expiry window (contract_math.rs) +// =========================================================================== + +/// Mirrors `contract_math::may_exercise`. +pub fn may_exercise(now: i64, expiry: i64) -> bool { + now < expiry +} + +/// Mirrors `contract_math::may_reclaim`. +pub fn may_reclaim(now: i64, expiry: i64) -> bool { + now >= expiry +} + +/// At every instant exactly one of the two parties can claim a held option's +/// collateral: the holder by exercising, or the writer by reclaiming. Never +/// both (a double claim), never neither (collateral stranded). +#[cfg(kani)] +#[kani::proof] +fn proof_exercise_and_reclaim_windows_partition_time() { + let now: i64 = kani::any(); + let expiry: i64 = kani::any(); + assert!(may_exercise(now, expiry) != may_reclaim(now, expiry)); +} + +// =========================================================================== +// 4. The vault ledger across an option's life (the handlers' custody accounting) +// =========================================================================== + +/// The market's ledger: what each vault owes, plus the venue's fees. Mirrors +/// the three counters on the `Market` account. +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] +pub struct Ledger { + pub underlying_locked: u64, + pub quote_locked: u64, + pub fees_owed: u64, + /// The token balances the handlers' transfers leave in the two vaults. + pub underlying_vault: u64, + pub quote_vault: u64, +} + +impl Ledger { + /// The custody invariant every handler asserts after its math (mirrors + /// `shared::check_custody`), strengthened to equality: with no donations, + /// each vault holds exactly what the market owes. + pub fn is_consistent(&self) -> bool { + self.underlying_vault == self.underlying_locked + && self.quote_vault as u128 == self.quote_locked as u128 + self.fees_owed as u128 + } + + /// `write_option`: collateral into the vault, owed back to the writer. + pub fn write(&mut self, kind: OptionKind, collateral: u64) -> Option<()> { + match kind { + OptionKind::Call => { + self.underlying_locked = self.underlying_locked.checked_add(collateral)?; + self.underlying_vault = self.underlying_vault.checked_add(collateral)?; + } + OptionKind::Put => { + self.quote_locked = self.quote_locked.checked_add(collateral)?; + self.quote_vault = self.quote_vault.checked_add(collateral)?; + } + } + Some(()) + } + + /// `buy_option`: the fee lands in the quote vault; the rest of the + /// premium goes buyer to writer and never touches a vault. + pub fn buy(&mut self, fee: u64) -> Option<()> { + self.fees_owed = self.fees_owed.checked_add(fee)?; + self.quote_vault = self.quote_vault.checked_add(fee)?; + Some(()) + } + + /// `exercise_option`: the collateral leaves for the holder, the payment + /// arrives and is owed to the writer. + pub fn exercise(&mut self, kind: OptionKind, collateral: u64, payment: u64) -> Option<()> { + match kind { + OptionKind::Call => { + self.underlying_locked = self.underlying_locked.checked_sub(collateral)?; + self.underlying_vault = self.underlying_vault.checked_sub(collateral)?; + self.quote_locked = self.quote_locked.checked_add(payment)?; + self.quote_vault = self.quote_vault.checked_add(payment)?; + } + OptionKind::Put => { + self.quote_locked = self.quote_locked.checked_sub(collateral)?; + self.quote_vault = self.quote_vault.checked_sub(collateral)?; + self.underlying_locked = self.underlying_locked.checked_add(payment)?; + self.underlying_vault = self.underlying_vault.checked_add(payment)?; + } + } + Some(()) + } + + /// `collect_proceeds`: the payment leaves for the writer. + pub fn collect_proceeds(&mut self, kind: OptionKind, payment: u64) -> Option<()> { + match kind { + OptionKind::Call => { + self.quote_locked = self.quote_locked.checked_sub(payment)?; + self.quote_vault = self.quote_vault.checked_sub(payment)?; + } + OptionKind::Put => { + self.underlying_locked = self.underlying_locked.checked_sub(payment)?; + self.underlying_vault = self.underlying_vault.checked_sub(payment)?; + } + } + Some(()) + } + + /// `cancel_option` and `reclaim_collateral`: the collateral goes back to + /// the writer. + pub fn return_collateral(&mut self, kind: OptionKind, collateral: u64) -> Option<()> { + match kind { + OptionKind::Call => { + self.underlying_locked = self.underlying_locked.checked_sub(collateral)?; + self.underlying_vault = self.underlying_vault.checked_sub(collateral)?; + } + OptionKind::Put => { + self.quote_locked = self.quote_locked.checked_sub(collateral)?; + self.quote_vault = self.quote_vault.checked_sub(collateral)?; + } + } + Some(()) + } + + /// `collect_fees`: the fees leave for the admin. + pub fn collect_fees(&mut self) -> Option<()> { + self.quote_vault = self.quote_vault.checked_sub(self.fees_owed)?; + self.fees_owed = 0; + Some(()) + } +} + +/// Every path through an option's life leaves the ledger consistent and, once the +/// option is closed and the fees swept, back at zero: cancel; buy then reclaim; +/// buy then exercise then collect. Two options of either kind run through the +/// model at once so the paths interleave over a shared vault, and every step +/// of every path is checked, not just the end state. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_vault_ledger_stays_consistent_across_every_lifecycle() { + let mut ledger = Ledger::default(); + + // Two options with symbolic terms. Bounded so the multiplications stay + // tractable; the ledger arithmetic is additions and subtractions whose + // behaviour does not depend on the magnitudes. + let mut options = [(OptionKind::Call, 0u64, 0u64, 0u64); 2]; + for option in options.iter_mut() { + let kind: bool = kani::any(); + let contracts: u64 = kani::any(); + let underlying_per_contract: u64 = kani::any(); + let strike_per_contract: u64 = kani::any(); + let premium: u64 = kani::any(); + let fee_bps: u16 = kani::any(); + kani::assume(contracts >= 1 && contracts <= 15); + kani::assume(underlying_per_contract >= 1 && underlying_per_contract <= 15); + kani::assume(strike_per_contract >= 1 && strike_per_contract <= 15); + kani::assume(premium >= 1 && premium <= 255); + kani::assume(fee_bps <= 255); + let kind = if kind { + OptionKind::Call + } else { + OptionKind::Put + }; + let collateral = collateral_amount( + kind, + contracts, + underlying_per_contract, + strike_per_contract, + ) + .unwrap(); + let payment = exercise_payment( + kind, + contracts, + underlying_per_contract, + strike_per_contract, + ) + .unwrap(); + let (fee, _) = split_premium(premium, fee_bps).unwrap(); + *option = (kind, collateral, payment, fee); + } + + // Both options are written first, so their collateral shares the vaults. + for (kind, collateral, _, _) in options { + ledger.write(kind, collateral).unwrap(); + assert!(ledger.is_consistent()); + } + + // Each option then takes one of the three exits, chosen symbolically. + for (kind, collateral, payment, fee) in options { + let path: u8 = kani::any(); + kani::assume(path < 3); + match path { + // Nobody buys: the writer cancels. + 0 => { + ledger.return_collateral(kind, collateral).unwrap(); + } + // Bought, then expires unexercised: the writer reclaims. + 1 => { + ledger.buy(fee).unwrap(); + assert!(ledger.is_consistent()); + ledger.return_collateral(kind, collateral).unwrap(); + } + // Bought and exercised: the writer collects the payment. + _ => { + ledger.buy(fee).unwrap(); + assert!(ledger.is_consistent()); + ledger.exercise(kind, collateral, payment).unwrap(); + assert!(ledger.is_consistent()); + ledger.collect_proceeds(kind, payment).unwrap(); + } + } + assert!(ledger.is_consistent()); + } + + // With every option closed, nothing is owed to any writer or holder ... + assert_eq!(ledger.underlying_locked, 0); + assert_eq!(ledger.quote_locked, 0); + // ... and once the admin sweeps the fees, both vaults are empty: no token + // was created or lost along any path. + ledger.collect_fees().unwrap(); + assert!(ledger.is_consistent()); + assert_eq!(ledger.underlying_vault, 0); + assert_eq!(ledger.quote_vault, 0); +} + +// =========================================================================== +// Plain unit tests (so the crate is meaningful without Kani installed). +// These pin the exact numbers the LiteSVM tests and the book chapter use. +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + // Both tokens have 6 decimals; the venue charges 1% of each premium. + const ONE_TOKEN: u64 = 1_000_000; + const FEE_BPS: u16 = 100; + + #[test] + fn the_call_posts_five_nvdax_and_settles_for_nine_hundred_usdc() { + // 5 contracts, each on 1 NVDAx, strike 180 USDC. + let collateral = + collateral_amount(OptionKind::Call, 5, ONE_TOKEN, 180 * ONE_TOKEN).unwrap(); + let payment = exercise_payment(OptionKind::Call, 5, ONE_TOKEN, 180 * ONE_TOKEN).unwrap(); + assert_eq!(collateral, 5 * ONE_TOKEN); + assert_eq!(payment, 900 * ONE_TOKEN); + } + + #[test] + fn the_put_posts_seven_fifty_usdc_and_settles_for_five_nvdax() { + // 5 contracts, each on 1 NVDAx, strike 150 USDC. + let collateral = collateral_amount(OptionKind::Put, 5, ONE_TOKEN, 150 * ONE_TOKEN).unwrap(); + let payment = exercise_payment(OptionKind::Put, 5, ONE_TOKEN, 150 * ONE_TOKEN).unwrap(); + assert_eq!(collateral, 750 * ONE_TOKEN); + assert_eq!(payment, 5 * ONE_TOKEN); + } + + #[test] + fn a_twenty_five_usdc_premium_splits_into_a_quarter_dollar_fee() { + assert_eq!( + split_premium(25 * ONE_TOKEN, FEE_BPS).unwrap(), + (250_000, 24_750_000) + ); + assert_eq!( + split_premium(20 * ONE_TOKEN, FEE_BPS).unwrap(), + (200_000, 19_800_000) + ); + } + + #[test] + fn the_fee_floors_and_the_writer_keeps_the_rounding_unit() { + // 999 minor units at 1%: 9.99 floors to 9, the writer gets 990. + assert_eq!(split_premium(999, FEE_BPS).unwrap(), (9, 990)); + // A zero fee passes the whole premium through. + assert_eq!(split_premium(999, 0).unwrap(), (0, 999)); + } + + #[test] + fn the_writer_is_paid_even_at_the_highest_fee_the_venue_allows() { + // 99.99% is the highest rate initialize_market accepts. The floor + // leaves the writer at least one minor unit at every premium size. + let ceiling: u16 = 9_999; + assert_eq!(split_premium(1, ceiling).unwrap(), (0, 1)); + assert_eq!(split_premium(10_000, ceiling).unwrap(), (9_999, 1)); + let (fee, to_writer) = split_premium(u64::MAX, ceiling).unwrap(); + assert!(to_writer > 0); + assert_eq!(fee as u128 + to_writer as u128, u64::MAX as u128); + } + + #[test] + fn the_holder_exercises_up_to_but_not_at_expiry() { + let expiry = 1_700_000_000; + assert!(may_exercise(expiry - 1, expiry)); + assert!(!may_exercise(expiry, expiry)); + assert!(!may_reclaim(expiry - 1, expiry)); + assert!(may_reclaim(expiry, expiry)); + } + + #[test] + fn a_lot_that_overflows_is_refused_at_write_time() { + assert_eq!(underlying_total(u64::MAX, 2), None); + assert_eq!(strike_total(u64::MAX, 2), None); + } + + #[test] + fn the_ledger_returns_to_zero_after_the_chapter() { + let mut ledger = Ledger::default(); + // Alice's call: written, bought by Bob, exercised, collected. + ledger.write(OptionKind::Call, 5 * ONE_TOKEN).unwrap(); + ledger.buy(250_000).unwrap(); + ledger + .exercise(OptionKind::Call, 5 * ONE_TOKEN, 900 * ONE_TOKEN) + .unwrap(); + assert!(ledger.is_consistent()); + assert_eq!(ledger.quote_vault, 900 * ONE_TOKEN + 250_000); + ledger + .collect_proceeds(OptionKind::Call, 900 * ONE_TOKEN) + .unwrap(); + // Carol's put: written, bought by Dave, expires, reclaimed. + ledger.write(OptionKind::Put, 750 * ONE_TOKEN).unwrap(); + ledger.buy(200_000).unwrap(); + assert!(ledger.is_consistent()); + ledger + .return_collateral(OptionKind::Put, 750 * ONE_TOKEN) + .unwrap(); + // Maria sweeps 0.45 USDC and the vaults are empty. + assert_eq!(ledger.fees_owed, 450_000); + ledger.collect_fees().unwrap(); + assert!(ledger.is_consistent()); + assert_eq!(ledger, Ledger::default()); + } +} diff --git a/finance/options/quasar/.gitignore b/finance/options/quasar/.gitignore new file mode 100644 index 00000000..3dcc0425 --- /dev/null +++ b/finance/options/quasar/.gitignore @@ -0,0 +1,5 @@ +target +**/*.rs.bk +node_modules +test-ledger +.DS_Store diff --git a/finance/options/quasar/CHANGELOG.md b/finance/options/quasar/CHANGELOG.md new file mode 100644 index 00000000..6d41632c --- /dev/null +++ b/finance/options/quasar/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 2026-09-04 + +Initial version: Quasar port of the options example, matching the Anchor +sibling's design and math. `kind` and `status` are `u8` constants, the +writer's premium account is bound by owner and mint in `buy_option`, and +every token account must exist before it is used. diff --git a/finance/options/quasar/Cargo.toml b/finance/options/quasar/Cargo.toml new file mode 100644 index 00000000..bb5feab1 --- /dev/null +++ b/finance/options/quasar/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "quasar-options" +version = "0.1.0" +edition = "2021" + +# Standalone workspace: not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. +[workspace] + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = [ + 'cfg(target_os, values("solana"))', +] + +[lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] + +[features] +alloc = [] +client = [] +debug = [] +idl-build = ["quasar-lang/idl-build"] + +[dependencies] +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +solana-instruction = { version = "3.2.0" } +# Pinned: quasar-lang asks for zeropod "0.3.3" and wincode 0.4, but zeropod +# 0.3.4 moved to wincode 0.5, so a fresh resolve (no lockfile is committed) +# splits the graph across two wincode versions and every Pod* trait bound +# fails. Unpin when quasar-lang's pinned rev accepts zeropod 0.3.4+. +zeropod = "=0.3.3" + +[dev-dependencies] +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/options/quasar/Quasar.toml b/finance/options/quasar/Quasar.toml new file mode 100644 index 00000000..b0b8ff2a --- /dev/null +++ b/finance/options/quasar/Quasar.toml @@ -0,0 +1,9 @@ +[project] +name = "quasar-options" + +[testing] +command = { program = "cargo", args = ["test"] } + +[clients] +path = "target/client" +targets = ["rust"] diff --git a/finance/options/quasar/README.md b/finance/options/quasar/README.md new file mode 100644 index 00000000..e0830e62 --- /dev/null +++ b/finance/options/quasar/README.md @@ -0,0 +1,48 @@ +# Solana Options (Quasar) + +A [Quasar](https://quasar-lang.com/docs) port of the Solana options example. +The design, math, and behavior match the Anchor implementation at +[`../anchor`](../anchor). Read that README for the full walkthrough of the +fully collateralized, physically settled venue. This page only covers what +differs in the Quasar version. + +## Differences from the Anchor version + +- **`kind` and `status` are `u8`.** Quasar instruction arguments and + zero-copy fields are plain integers, so the Anchor sibling's `OptionKind` + and `OptionStatus` enums become the constants in `constants.rs`: + `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. +- **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 + token; here the tests create both token accounts for every character up + front. +- **The writer's premium account is bound in the handler.** The Anchor + version derives it as the writer's associated token account; here + `buy_option` checks that the account passed as `writer_quote` is owned by + the writer and holds the quote token, so a buyer cannot route the premium + to themselves (`buy_refuses_a_premium_account_the_writer_does_not_own`). +- **State writes** use Quasar's zero-copy field accessors (`field.get()` / + `field.set()`) and `set_inner`, rather than Anchor's account mutation. + +## Testing + +```bash +quasar build +cargo test +``` + +Tests run in-process with [`quasar-test`](https://github.com/blueshift-gg/quasar). +They set up both mints, a venue at a 1% fee, and the five characters with +their token accounts, warp the clock to a fixed start time so the week-long +expiry is deterministic, then walk the call from write to collected strike +and the put from write to exercise and to expiry, checking the custody +ledger against the vault balances after every step. Every gate has a test +that proves it shuts: the expiry boundary from both sides, cancel after +sale, buy after sale or expiry, exercise by a non-holder, collection by a +non-writer or before exercise, reclaim after exercise, fee collection by a +non-admin, and the parameter checks at write time. diff --git a/finance/options/quasar/src/constants.rs b/finance/options/quasar/src/constants.rs new file mode 100644 index 00000000..e7819a01 --- /dev/null +++ b/finance/options/quasar/src/constants.rs @@ -0,0 +1,13 @@ +/// Basis-point denominator: 100% = 10_000 bps. The venue's fee on each premium +/// is expressed in basis points and divided by this. +pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; + +/// `kind` values. Quasar instruction arguments and zero-copy fields are plain +/// integers, so the Anchor sibling's `OptionKind` enum becomes a `u8`. +pub const KIND_CALL: u8 = 0; +pub const KIND_PUT: u8 = 1; + +/// `status` values, mirroring the Anchor sibling's `OptionStatus`. +pub const STATUS_LISTED: u8 = 0; +pub const STATUS_HELD: u8 = 1; +pub const STATUS_EXERCISED: u8 = 2; diff --git a/finance/options/quasar/src/errors.rs b/finance/options/quasar/src/errors.rs new file mode 100644 index 00000000..abba417f --- /dev/null +++ b/finance/options/quasar/src/errors.rs @@ -0,0 +1,28 @@ +use quasar_lang::prelude::*; + +/// Program errors. Codes start at 6000, the Anchor-compatible offset, so the +/// two builds report the same numbers (Quasar's `#[error_code]` starts at 0 +/// unless told otherwise; framework errors occupy 3000+). +#[error_code] +pub enum OptionsError { + /// Market or option parameter is outside the allowed range. + InvalidParameter = 6000, + /// Option expiry must be in the future. + ExpiryInPast, + /// Arithmetic overflow. + MathOverflow, + /// Option is not listed for sale: it has been sold or exercised. + OptionNotListed, + /// Option has expired: it can no longer be bought or exercised. + OptionExpired, + /// Option has no holder: it is unsold or already exercised. + OptionNotHeld, + /// Option has not expired: the holder may still exercise it. + OptionNotExpired, + /// Option has not been exercised: there are no proceeds to collect. + OptionNotExercised, + /// No fees are available to collect. + NothingToCollect, + /// Vault balance would fall below what the market owes. + CustodyInvariantViolated, +} diff --git a/finance/options/quasar/src/instructions/buy_option.rs b/finance/options/quasar/src/instructions/buy_option.rs new file mode 100644 index 00000000..afbbbcf2 --- /dev/null +++ b/finance/options/quasar/src/instructions/buy_option.rs @@ -0,0 +1,108 @@ +use { + crate::{ + constants::{STATUS_HELD, STATUS_LISTED}, + errors::OptionsError, + instructions::shared::{add_locked, check_custody, may_exercise, split_premium}, + state::{Market, OptionContract}, + }, + quasar_lang::{prelude::*, sysvars::Sysvar as _}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct BuyOptionAccountConstraints { + #[account(mut)] + pub buyer: Signer, + /// CHECK: bound to the option via `has_one(writer)`; a seed of the option + /// PDA and the owner of the account the premium is paid into. + pub writer: UncheckedAccount, + #[account( + mut, + address = Market::seeds(underlying_mint.address(), quote_mint.address()), + has_one(quote_vault), + )] + pub market: Account, + #[account( + mut, + has_one(market), + has_one(writer), + address = OptionContract::seeds(market.address(), writer.address(), option.id.into()), + )] + pub option: Account, + /// CHECK: seed input for the market PDA. + pub underlying_mint: UncheckedAccount, + pub quote_mint: Account, + #[account(mut)] + pub quote_vault: Account, + #[account(mut)] + pub buyer_quote: Account, + /// Must be the writer's quote-token account: bound below in the handler + /// against the token account's owner, so a buyer cannot route the + /// premium to themselves. + #[account(mut)] + pub writer_quote: Account, + pub token_program: Program, +} + +/// Buy a listed option. The premium is the only money that changes hands: the +/// venue's fee comes out of it into the quote vault, and the rest goes +/// straight to the writer. The collateral does not move. +#[inline(always)] +pub fn handle_buy_option(accounts: &mut BuyOptionAccountConstraints) -> Result<(), ProgramError> { + require!( + accounts.option.status == STATUS_LISTED, + OptionsError::OptionNotListed + ); + // An option nobody can exercise any more is not for sale. + let now: i64 = Clock::get()?.unix_timestamp.into(); + require!( + may_exercise(now, accounts.option.expiry.get()), + OptionsError::OptionExpired + ); + // The premium goes to the writer's own quote account, nowhere else. + require!( + accounts.writer_quote.owner() == accounts.writer.address() + && accounts.writer_quote.mint() == accounts.quote_mint.address(), + OptionsError::InvalidParameter + ); + // A writer cannot buy their own option: the same address would sit in the + // `buyer` and `writer` slots at once, which the runtime refuses before + // this handler runs. + + let (fee, to_writer) = + split_premium(accounts.option.premium.get(), accounts.market.fee_bps.get())?; + + // Effects before the transfers. + accounts.option.holder = *accounts.buyer.address(); + accounts.option.status = STATUS_HELD; + let mut quote_after = accounts.quote_vault.amount(); + add_locked(&mut accounts.market.fees_owed, &mut quote_after, fee)?; + let underlying_after = accounts.market.underlying_locked.get(); + check_custody(&accounts.market, underlying_after, quote_after)?; + + accounts + .token_program + .transfer_checked( + &accounts.buyer_quote, + &accounts.quote_mint, + &accounts.writer_quote, + &accounts.buyer, + to_writer, + accounts.quote_mint.decimals(), + ) + .invoke()?; + if fee > 0 { + accounts + .token_program + .transfer_checked( + &accounts.buyer_quote, + &accounts.quote_mint, + &accounts.quote_vault, + &accounts.buyer, + fee, + accounts.quote_mint.decimals(), + ) + .invoke()?; + } + Ok(()) +} diff --git a/finance/options/quasar/src/instructions/cancel_option.rs b/finance/options/quasar/src/instructions/cancel_option.rs new file mode 100644 index 00000000..bc0d5060 --- /dev/null +++ b/finance/options/quasar/src/instructions/cancel_option.rs @@ -0,0 +1,104 @@ +use { + crate::{ + constants::STATUS_LISTED, + errors::OptionsError, + instructions::shared::{check_custody, sub_locked, transfer_from_vault, Terms}, + state::{Market, MarketAuthorityPda, OptionContract}, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct CancelOptionAccountConstraints { + #[account(mut)] + pub writer: Signer, + #[account( + mut, + address = Market::seeds(underlying_mint.address(), quote_mint.address()), + has_one(underlying_vault), + has_one(quote_vault), + )] + pub market: Account, + #[account( + mut, + has_one(market), + has_one(writer), + close(dest = writer), + address = OptionContract::seeds(market.address(), writer.address(), option.id.into()), + )] + pub option: Account, + /// Authority PDA over both vaults; holds no data, only signs. + #[account(address = MarketAuthorityPda::seeds(market.address()))] + pub market_authority: UncheckedAccount, + pub underlying_mint: Account, + pub quote_mint: Account, + #[account(mut)] + pub underlying_vault: Account, + #[account(mut)] + pub quote_vault: Account, + #[account(mut)] + pub writer_underlying: Account, + #[account(mut)] + pub writer_quote: Account, + pub token_program: Program, +} + +/// Withdraw an unsold option. Any time is fine, including after expiry: an +/// unsold option has no holder whose rights could be cut short. +#[inline(always)] +pub fn handle_cancel_option( + accounts: &mut CancelOptionAccountConstraints, +) -> Result<(), ProgramError> { + require!( + accounts.option.status == STATUS_LISTED, + OptionsError::OptionNotListed + ); + 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(), + }; + let collateral = terms.collateral_amount()?; + + let mut underlying_after = accounts.underlying_vault.amount(); + let mut quote_after = accounts.quote_vault.amount(); + if terms.is_call() { + sub_locked( + &mut accounts.market.underlying_locked, + &mut underlying_after, + collateral, + )?; + } else { + sub_locked( + &mut accounts.market.quote_locked, + &mut quote_after, + collateral, + )?; + } + check_custody(&accounts.market, underlying_after, quote_after)?; + + if terms.is_call() { + transfer_from_vault( + &accounts.token_program, + &accounts.underlying_vault, + &accounts.underlying_mint, + &accounts.writer_underlying, + &accounts.market_authority, + &accounts.market, + collateral, + ) + } else { + transfer_from_vault( + &accounts.token_program, + &accounts.quote_vault, + &accounts.quote_mint, + &accounts.writer_quote, + &accounts.market_authority, + &accounts.market, + collateral, + ) + } + // The option closes to the writer through `close(dest = writer)`. +} diff --git a/finance/options/quasar/src/instructions/collect_fees.rs b/finance/options/quasar/src/instructions/collect_fees.rs new file mode 100644 index 00000000..97774ef0 --- /dev/null +++ b/finance/options/quasar/src/instructions/collect_fees.rs @@ -0,0 +1,70 @@ +use { + crate::{ + errors::OptionsError, + instructions::shared::{check_custody, transfer_from_vault}, + state::{Market, MarketAuthorityPda}, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct CollectFeesAccountConstraints { + #[account(mut)] + pub admin: Signer, + #[account( + mut, + address = Market::seeds(underlying_mint.address(), quote_mint.address()), + has_one(admin), + has_one(underlying_vault), + has_one(quote_vault), + )] + pub market: Account, + /// Authority PDA over both vaults; holds no data, only signs. + #[account(address = MarketAuthorityPda::seeds(market.address()))] + pub market_authority: UncheckedAccount, + /// CHECK: seed input for the market PDA. + pub underlying_mint: UncheckedAccount, + pub quote_mint: Account, + /// Read only, for the custody check: the invariant covers both vaults. + pub underlying_vault: Account, + #[account(mut)] + pub quote_vault: Account, + /// Unlike the Anchor sibling, it must already exist. + #[account(mut)] + pub admin_quote: Account, + pub token_program: Program, +} + +/// The admin sweeps the fees the venue has earned on premiums. `fees_owed` +/// is the only part of the quote vault the admin can reach. +#[inline(always)] +pub fn handle_collect_fees( + accounts: &mut CollectFeesAccountConstraints, +) -> Result<(), ProgramError> { + let amount = accounts.market.fees_owed.get(); + require!(amount > 0, OptionsError::NothingToCollect); + + // Effects before the transfer: zero the balance, then pay it out. + accounts.market.fees_owed.set(0); + let quote_after = accounts + .quote_vault + .amount() + .checked_sub(amount) + .ok_or(OptionsError::CustodyInvariantViolated)?; + check_custody( + &accounts.market, + accounts.underlying_vault.amount(), + quote_after, + )?; + + transfer_from_vault( + &accounts.token_program, + &accounts.quote_vault, + &accounts.quote_mint, + &accounts.admin_quote, + &accounts.market_authority, + &accounts.market, + amount, + ) +} diff --git a/finance/options/quasar/src/instructions/collect_proceeds.rs b/finance/options/quasar/src/instructions/collect_proceeds.rs new file mode 100644 index 00000000..82d0c57c --- /dev/null +++ b/finance/options/quasar/src/instructions/collect_proceeds.rs @@ -0,0 +1,107 @@ +use { + crate::{ + constants::STATUS_EXERCISED, + errors::OptionsError, + instructions::shared::{check_custody, sub_locked, transfer_from_vault, Terms}, + state::{Market, MarketAuthorityPda, OptionContract}, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct CollectProceedsAccountConstraints { + #[account(mut)] + pub writer: Signer, + #[account( + mut, + address = Market::seeds(underlying_mint.address(), quote_mint.address()), + has_one(underlying_vault), + has_one(quote_vault), + )] + pub market: Account, + #[account( + mut, + has_one(market), + has_one(writer), + close(dest = writer), + address = OptionContract::seeds(market.address(), writer.address(), option.id.into()), + )] + pub option: Account, + /// Authority PDA over both vaults; holds no data, only signs. + #[account(address = MarketAuthorityPda::seeds(market.address()))] + pub market_authority: UncheckedAccount, + pub underlying_mint: Account, + pub quote_mint: Account, + #[account(mut)] + pub underlying_vault: Account, + #[account(mut)] + pub quote_vault: Account, + /// Unlike the Anchor sibling, both writer accounts must already exist. + #[account(mut)] + pub writer_underlying: Account, + #[account(mut)] + pub writer_quote: Account, + pub token_program: Program, +} + +/// The writer collects what the holder paid at exercise: the strike for a +/// call, the underlying for a put. The option closes. +#[inline(always)] +pub fn handle_collect_proceeds( + accounts: &mut CollectProceedsAccountConstraints, +) -> Result<(), ProgramError> { + require!( + accounts.option.status == STATUS_EXERCISED, + OptionsError::OptionNotExercised + ); + 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(), + }; + let proceeds = terms.exercise_payment()?; + + let mut underlying_after = accounts.underlying_vault.amount(); + let mut quote_after = accounts.quote_vault.amount(); + if terms.is_call() { + // A call's proceeds are the strike, in the quote token. + sub_locked( + &mut accounts.market.quote_locked, + &mut quote_after, + proceeds, + )?; + } else { + // A put's proceeds are the delivered underlying. + sub_locked( + &mut accounts.market.underlying_locked, + &mut underlying_after, + proceeds, + )?; + } + check_custody(&accounts.market, underlying_after, quote_after)?; + + if terms.is_call() { + transfer_from_vault( + &accounts.token_program, + &accounts.quote_vault, + &accounts.quote_mint, + &accounts.writer_quote, + &accounts.market_authority, + &accounts.market, + proceeds, + ) + } else { + transfer_from_vault( + &accounts.token_program, + &accounts.underlying_vault, + &accounts.underlying_mint, + &accounts.writer_underlying, + &accounts.market_authority, + &accounts.market, + proceeds, + ) + } + // The option closes to the writer through `close(dest = writer)`. +} diff --git a/finance/options/quasar/src/instructions/exercise_option.rs b/finance/options/quasar/src/instructions/exercise_option.rs new file mode 100644 index 00000000..636ac0bc --- /dev/null +++ b/finance/options/quasar/src/instructions/exercise_option.rs @@ -0,0 +1,156 @@ +use { + crate::{ + constants::{STATUS_EXERCISED, STATUS_HELD}, + errors::OptionsError, + instructions::shared::{ + add_locked, check_custody, may_exercise, sub_locked, transfer_from_vault, Terms, + }, + state::{Market, MarketAuthorityPda, OptionContract}, + }, + quasar_lang::{prelude::*, sysvars::Sysvar as _}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct ExerciseOptionAccountConstraints { + #[account(mut)] + pub holder: Signer, + /// CHECK: bound to the option via `has_one(writer)`; a seed of the option + /// PDA. + pub writer: UncheckedAccount, + #[account( + mut, + address = Market::seeds(underlying_mint.address(), quote_mint.address()), + has_one(underlying_vault), + has_one(quote_vault), + )] + pub market: Account, + #[account( + mut, + has_one(market), + has_one(writer), + has_one(holder), + address = OptionContract::seeds(market.address(), writer.address(), option.id.into()), + )] + pub option: Account, + /// Authority PDA over both vaults; holds no data, only signs. + #[account(address = MarketAuthorityPda::seeds(market.address()))] + pub market_authority: UncheckedAccount, + pub underlying_mint: Account, + pub quote_mint: Account, + #[account(mut)] + pub underlying_vault: Account, + #[account(mut)] + pub quote_vault: Account, + /// Unlike the Anchor sibling, both holder accounts must already exist. + #[account(mut)] + pub holder_underlying: Account, + #[account(mut)] + pub holder_quote: Account, + pub token_program: Program, +} + +/// Exercise a held option before expiry. A call holder pays the strike and takes +/// the underlying; a put holder delivers the underlying and takes the strike. +/// The payment stays in the vault, owed to the writer, until they call +/// `collect_proceeds`. No price is read: whether exercising is worth it is +/// the holder's decision. +#[inline(always)] +pub fn handle_exercise_option( + accounts: &mut ExerciseOptionAccountConstraints, +) -> Result<(), ProgramError> { + require!( + accounts.option.status == STATUS_HELD, + OptionsError::OptionNotHeld + ); + // The holder may exercise while now < expiry. + let now: i64 = Clock::get()?.unix_timestamp.into(); + require!( + may_exercise(now, accounts.option.expiry.get()), + OptionsError::OptionExpired + ); + + 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(), + }; + 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. + accounts.option.status = STATUS_EXERCISED; + let mut underlying_after = accounts.underlying_vault.amount(); + let mut quote_after = accounts.quote_vault.amount(); + if terms.is_call() { + sub_locked( + &mut accounts.market.underlying_locked, + &mut underlying_after, + underlying_total, + )?; + add_locked( + &mut accounts.market.quote_locked, + &mut quote_after, + strike_total, + )?; + } else { + sub_locked( + &mut accounts.market.quote_locked, + &mut quote_after, + strike_total, + )?; + add_locked( + &mut accounts.market.underlying_locked, + &mut underlying_after, + underlying_total, + )?; + } + check_custody(&accounts.market, underlying_after, quote_after)?; + + // The holder pays in, then the vault pays out, atomically or not at all. + if terms.is_call() { + accounts + .token_program + .transfer_checked( + &accounts.holder_quote, + &accounts.quote_mint, + &accounts.quote_vault, + &accounts.holder, + strike_total, + accounts.quote_mint.decimals(), + ) + .invoke()?; + transfer_from_vault( + &accounts.token_program, + &accounts.underlying_vault, + &accounts.underlying_mint, + &accounts.holder_underlying, + &accounts.market_authority, + &accounts.market, + underlying_total, + ) + } else { + accounts + .token_program + .transfer_checked( + &accounts.holder_underlying, + &accounts.underlying_mint, + &accounts.underlying_vault, + &accounts.holder, + underlying_total, + accounts.underlying_mint.decimals(), + ) + .invoke()?; + transfer_from_vault( + &accounts.token_program, + &accounts.quote_vault, + &accounts.quote_mint, + &accounts.holder_quote, + &accounts.market_authority, + &accounts.market, + strike_total, + ) + } +} diff --git a/finance/options/quasar/src/instructions/initialize_market.rs b/finance/options/quasar/src/instructions/initialize_market.rs new file mode 100644 index 00000000..0943cdca --- /dev/null +++ b/finance/options/quasar/src/instructions/initialize_market.rs @@ -0,0 +1,82 @@ +use { + crate::{ + constants::BASIS_POINTS_DENOMINATOR, + errors::OptionsError, + state::{Market, MarketAuthorityPda, MarketInner, QuoteVaultPda, UnderlyingVaultPda}, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct InitializeMarketAccountConstraints { + #[account(mut)] + pub admin: Signer, + // One venue per pair, so every option on NVDAx settled in USDC shares the + // two vaults and the one fee schedule. + #[account( + mut, + init, + payer = admin, + address = Market::seeds(underlying_mint.address(), quote_mint.address()), + )] + pub market: Account, + pub underlying_mint: Account, + pub quote_mint: Account, + /// Authority PDA over both vaults. Holds no data; only signs. + #[account(address = MarketAuthorityPda::seeds(market.address()))] + pub market_authority: UncheckedAccount, + #[account( + mut, + init(idempotent), + payer = admin, + address = UnderlyingVaultPda::seeds(market.address()), + token(mint = underlying_mint, authority = market_authority, token_program = token_program), + )] + pub underlying_vault: Account, + #[account( + mut, + init(idempotent), + payer = admin, + address = QuoteVaultPda::seeds(market.address()), + token(mint = quote_mint, authority = market_authority, token_program = token_program), + )] + pub quote_vault: Account, + pub token_program: Program, + pub system_program: Program, + pub rent: Sysvar, +} + +#[inline(always)] +pub fn handle_initialize_market( + accounts: &mut InitializeMarketAccountConstraints, + fee_bps: u16, + bumps: &InitializeMarketAccountConstraintsBumps, +) -> Result<(), ProgramError> { + // Options on a token settled in the same token are meaningless. + require!( + accounts.underlying_mint.address() != accounts.quote_mint.address(), + OptionsError::InvalidParameter + ); + // A fee of 100% or more would leave the writer nothing from a sale. Zero + // is allowed: a venue run at cost is a valid choice. + require!( + fee_bps < BASIS_POINTS_DENOMINATOR as u16, + OptionsError::InvalidParameter + ); + + accounts.market.set_inner(MarketInner { + admin: *accounts.admin.address(), + underlying_mint: *accounts.underlying_mint.address(), + quote_mint: *accounts.quote_mint.address(), + underlying_vault: *accounts.underlying_vault.address(), + quote_vault: *accounts.quote_vault.address(), + underlying_locked: 0, + quote_locked: 0, + fees_owed: 0, + fee_bps, + bump: bumps.market, + authority_bump: bumps.market_authority, + }); + Ok(()) +} diff --git a/finance/options/quasar/src/instructions/mod.rs b/finance/options/quasar/src/instructions/mod.rs new file mode 100644 index 00000000..f94ca097 --- /dev/null +++ b/finance/options/quasar/src/instructions/mod.rs @@ -0,0 +1,18 @@ +pub mod buy_option; +pub mod cancel_option; +pub mod collect_fees; +pub mod collect_proceeds; +pub mod exercise_option; +pub mod initialize_market; +pub mod reclaim_collateral; +pub mod shared; +pub mod write_option; + +pub use buy_option::*; +pub use cancel_option::*; +pub use collect_fees::*; +pub use collect_proceeds::*; +pub use exercise_option::*; +pub use initialize_market::*; +pub use reclaim_collateral::*; +pub use write_option::*; diff --git a/finance/options/quasar/src/instructions/reclaim_collateral.rs b/finance/options/quasar/src/instructions/reclaim_collateral.rs new file mode 100644 index 00000000..023e7bee --- /dev/null +++ b/finance/options/quasar/src/instructions/reclaim_collateral.rs @@ -0,0 +1,115 @@ +use { + crate::{ + constants::STATUS_HELD, + errors::OptionsError, + instructions::shared::{ + check_custody, may_reclaim, sub_locked, transfer_from_vault, Terms, + }, + state::{Market, MarketAuthorityPda, OptionContract}, + }, + quasar_lang::{prelude::*, sysvars::Sysvar as _}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct ReclaimCollateralAccountConstraints { + #[account(mut)] + pub writer: Signer, + #[account( + mut, + address = Market::seeds(underlying_mint.address(), quote_mint.address()), + has_one(underlying_vault), + has_one(quote_vault), + )] + pub market: Account, + #[account( + mut, + has_one(market), + has_one(writer), + close(dest = writer), + address = OptionContract::seeds(market.address(), writer.address(), option.id.into()), + )] + pub option: Account, + /// Authority PDA over both vaults; holds no data, only signs. + #[account(address = MarketAuthorityPda::seeds(market.address()))] + pub market_authority: UncheckedAccount, + pub underlying_mint: Account, + pub quote_mint: Account, + #[account(mut)] + pub underlying_vault: Account, + #[account(mut)] + pub quote_vault: Account, + #[account(mut)] + pub writer_underlying: Account, + #[account(mut)] + pub writer_quote: Account, + pub token_program: Program, +} + +/// The holder let the option expire, so the writer takes the collateral +/// back. The premium was the writer's the moment it was paid. The option +/// account closes. +#[inline(always)] +pub fn handle_reclaim_collateral( + accounts: &mut ReclaimCollateralAccountConstraints, +) -> Result<(), ProgramError> { + require!( + accounts.option.status == STATUS_HELD, + OptionsError::OptionNotHeld + ); + // The writer may reclaim once now >= expiry: the complement of the + // holder's window, so the two can never both claim the collateral. + let now: i64 = Clock::get()?.unix_timestamp.into(); + require!( + may_reclaim(now, accounts.option.expiry.get()), + OptionsError::OptionNotExpired + ); + + 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(), + }; + let collateral = terms.collateral_amount()?; + + let mut underlying_after = accounts.underlying_vault.amount(); + let mut quote_after = accounts.quote_vault.amount(); + if terms.is_call() { + sub_locked( + &mut accounts.market.underlying_locked, + &mut underlying_after, + collateral, + )?; + } else { + sub_locked( + &mut accounts.market.quote_locked, + &mut quote_after, + collateral, + )?; + } + check_custody(&accounts.market, underlying_after, quote_after)?; + + if terms.is_call() { + transfer_from_vault( + &accounts.token_program, + &accounts.underlying_vault, + &accounts.underlying_mint, + &accounts.writer_underlying, + &accounts.market_authority, + &accounts.market, + collateral, + ) + } else { + transfer_from_vault( + &accounts.token_program, + &accounts.quote_vault, + &accounts.quote_mint, + &accounts.writer_quote, + &accounts.market_authority, + &accounts.market, + collateral, + ) + } + // The option closes to the writer through `close(dest = writer)`. +} diff --git a/finance/options/quasar/src/instructions/shared.rs b/finance/options/quasar/src/instructions/shared.rs new file mode 100644 index 00000000..1b168224 --- /dev/null +++ b/finance/options/quasar/src/instructions/shared.rs @@ -0,0 +1,178 @@ +//! 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. + +use { + crate::{ + constants::{BASIS_POINTS_DENOMINATOR, KIND_CALL, KIND_PUT}, + errors::OptionsError, + state::Market, + }, + quasar_lang::{cpi::Seed, prelude::*}, + quasar_spl::prelude::*, +}; + +/// An option's terms, as read from the account or the instruction arguments. +#[derive(Clone, Copy)] +pub struct Terms { + pub kind: u8, + pub contracts: u64, + pub underlying_per_contract: u64, + pub strike_per_contract: u64, +} + +impl Terms { + pub fn is_call(&self) -> bool { + 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 { + if self.is_call() { + self.underlying_total() + } else { + self.strike_total() + } + } + + /// 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 { + if self.is_call() { + self.strike_total() + } else { + self.underlying_total() + } + } +} + +/// Which of the two kinds a `kind` argument names, or an error. +pub fn require_valid_kind(kind: u8) -> Result<(), ProgramError> { + require!( + kind == KIND_CALL || kind == KIND_PUT, + OptionsError::InvalidParameter + ); + Ok(()) +} + +/// Split a premium into the venue's fee and the writer's share. The fee +/// floors, so the writer receives the rounding minor unit. +pub fn split_premium(premium: u64, fee_bps: u16) -> Result<(u64, u64), ProgramError> { + let fee = (premium as u128) + .checked_mul(fee_bps as u128) + .ok_or(OptionsError::MathOverflow)? + .checked_div(BASIS_POINTS_DENOMINATOR as u128) + .ok_or(OptionsError::MathOverflow)?; + let fee = u64::try_from(fee).map_err(|_| OptionsError::MathOverflow)?; + let to_writer = premium.checked_sub(fee).ok_or(OptionsError::MathOverflow)?; + Ok((fee, to_writer)) +} + +/// The holder may exercise while the option has not expired. +pub fn may_exercise(now: i64, expiry: i64) -> bool { + now < expiry +} + +/// The writer may reclaim once the option has expired: the exact complement +/// of `may_exercise`. +pub fn may_reclaim(now: i64, expiry: i64) -> bool { + now >= expiry +} + +/// The custody invariant, asserted after the math in every handler that +/// moves tokens: each vault covers what the market owes. The two arguments +/// are the vault balances the handler's transfers will leave behind. +pub fn check_custody( + market: &Account, + underlying_after: u64, + quote_after: u64, +) -> Result<(), ProgramError> { + require!( + underlying_after >= market.underlying_locked.get(), + OptionsError::CustodyInvariantViolated + ); + let quote_owed = market + .quote_locked + .get() + .checked_add(market.fees_owed.get()) + .ok_or(OptionsError::MathOverflow)?; + require!( + quote_after >= quote_owed, + OptionsError::CustodyInvariantViolated + ); + Ok(()) +} + +/// Add `amount` to a ledger counter and to the matching projected balance. +pub fn add_locked( + counter: &mut PodU64, + balance: &mut u64, + amount: u64, +) -> Result<(), ProgramError> { + counter.set( + counter + .get() + .checked_add(amount) + .ok_or(OptionsError::MathOverflow)?, + ); + *balance = balance + .checked_add(amount) + .ok_or(OptionsError::MathOverflow)?; + Ok(()) +} + +/// Subtract `amount` from a ledger counter and from the matching projected +/// balance. A balance that cannot cover the subtraction is a custody failure. +pub fn sub_locked( + counter: &mut PodU64, + balance: &mut u64, + amount: u64, +) -> Result<(), ProgramError> { + counter.set( + counter + .get() + .checked_sub(amount) + .ok_or(OptionsError::MathOverflow)?, + ); + *balance = balance + .checked_sub(amount) + .ok_or(OptionsError::CustodyInvariantViolated)?; + Ok(()) +} + +/// A transfer out of a vault, signed by the market's vault authority PDA. +pub fn transfer_from_vault( + token_program: &Program, + vault: &Account, + mint: &Account, + to: &Account, + market_authority: &UncheckedAccount, + market: &Account, + amount: u64, +) -> Result<(), ProgramError> { + let bump = [market.authority_bump]; + let market_address = *market.address(); + let seeds: &[Seed] = &[ + Seed::from(b"authority".as_ref()), + Seed::from(market_address.as_ref()), + Seed::from(&bump as &[u8]), + ]; + token_program + .transfer_checked(vault, mint, to, market_authority, amount, mint.decimals()) + .invoke_signed(seeds) +} diff --git a/finance/options/quasar/src/instructions/write_option.rs b/finance/options/quasar/src/instructions/write_option.rs new file mode 100644 index 00000000..58643f52 --- /dev/null +++ b/finance/options/quasar/src/instructions/write_option.rs @@ -0,0 +1,159 @@ +use { + crate::{ + constants::STATUS_LISTED, + errors::OptionsError, + instructions::shared::{add_locked, check_custody, require_valid_kind, Terms}, + state::{Market, OptionContract, OptionContractInner}, + }, + quasar_lang::{prelude::*, sysvars::Sysvar as _}, + quasar_spl::prelude::*, +}; + +/// The instruction's arguments, bundled so the handler signature stays +/// readable. +#[derive(Clone, Copy)] +pub struct WriteOptionArguments { + pub id: u64, + pub kind: u8, + pub contracts: u64, + pub underlying_per_contract: u64, + pub strike_per_contract: u64, + pub premium: u64, + pub expiry: i64, +} + +#[derive(Accounts)] +#[instruction(id: u64)] +pub struct WriteOptionAccountConstraints { + #[account(mut)] + pub writer: Signer, + #[account( + mut, + address = Market::seeds(underlying_mint.address(), quote_mint.address()), + has_one(underlying_vault), + has_one(quote_vault), + )] + pub market: Account, + #[account( + mut, + init, + payer = writer, + address = OptionContract::seeds(market.address(), writer.address(), id), + )] + pub option: Account, + pub underlying_mint: Account, + pub quote_mint: Account, + #[account(mut)] + pub underlying_vault: Account, + #[account(mut)] + pub quote_vault: Account, + /// A call writer pays collateral from this account; a put writer's copy + /// is only validated. Unlike the Anchor sibling, it must already exist. + #[account(mut)] + pub writer_underlying: Account, + /// A put writer pays collateral from this account, and every writer is + /// paid their premium into it by `buy_option`. Must already exist. + #[account(mut)] + pub writer_quote: Account, + pub token_program: Program, + pub system_program: Program, + pub rent: Sysvar, +} + +/// Write an option. The writer posts the entire collateral up front: +/// the underlying for a call, the strike in the quote token for a put. From +/// this moment the vault holds everything a future holder could claim, which +/// is why nothing in this program ever has to be liquidated. +#[inline(always)] +pub fn handle_write_option( + accounts: &mut WriteOptionAccountConstraints, + arguments: WriteOptionArguments, + 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. + require!( + arguments.contracts > 0 + && arguments.underlying_per_contract > 0 + && arguments.strike_per_contract > 0 + && arguments.premium > 0, + OptionsError::InvalidParameter + ); + // Written in words: the holder may exercise while now < expiry. An expiry + // at or before now would create an option nobody could ever exercise. + let now: i64 = Clock::get()?.unix_timestamp.into(); + require!(arguments.expiry > now, OptionsError::ExpiryInPast); + + let terms = Terms { + kind: arguments.kind, + contracts: arguments.contracts, + underlying_per_contract: arguments.underlying_per_contract, + strike_per_contract: arguments.strike_per_contract, + }; + // 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()?; + + // Effects before the transfer: record the option and what the vault now owes. + accounts.option.set_inner(OptionContractInner { + id: arguments.id, + 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, + premium: arguments.premium, + expiry: arguments.expiry, + kind: arguments.kind, + status: STATUS_LISTED, + bump: bumps.option, + }); + + let mut underlying_after = accounts.underlying_vault.amount(); + let mut quote_after = accounts.quote_vault.amount(); + if terms.is_call() { + add_locked( + &mut accounts.market.underlying_locked, + &mut underlying_after, + collateral, + )?; + } else { + add_locked( + &mut accounts.market.quote_locked, + &mut quote_after, + collateral, + )?; + } + check_custody(&accounts.market, underlying_after, quote_after)?; + + if terms.is_call() { + accounts + .token_program + .transfer_checked( + &accounts.writer_underlying, + &accounts.underlying_mint, + &accounts.underlying_vault, + &accounts.writer, + collateral, + accounts.underlying_mint.decimals(), + ) + .invoke() + } else { + accounts + .token_program + .transfer_checked( + &accounts.writer_quote, + &accounts.quote_mint, + &accounts.quote_vault, + &accounts.writer, + collateral, + accounts.quote_mint.decimals(), + ) + .invoke() + } +} diff --git a/finance/options/quasar/src/lib.rs b/finance/options/quasar/src/lib.rs new file mode 100644 index 00000000..3c828682 --- /dev/null +++ b/finance/options/quasar/src/lib.rs @@ -0,0 +1,92 @@ +#![cfg_attr(not(test), no_std)] + +//! Quasar port of the options example. The design, math, and behavior match +//! the Anchor sibling at `finance/options/anchor`; see its README for the +//! full walkthrough. This file wires up the program; the per-instruction +//! logic lives in `instructions/`. + +use quasar_lang::prelude::*; + +pub mod constants; +pub mod errors; +pub mod instructions; +pub mod state; +#[cfg(test)] +mod tests; + +use instructions::*; + +declare_id!("2gmMGMmipfYypLxWsvQ5GQJT5AGnMWmk4Rb9vQMRo6ig"); + +#[program] +mod quasar_options { + use super::*; + + #[instruction(discriminator = 0)] + pub fn initialize_market( + ctx: Ctx, + fee_bps: u16, + ) -> Result<(), ProgramError> { + instructions::handle_initialize_market(&mut ctx.accounts, fee_bps, &ctx.bumps) + } + + #[instruction(discriminator = 1)] + pub fn write_option( + ctx: Ctx, + id: u64, + kind: u8, + contracts: u64, + underlying_per_contract: u64, + strike_per_contract: u64, + premium: u64, + expiry: i64, + ) -> Result<(), ProgramError> { + instructions::handle_write_option( + &mut ctx.accounts, + WriteOptionArguments { + id, + kind, + contracts, + underlying_per_contract, + strike_per_contract, + premium, + expiry, + }, + &ctx.bumps, + ) + } + + #[instruction(discriminator = 2)] + pub fn buy_option(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_buy_option(&mut ctx.accounts) + } + + #[instruction(discriminator = 3)] + pub fn cancel_option(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_cancel_option(&mut ctx.accounts) + } + + #[instruction(discriminator = 4)] + pub fn exercise_option(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_exercise_option(&mut ctx.accounts) + } + + #[instruction(discriminator = 5)] + pub fn collect_proceeds( + ctx: Ctx, + ) -> Result<(), ProgramError> { + instructions::handle_collect_proceeds(&mut ctx.accounts) + } + + #[instruction(discriminator = 6)] + pub fn reclaim_collateral( + ctx: Ctx, + ) -> Result<(), ProgramError> { + instructions::handle_reclaim_collateral(&mut ctx.accounts) + } + + #[instruction(discriminator = 7)] + pub fn collect_fees(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_collect_fees(&mut ctx.accounts) + } +} diff --git a/finance/options/quasar/src/state.rs b/finance/options/quasar/src/state.rs new file mode 100644 index 00000000..bdf74b0c --- /dev/null +++ b/finance/options/quasar/src/state.rs @@ -0,0 +1,71 @@ +use quasar_lang::prelude::*; + +/// One options venue. Mirrors the Anchor `Market` field-for-field; see the +/// Anchor sibling's README for what each field means. The three `*_locked` / +/// `fees_owed` counters are the ledger of what each vault owes, asserted +/// against the vault balances after every transfer. +#[account(discriminator = 1, set_inner)] +#[seeds(b"market", underlying_mint: Address, quote_mint: Address)] +pub struct Market { + pub admin: Address, + pub underlying_mint: Address, + pub quote_mint: Address, + pub underlying_vault: Address, + pub quote_vault: Address, + /// Underlying minor units the vault owes: call writers' collateral, plus + /// put holders' deliveries awaiting the writer's `collect_proceeds`. + pub underlying_locked: u64, + /// Quote minor units the vault owes: put writers' collateral, plus call + /// holders' strike payments awaiting the writer's `collect_proceeds`. + pub quote_locked: u64, + /// Quote minor units held for the admin, swept by `collect_fees`. + pub fees_owed: u64, + /// Fee charged on each premium, in basis points. + pub fee_bps: u16, + pub bump: u8, + pub authority_bump: u8, +} + +/// 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. +#[account(discriminator = 2, set_inner)] +#[seeds(b"option", market: Address, writer: Address, id: u64)] +pub struct OptionContract { + pub id: u64, + pub market: Address, + 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, + 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 + /// expiry is a calendar date the parties agreed on; the program reads no + /// oracle, so slot-measured freshness never enters into it. + pub expiry: i64, + pub kind: u8, + pub status: u8, + pub bump: u8, +} + +/// Authority PDA at seeds = [b"authority", market]. Holds no data; signs +/// every transfer out of either vault. +#[derive(Seeds)] +#[seeds(b"authority", market: Address)] +pub struct MarketAuthorityPda; + +/// Underlying-token vault PDA at seeds = [b"underlying_vault", market]. +#[derive(Seeds)] +#[seeds(b"underlying_vault", market: Address)] +pub struct UnderlyingVaultPda; + +/// Quote-token vault PDA at seeds = [b"quote_vault", market]. +#[derive(Seeds)] +#[seeds(b"quote_vault", market: Address)] +pub struct QuoteVaultPda; diff --git a/finance/options/quasar/src/tests.rs b/finance/options/quasar/src/tests.rs new file mode 100644 index 00000000..e3ffdf87 --- /dev/null +++ b/finance/options/quasar/src/tests.rs @@ -0,0 +1,814 @@ +//! quasar-test integration tests: open a venue, write a covered call and a +//! cash-secured put, buy them, exercise or let them expire, collect, and +//! prove every gate shuts (the expiry boundary from both sides, status +//! checks, and who may call what). + +use { + crate::{ + constants::{KIND_CALL, KIND_PUT, STATUS_EXERCISED, STATUS_HELD, STATUS_LISTED}, + cpi::{ + BuyOptionInstruction, CancelOptionInstruction, CollectFeesInstruction, + CollectProceedsInstruction, ExerciseOptionInstruction, InitializeMarketInstruction, + ReclaimCollateralInstruction, WriteOptionInstruction, + }, + errors::OptionsError, + state::{Market, OptionContract, QuoteVaultPda, UnderlyingVaultPda}, + }, + quasar_test::prelude::*, +}; + +// Both tokens have 6 decimals: the underlying is NVDAx (tokenized NVIDIA +// stock) and the quote is USDC. +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; +const CALL_PREMIUM: u64 = 25 * ONE_TOKEN; +const PUT_STRIKE: u64 = 150 * ONE_TOKEN; +const PUT_PREMIUM: u64 = 20 * ONE_TOKEN; +const CALL_ID: u64 = 1; +const PUT_ID: u64 = 2; + +// Every character starts with the standard wallet of 1,000 USDC; the story +// hands the writers 5 NVDAx. +const STANDARD_USDC: u64 = 1_000 * ONE_TOKEN; +const FIVE_NVDAX: u64 = 5 * ONE_TOKEN; + +// A fixed unix timestamp the clock is warped to before anything is written, +// so the expiry a week later is deterministic. +const START_TIME: i64 = 1_750_000_000; +const SECONDS_PER_DAY: i64 = 24 * 60 * 60; +const EXPIRY: i64 = START_TIME + 7 * SECONDS_PER_DAY; + +// Deterministic addresses. +const MARIA: Pubkey = Pubkey::new_from_array([1; 32]); +const NVDAX_MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const USDC_MINT: Pubkey = Pubkey::new_from_array([3; 32]); +const MARIA_USDC: Pubkey = Pubkey::new_from_array([4; 32]); +const ALICE: Pubkey = Pubkey::new_from_array([5; 32]); +const ALICE_NVDAX: Pubkey = Pubkey::new_from_array([6; 32]); +const ALICE_USDC: Pubkey = Pubkey::new_from_array([7; 32]); +const BOB: Pubkey = Pubkey::new_from_array([8; 32]); +const BOB_NVDAX: Pubkey = Pubkey::new_from_array([9; 32]); +const BOB_USDC: Pubkey = Pubkey::new_from_array([10; 32]); +const CAROL: Pubkey = Pubkey::new_from_array([11; 32]); +const CAROL_NVDAX: Pubkey = Pubkey::new_from_array([12; 32]); +const CAROL_USDC: Pubkey = Pubkey::new_from_array([13; 32]); +const DAVE: Pubkey = Pubkey::new_from_array([14; 32]); +const DAVE_NVDAX: Pubkey = Pubkey::new_from_array([15; 32]); +const DAVE_USDC: Pubkey = Pubkey::new_from_array([16; 32]); +const MALLORY: Pubkey = Pubkey::new_from_array([17; 32]); +const MALLORY_NVDAX: Pubkey = Pubkey::new_from_array([18; 32]); +const MALLORY_USDC: Pubkey = Pubkey::new_from_array([19; 32]); +// A second USDC account of Alice's, for the self-purchase test. +const ALICE_OTHER_USDC: Pubkey = Pubkey::new_from_array([20; 32]); + +struct Env { + market: Pubkey, + underlying_vault: Pubkey, + quote_vault: Pubkey, +} + +/// A character with a wallet and both token accounts. +struct Person { + wallet: Pubkey, + nvdax: Pubkey, + usdc: Pubkey, +} + +const fn person(wallet: Pubkey, nvdax: Pubkey, usdc: Pubkey) -> Person { + Person { + wallet, + nvdax, + usdc, + } +} + +const ALICE_P: Person = person(ALICE, ALICE_NVDAX, ALICE_USDC); +const BOB_P: Person = person(BOB, BOB_NVDAX, BOB_USDC); +const CAROL_P: Person = person(CAROL, CAROL_NVDAX, CAROL_USDC); +const DAVE_P: Person = person(DAVE, DAVE_NVDAX, DAVE_USDC); +const MALLORY_P: Person = person(MALLORY, MALLORY_NVDAX, MALLORY_USDC); + +fn add_person(test: &mut Test, who: &Person, nvdax: u64, usdc: u64) { + test.add(Wallet::new().at(who.wallet)); + test.add( + TokenAccount::new(NVDAX_MINT, who.wallet) + .at(who.nvdax) + .amount(nvdax), + ); + test.add( + TokenAccount::new(USDC_MINT, who.wallet) + .at(who.usdc) + .amount(usdc), + ); +} + +fn initialize_market(test: &mut Test, fee_bps: u16, quote_mint: Pubkey) -> Outcome { + test.send(InitializeMarketInstruction { + admin: MARIA, + underlying_mint: NVDAX_MINT, + quote_mint, + fee_bps, + }) +} + +/// Mints, the clock at `START_TIME`, Maria's wallet, and a venue at +/// `FEE_BPS`. Alice and Dave hold 5 NVDAx; everyone holds 1,000 USDC. +fn setup_with_fee(test: &mut Test, fee_bps: u16) -> Env { + test.add(Wallet::new().at(MARIA)); + test.add(TokenAccount::new(USDC_MINT, MARIA).at(MARIA_USDC).amount(0)); + test.add(Mint::new(MARIA).at(NVDAX_MINT).decimals(6)); + test.add(Mint::new(MARIA).at(USDC_MINT).decimals(6)); + test.warp_to_timestamp(START_TIME); + add_person(test, &ALICE_P, FIVE_NVDAX, STANDARD_USDC); + add_person(test, &BOB_P, 0, STANDARD_USDC); + add_person(test, &CAROL_P, 0, STANDARD_USDC); + add_person(test, &DAVE_P, FIVE_NVDAX, STANDARD_USDC); + add_person(test, &MALLORY_P, 0, STANDARD_USDC); + initialize_market(test, fee_bps, USDC_MINT).succeeds(); + let market = test.derive_pda(Market::seeds(&NVDAX_MINT, &USDC_MINT)); + Env { + market, + underlying_vault: test.derive_pda(UnderlyingVaultPda::seeds(&market)), + quote_vault: test.derive_pda(QuoteVaultPda::seeds(&market)), + } +} + +fn setup(test: &mut Test) -> Env { + setup_with_fee(test, FEE_BPS) +} + +#[allow(clippy::too_many_arguments)] +fn write_option( + test: &mut Test, + env: &Env, + writer: &Person, + id: u64, + kind: u8, + contracts: u64, + underlying_per_contract: u64, + strike_per_contract: u64, + premium: u64, + expiry: i64, +) -> Outcome { + test.send(WriteOptionInstruction { + writer: writer.wallet, + underlying_mint: NVDAX_MINT, + quote_mint: USDC_MINT, + underlying_vault: env.underlying_vault, + quote_vault: env.quote_vault, + writer_underlying: writer.nvdax, + writer_quote: writer.usdc, + id, + kind, + contracts, + underlying_per_contract, + strike_per_contract, + premium, + expiry, + }) +} + +fn option_pda(test: &Test, env: &Env, writer: &Person, id: u64) -> Pubkey { + test.derive_pda(OptionContract::seeds(&env.market, &writer.wallet, id)) +} + +/// Alice's call, written and listed. +fn write_call(test: &mut Test, env: &Env) -> Pubkey { + write_option( + test, + env, + &ALICE_P, + CALL_ID, + KIND_CALL, + CONTRACTS, + ONE_NVDAX_PER_CONTRACT, + CALL_STRIKE, + CALL_PREMIUM, + EXPIRY, + ) + .succeeds(); + option_pda(test, env, &ALICE_P, CALL_ID) +} + +/// Carol's put, written and listed. +fn write_put(test: &mut Test, env: &Env) -> Pubkey { + write_option( + test, + env, + &CAROL_P, + PUT_ID, + KIND_PUT, + CONTRACTS, + ONE_NVDAX_PER_CONTRACT, + PUT_STRIKE, + PUT_PREMIUM, + EXPIRY, + ) + .succeeds(); + option_pda(test, env, &CAROL_P, PUT_ID) +} + +fn buy_option(test: &mut Test, env: &Env, buyer: &Person, writer: &Person, id: u64) -> Outcome { + test.send(BuyOptionInstruction { + buyer: buyer.wallet, + writer: writer.wallet, + option_id_seed: id, + underlying_mint: NVDAX_MINT, + quote_mint: USDC_MINT, + quote_vault: env.quote_vault, + buyer_quote: buyer.usdc, + writer_quote: writer.usdc, + }) +} + +fn cancel_option(test: &mut Test, env: &Env, writer: &Person, id: u64) -> Outcome { + test.send(CancelOptionInstruction { + writer: writer.wallet, + option_id_seed: id, + underlying_mint: NVDAX_MINT, + quote_mint: USDC_MINT, + underlying_vault: env.underlying_vault, + quote_vault: env.quote_vault, + writer_underlying: writer.nvdax, + writer_quote: writer.usdc, + }) +} + +fn exercise_option( + test: &mut Test, + env: &Env, + holder: &Person, + writer: &Person, + id: u64, +) -> Outcome { + test.send(ExerciseOptionInstruction { + holder: holder.wallet, + writer: writer.wallet, + option_id_seed: id, + underlying_mint: NVDAX_MINT, + quote_mint: USDC_MINT, + underlying_vault: env.underlying_vault, + quote_vault: env.quote_vault, + holder_underlying: holder.nvdax, + holder_quote: holder.usdc, + }) +} + +fn collect_proceeds(test: &mut Test, env: &Env, writer: &Person, id: u64) -> Outcome { + test.send(CollectProceedsInstruction { + writer: writer.wallet, + option_id_seed: id, + underlying_mint: NVDAX_MINT, + quote_mint: USDC_MINT, + underlying_vault: env.underlying_vault, + quote_vault: env.quote_vault, + writer_underlying: writer.nvdax, + writer_quote: writer.usdc, + }) +} + +fn reclaim_collateral(test: &mut Test, env: &Env, writer: &Person, id: u64) -> Outcome { + test.send(ReclaimCollateralInstruction { + writer: writer.wallet, + option_id_seed: id, + underlying_mint: NVDAX_MINT, + quote_mint: USDC_MINT, + underlying_vault: env.underlying_vault, + quote_vault: env.quote_vault, + writer_underlying: writer.nvdax, + writer_quote: writer.usdc, + }) +} + +fn collect_fees(test: &mut Test, env: &Env, admin: Pubkey, admin_quote: Pubkey) -> Outcome { + test.send(CollectFeesInstruction { + admin, + underlying_mint: NVDAX_MINT, + quote_mint: USDC_MINT, + underlying_vault: env.underlying_vault, + quote_vault: env.quote_vault, + admin_quote, + }) +} + +/// The custody invariant: each vault holds exactly what the market owes. +fn assert_vaults_match_ledger(test: &Test, env: &Env) { + let market = test.read::(env.market); + assert_eq!( + test.tokens(env.underlying_vault), + u64::from(market.underlying_locked), + "underlying vault must hold exactly the locked underlying" + ); + assert_eq!( + test.tokens(env.quote_vault), + u64::from(market.quote_locked) + u64::from(market.fees_owed), + "quote vault must hold exactly the locked quote plus the fees owed" + ); +} + +// =========================================================================== +// The call: write, buy, exercise, collect +// =========================================================================== + +/// Alice writes 5 covered calls 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_locks_the_underlying(test: &mut Test) { + let env = setup(test); + let option = write_call(test, &env); + + assert_eq!(test.tokens(ALICE_NVDAX), 0); + assert_eq!(test.tokens(env.underlying_vault), FIVE_NVDAX); + let state = test.read::(option); + assert_eq!(state.writer, ALICE); + 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.premium), CALL_PREMIUM); + assert_eq!(i64::from(state.expiry), EXPIRY); + assert_eq!( + u64::from(test.read::(env.market).underlying_locked), + FIVE_NVDAX + ); + assert_vaults_match_ledger(test, &env); +} + +/// Bob buys the option. He pays 25 USDC: 1% (0.25 USDC) to the venue, the rest +/// straight to Alice. The 5 NVDAx do not move. +#[quasar_test] +fn buy_option_pays_the_premium_minus_the_fee(test: &mut Test) { + let env = setup(test); + let option = write_call(test, &env); + + let fee = 250_000; // 0.25 USDC + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID) + .succeeds() + .has_tokens(BOB_USDC, STANDARD_USDC - CALL_PREMIUM) + .has_tokens(ALICE_USDC, STANDARD_USDC + CALL_PREMIUM - fee) + .has_tokens(env.quote_vault, fee); + // The underlying vault is not part of a buy, and did not move. + assert_eq!(test.tokens(env.underlying_vault), FIVE_NVDAX); + + let state = test.read::(option); + assert_eq!(state.holder, BOB); + assert_eq!(state.status, STATUS_HELD); + assert_eq!(u64::from(test.read::(env.market).fees_owed), fee); + assert_vaults_match_ledger(test, &env); +} + +/// NVIDIA rallies past the strike offchain, so Bob exercises: he pays the +/// strike, 5 x 180 = 900 USDC, into the vault and takes the 5 NVDAx. +#[quasar_test] +fn exercise_call_swaps_the_strike_for_the_underlying(test: &mut Test) { + let env = setup(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(env.underlying_vault, 0) + .has_tokens(env.quote_vault, strike_total + 250_000); + + let market = test.read::(env.market); + assert_eq!(u64::from(market.underlying_locked), 0); + assert_eq!(u64::from(market.quote_locked), strike_total); + assert_eq!(test.read::(option).status, STATUS_EXERCISED); + assert_vaults_match_ledger(test, &env); +} + +/// Alice collects the 900 USDC Bob paid, and the option closes. +#[quasar_test] +fn collect_proceeds_pays_the_writer_and_closes_the_option(test: &mut Test) { + let env = setup(test); + let option = write_call(test, &env); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + exercise_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + + collect_proceeds(test, &env, &ALICE_P, CALL_ID) + .succeeds() + .has_tokens( + ALICE_USDC, + STANDARD_USDC + 900 * ONE_TOKEN + CALL_PREMIUM - 250_000, + ) + .has_tokens(ALICE_NVDAX, 0) + .is_closed(option); + + let market = test.read::(env.market); + assert_eq!(u64::from(market.quote_locked), 0); + assert_eq!(u64::from(market.fees_owed), 250_000); + assert_vaults_match_ledger(test, &env); +} + +/// Maria sweeps the venue's fee. Only the 0.25 USDC of fees leaves the +/// vault; the strike payment sitting beside it stays locked to Alice. +#[quasar_test] +fn collect_fees_pays_only_the_fees_owed(test: &mut Test) { + let env = setup(test); + write_call(test, &env); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + exercise_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + + collect_fees(test, &env, MARIA, MARIA_USDC) + .succeeds() + .has_tokens(MARIA_USDC, 250_000) + .has_tokens(env.quote_vault, 900 * ONE_TOKEN); + assert_eq!(u64::from(test.read::(env.market).fees_owed), 0); + assert_vaults_match_ledger(test, &env); + + // Nothing left to sweep. + collect_fees(test, &env, MARIA, MARIA_USDC).fails_with(OptionsError::NothingToCollect); +} + +// =========================================================================== +// 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. +#[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; + assert_eq!(test.tokens(CAROL_USDC), STANDARD_USDC - collateral); + assert_eq!(test.tokens(env.quote_vault), collateral); + assert_vaults_match_ledger(test, &env); + + let fee = 200_000; // 1% of 20 USDC + buy_option(test, &env, &DAVE_P, &CAROL_P, PUT_ID) + .succeeds() + .has_tokens(CAROL_USDC, STANDARD_USDC - collateral + PUT_PREMIUM - fee) + .has_tokens(DAVE_USDC, STANDARD_USDC - PUT_PREMIUM); + assert_vaults_match_ledger(test, &env); + + exercise_option(test, &env, &DAVE_P, &CAROL_P, PUT_ID) + .succeeds() + .has_tokens(DAVE_NVDAX, 0) + .has_tokens(DAVE_USDC, STANDARD_USDC - PUT_PREMIUM + collateral) + .has_tokens(env.underlying_vault, FIVE_NVDAX) + .has_tokens(env.quote_vault, fee); + let market = test.read::(env.market); + assert_eq!(u64::from(market.underlying_locked), FIVE_NVDAX); + assert_eq!(u64::from(market.quote_locked), 0); + assert_vaults_match_ledger(test, &env); + + collect_proceeds(test, &env, &CAROL_P, PUT_ID) + .succeeds() + .has_tokens(CAROL_NVDAX, FIVE_NVDAX) + .is_closed(option); + assert_eq!( + u64::from(test.read::(env.market).underlying_locked), + 0 + ); + assert_vaults_match_ledger(test, &env); +} + +/// Bob never exercises. Once the expiry passes, Alice takes her 5 NVDAx back +/// and keeps the premium. +#[quasar_test] +fn reclaim_collateral_after_expiry_returns_it_to_the_writer(test: &mut Test) { + let env = setup(test); + let option = write_call(test, &env); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + + test.warp_to_timestamp(EXPIRY); + reclaim_collateral(test, &env, &ALICE_P, CALL_ID) + .succeeds() + .has_tokens(ALICE_NVDAX, FIVE_NVDAX) + .has_tokens(ALICE_USDC, STANDARD_USDC + CALL_PREMIUM - 250_000) + .is_closed(option); + // Bob is not part of the reclaim: he is left with nothing to claim. + assert_eq!(test.tokens(BOB_USDC), STANDARD_USDC - CALL_PREMIUM); + assert_eq!( + u64::from(test.read::(env.market).underlying_locked), + 0 + ); + assert_vaults_match_ledger(test, &env); +} + +// =========================================================================== +// The expiry boundary, from both sides +// =========================================================================== + +/// The holder may exercise while now < expiry: one second before, yes; at +/// expiry, no. +#[quasar_test] +fn exercise_is_allowed_up_to_but_not_at_expiry(test: &mut Test) { + let env = setup(test); + write_call(test, &env); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + + test.warp_to_timestamp(EXPIRY); + exercise_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).fails_with(OptionsError::OptionExpired); + + test.warp_to_timestamp(EXPIRY - 1); + exercise_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); +} + +/// The writer may reclaim once now >= expiry, and not one second earlier. +#[quasar_test] +fn reclaim_is_refused_before_expiry(test: &mut Test) { + let env = setup(test); + write_call(test, &env); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + + test.warp_to_timestamp(EXPIRY - 1); + reclaim_collateral(test, &env, &ALICE_P, CALL_ID).fails_with(OptionsError::OptionNotExpired); + + test.warp_to_timestamp(EXPIRY); + reclaim_collateral(test, &env, &ALICE_P, CALL_ID).succeeds(); +} + +/// An expired option cannot be bought. +#[quasar_test] +fn buy_is_refused_after_expiry(test: &mut Test) { + let env = setup(test); + write_call(test, &env); + test.warp_to_timestamp(EXPIRY); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).fails_with(OptionsError::OptionExpired); +} + +// =========================================================================== +// Cancel: the writer's exit from an unsold option +// =========================================================================== + +#[quasar_test] +fn cancel_unsold_option_returns_the_collateral(test: &mut Test) { + let env = setup(test); + let option = write_call(test, &env); + + cancel_option(test, &env, &ALICE_P, CALL_ID) + .succeeds() + .has_tokens(ALICE_NVDAX, FIVE_NVDAX) + .has_tokens(env.underlying_vault, 0) + .is_closed(option); + assert_vaults_match_ledger(test, &env); +} + +/// An unsold option that expired is still the writer's to cancel. +#[quasar_test] +fn cancel_unsold_option_works_after_expiry(test: &mut Test) { + let env = setup(test); + write_put(test, &env); + test.warp_to_timestamp(EXPIRY + SECONDS_PER_DAY); + cancel_option(test, &env, &CAROL_P, PUT_ID) + .succeeds() + .has_tokens(CAROL_USDC, STANDARD_USDC); + assert_eq!(u64::from(test.read::(env.market).quote_locked), 0); + assert_vaults_match_ledger(test, &env); +} + +/// Once sold, the collateral belongs to the deal. +#[quasar_test] +fn cancel_is_refused_once_sold(test: &mut Test) { + let env = setup(test); + write_call(test, &env); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + cancel_option(test, &env, &ALICE_P, CALL_ID).fails_with(OptionsError::OptionNotListed); + assert_eq!(test.tokens(env.underlying_vault), FIVE_NVDAX); +} + +// =========================================================================== +// Who may do what +// =========================================================================== + +#[quasar_test] +fn buy_is_refused_once_sold(test: &mut Test) { + let env = setup(test); + let option = write_call(test, &env); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + buy_option(test, &env, &CAROL_P, &ALICE_P, CALL_ID).fails_with(OptionsError::OptionNotListed); + assert_eq!(test.read::(option).holder, BOB); +} + +/// A writer cannot buy their own option: their address would sit in the `buyer` +/// and `writer` slots at once, which the runtime refuses before the handler +/// runs, whichever of their token accounts the premium is paid from. +#[quasar_test] +fn writer_cannot_buy_their_own_option(test: &mut Test) { + let env = setup(test); + let option = write_call(test, &env); + assert!(buy_option(test, &env, &ALICE_P, &ALICE_P, CALL_ID).is_err()); + + test.add( + TokenAccount::new(USDC_MINT, ALICE) + .at(ALICE_OTHER_USDC) + .amount(STANDARD_USDC), + ); + let alice_from_other_account = person(ALICE, ALICE_NVDAX, ALICE_OTHER_USDC); + assert!(buy_option(test, &env, &alice_from_other_account, &ALICE_P, CALL_ID).is_err()); + assert_eq!(test.read::(option).status, STATUS_LISTED); + assert_eq!(test.tokens(ALICE_OTHER_USDC), STANDARD_USDC); +} + +/// A buyer cannot route the premium to their own account by passing it as +/// the writer's. +#[quasar_test] +fn buy_refuses_a_premium_account_the_writer_does_not_own(test: &mut Test) { + let env = setup(test); + write_call(test, &env); + let mut instruction: Instruction = BuyOptionInstruction { + buyer: BOB, + writer: ALICE, + option_id_seed: CALL_ID, + underlying_mint: NVDAX_MINT, + quote_mint: USDC_MINT, + quote_vault: env.quote_vault, + buyer_quote: BOB_USDC, + writer_quote: ALICE_USDC, + } + .into(); + // Account order matches the `#[derive(Accounts)]` struct: writer_quote is + // the last account before the token program. + let writer_quote_index = instruction + .accounts + .iter() + .position(|meta| meta.pubkey == ALICE_USDC) + .unwrap(); + instruction.accounts[writer_quote_index].pubkey = MALLORY_USDC; + test.send(instruction) + .fails_with(OptionsError::InvalidParameter); + assert_eq!(test.tokens(MALLORY_USDC), STANDARD_USDC); +} + +/// Only the holder can exercise. +#[quasar_test] +fn exercise_is_refused_for_anyone_but_the_holder(test: &mut Test) { + let env = setup(test); + write_call(test, &env); + // Unsold: the holder field is all zeroes, which no signer can match. + assert!(exercise_option(test, &env, &MALLORY_P, &ALICE_P, CALL_ID).is_err()); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + assert!(exercise_option(test, &env, &MALLORY_P, &ALICE_P, CALL_ID).is_err()); + assert_eq!(test.tokens(env.underlying_vault), FIVE_NVDAX); +} + +/// Proceeds exist only after exercise, and only the writer may collect them. +#[quasar_test] +fn collect_proceeds_needs_an_exercised_option_and_the_writer(test: &mut Test) { + let env = setup(test); + write_call(test, &env); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + collect_proceeds(test, &env, &ALICE_P, CALL_ID).fails_with(OptionsError::OptionNotExercised); + + exercise_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + // 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); +} + +/// An exercised option has no collateral left to reclaim, whatever the clock +/// says. +#[quasar_test] +fn reclaim_is_refused_after_exercise(test: &mut Test) { + let env = setup(test); + write_call(test, &env); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + exercise_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + test.warp_to_timestamp(EXPIRY + 1); + reclaim_collateral(test, &env, &ALICE_P, CALL_ID).fails_with(OptionsError::OptionNotHeld); +} + +#[quasar_test] +fn collect_fees_is_refused_for_anyone_but_the_admin(test: &mut Test) { + let env = setup(test); + write_call(test, &env); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); + assert!(collect_fees(test, &env, MALLORY, MALLORY_USDC).is_err()); + assert_eq!( + u64::from(test.read::(env.market).fees_owed), + 250_000 + ); +} + +// =========================================================================== +// Parameter validation +// =========================================================================== + +#[quasar_test] +fn write_option_rejects_zero_quantities_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), + ]; + for (offset, (contracts, per_contract, strike, premium)) in attempts.into_iter().enumerate() { + write_option( + test, + &env, + &ALICE_P, + 10 + offset as u64, + KIND_CALL, + contracts, + per_contract, + strike, + premium, + EXPIRY, + ) + .fails_with(OptionsError::InvalidParameter); + } + assert_eq!(test.tokens(ALICE_NVDAX), FIVE_NVDAX); +} + +#[quasar_test] +fn write_option_rejects_an_unknown_kind(test: &mut Test) { + let env = setup(test); + write_option( + test, + &env, + &ALICE_P, + 20, + 2, + CONTRACTS, + ONE_NVDAX_PER_CONTRACT, + CALL_STRIKE, + CALL_PREMIUM, + EXPIRY, + ) + .fails_with(OptionsError::InvalidParameter); +} + +/// An expiry at or before now would be an option nobody could ever exercise. +#[quasar_test] +fn write_option_rejects_an_expiry_that_has_passed(test: &mut Test) { + let env = setup(test); + for expiry in [START_TIME, START_TIME - SECONDS_PER_DAY] { + write_option( + test, + &env, + &ALICE_P, + 30, + KIND_CALL, + CONTRACTS, + ONE_NVDAX_PER_CONTRACT, + CALL_STRIKE, + CALL_PREMIUM, + expiry, + ) + .fails_with(OptionsError::ExpiryInPast); + } +} + +/// 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)); + test.add(Mint::new(MARIA).at(NVDAX_MINT).decimals(6)); + test.add(Mint::new(MARIA).at(USDC_MINT).decimals(6)); + initialize_market(test, 10_000, USDC_MINT).fails_with(OptionsError::InvalidParameter); +} + +/// One mint on both sides is refused by the runtime before the handler's own +/// check runs: the same account cannot be loaded into two slots. +#[quasar_test] +fn initialize_market_rejects_the_same_mint_on_both_sides(test: &mut Test) { + test.add(Wallet::new().at(MARIA)); + test.add(Mint::new(MARIA).at(NVDAX_MINT).decimals(6)); + assert!(initialize_market(test, FEE_BPS, NVDAX_MINT).is_err()); +} + +/// A venue run at cost is a valid choice: the writer receives the whole +/// premium and no fee transfer is attempted. +#[quasar_test] +fn zero_fee_venue_pays_the_writer_the_whole_premium(test: &mut Test) { + let env = setup_with_fee(test, 0); + write_call(test, &env); + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID) + .succeeds() + .has_tokens(ALICE_USDC, STANDARD_USDC + CALL_PREMIUM); + assert_eq!(u64::from(test.read::(env.market).fees_owed), 0); + assert_vaults_match_ledger(test, &env); +} diff --git a/llms.txt b/llms.txt index 0092cabb..49264135 100644 --- a/llms.txt +++ b/llms.txt @@ -11,6 +11,7 @@ A Solana program is what other chains call a smart contract. These examples are - [Solana Order Book Exchange](https://github.com/quicknode/solana-program-examples/tree/main/finance/order-book/anchor): a central limit order book (CLOB) with price-time priority matching on a critbit tree, ported from Openbook v2. - [Solana Token Swap AMM](https://github.com/quicknode/solana-program-examples/tree/main/finance/token-swap/anchor): a constant-product automated market maker with liquidity pools, LP tokens, and slippage guards, like Raydium or Orca. - [Solana Prop AMM](https://github.com/quicknode/solana-program-examples/tree/main/finance/prop-amm/anchor): an oracle-quoted proprietary AMM where one market-making firm supplies all capital and earns the spread, like Lifinity or SolFi. +- [Solana Options](https://github.com/quicknode/solana-program-examples/tree/main/finance/options/anchor): a fully collateralized, physically settled options venue, where covered calls and cash-secured puts are written, bought, exercised before expiry or reclaimed after it, with no oracle and no liquidation. - [Solana Perpetual Futures](https://github.com/quicknode/solana-program-examples/tree/main/finance/perpetual-futures/anchor): a pool-collateralized leveraged perps exchange with funding, maintenance margin, and liquidation, like Jupiter Perpetuals. - [Solana Vault Strategy](https://github.com/quicknode/solana-program-examples/tree/main/finance/vault-strategy/anchor): a manager-run onchain fund. Investors deposit USDC for shares priced at net asset value across a weighted asset basket. - [Solana Betting Market](https://github.com/quicknode/solana-program-examples/tree/main/finance/betting-market/anchor): a parimutuel prediction market where winners split the losing pool in proportion to their stake.