diff --git a/finance/perpetual-futures/anchor-v1/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index ce12abd8..f2ee8472 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 2026-09-30 + +Remove `set_funding_rate`. The pool's authority could change the funding rate at +any time, with no upper bound. The lighter side of open interest is paid funding +out of `liquidity`, so the authority could hold a small position on that side +from any wallet, raise the rate, and close it to take the liquidity providers' +deposits. The rate is now fixed by `initialize_pool`, which refuses a rate above +`MAX_FUNDING_RATE_PER_SECOND` (277, just under 0.1% of a position's size per +hour) with `InvalidParameter`. + +Tested by `test_initialize_pool_rejects_funding_rate_above_the_maximum` and +`test_operator_on_the_lighter_side_earns_only_the_fixed_rate`. +`test_set_funding_rate_settles_at_the_old_rate_first` and +`test_only_authority_can_set_funding_rate` are removed with the handler. + ## 2026-09-23 The mock oracle program is now `mock-price-feed` (library and program diff --git a/finance/perpetual-futures/anchor-v1/README.md b/finance/perpetual-futures/anchor-v1/README.md index cc0a81fd..d2e38e21 100644 --- a/finance/perpetual-futures/anchor-v1/README.md +++ b/finance/perpetual-futures/anchor-v1/README.md @@ -48,7 +48,7 @@ So a winning trader can always be paid, the pool **reserves** liquidity to back [Funding](https://www.investopedia.com/terms/f/futurescontract.asp) anchors the pool's risk: the heavier side of [open interest](https://www.investopedia.com/terms/o/openinterest.asp) pays the pool over time. A cumulative funding index rises while longs are the larger side and falls while shorts are, advancing by `funding_rate_per_second` for each second on the Clock's `unix_timestamp`; a position records the index at open and settles the change when it closes. In a pool-based perp this is the equivalent of the borrow fee Jupiter Perpetuals charges. -Funding runs on the wall clock rather than the slot count, so what a position costs per hour is set by the rate alone and does not change when the cluster's slot time does. The timestamp is written by each block's leader, but the runtime bounds how far one block can move it, so the elapsed time behind a position's funding is out by a second or two at most; a timestamp at or before the stored `last_funding_timestamp` accrues nothing. `set_funding_rate(funding_rate_per_second)` lets the pool operator change the rate; it advances the index at the old rate first, so seconds already elapsed are charged at the rate that was in force for them. +Funding runs on the wall clock rather than the slot count, so what a position costs per hour is set by the rate alone and does not change when the cluster's slot time does. The timestamp is written by each block's leader, but the runtime bounds how far one block can move it, so the elapsed time behind a position's funding is out by a second or two at most; a timestamp at or before the stored `last_funding_timestamp` accrues nothing. The rate is set once, in `initialize_pool`, and cannot be changed afterwards; `initialize_pool` refuses a rate above `MAX_FUNDING_RATE_PER_SECOND` (277, just under 0.1% of a position's size per hour). The lighter side is paid funding out of `liquidity`, so an operator who could raise the rate at will could hold a small position on that side, raise the rate and close it to take the liquidity providers' deposits. `test_operator_on_the_lighter_side_earns_only_the_fixed_rate` runs that position and checks it earns only the fixed rate. ### Maintenance margin and liquidation diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs index ee3133dc..07bedc4f 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs @@ -38,6 +38,14 @@ pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; /// liquidatable on the smallest price move. pub const MAX_LEVERAGE_CEILING: u16 = 100; +/// Upper bound on the per-pool `funding_rate_per_second` parameter, in +/// `FUNDING_PRECISION` units: 277 billionths of a position's size per second, +/// just under 0.1% of its size per hour. The rate is fixed when the pool is +/// created, so everyone who opens a position or deposits liquidity has seen it, +/// and no position can be charged or paid funding faster than this. +#[constant] +pub const MAX_FUNDING_RATE_PER_SECOND: u64 = 277; + #[constant] pub const POOL_SEED: &[u8] = b"pool"; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs index 7c207918..f87dafa8 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -5,12 +5,14 @@ use anchor_spl::{ }; use crate::constants::{ - BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_LEVERAGE_CEILING, POOL_SEED, VAULT_SEED, + BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING, + POOL_SEED, VAULT_SEED, }; use crate::errors::PerpError; use crate::state::Pool; -/// Trading parameters set once at pool creation. Bundled into one struct so the +/// Trading parameters set once at pool creation. None of them can be changed +/// afterwards. Bundled into one struct so the /// instruction signature stays readable. #[derive(AnchorSerialize, AnchorDeserialize, Clone)] pub struct PoolParameters { @@ -40,6 +42,12 @@ pub fn handle_initialize_pool( parameters.max_leverage >= 1 && parameters.max_leverage <= MAX_LEVERAGE_CEILING, PerpError::InvalidParameter ); + // The rate never changes after this, so bounding it here bounds it for the + // life of the pool. + require!( + parameters.funding_rate_per_second <= MAX_FUNDING_RATE_PER_SECOND, + PerpError::InvalidParameter + ); require!( parameters.open_fee_bps < denominator, PerpError::InvalidParameter diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs index 9a7210c2..88337e1d 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs @@ -5,7 +5,6 @@ pub mod initialize_pool; pub mod liquidate_position; pub mod open_position; pub mod remove_liquidity; -pub mod set_funding_rate; pub mod shared; pub use add_liquidity::*; @@ -15,4 +14,3 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; -pub use set_funding_rate::*; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/set_funding_rate.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/set_funding_rate.rs deleted file mode 100644 index 454979e6..00000000 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/set_funding_rate.rs +++ /dev/null @@ -1,33 +0,0 @@ -use anchor_lang::prelude::*; - -use crate::constants::POOL_SEED; -use crate::instructions::shared::accrue_funding; -use crate::state::Pool; - -/// Retune the pool's funding rate, quoted per second of wall-clock time. -/// -/// Funding is accrued at the old rate first, so the seconds already elapsed are -/// charged at the rate that was in force for them rather than repriced by the -/// new one. -pub fn handle_set_funding_rate( - context: Context, - funding_rate_per_second: u64, -) -> Result<()> { - let pool = &mut context.accounts.pool; - accrue_funding(pool, Clock::get()?.unix_timestamp)?; - pool.funding_rate_per_second = funding_rate_per_second; - Ok(()) -} - -#[derive(Accounts)] -pub struct SetFundingRateAccountConstraints<'info> { - pub authority: Signer<'info>, - - #[account( - mut, - seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], - bump = pool.bump, - has_one = authority, - )] - pub pool: Box>, -} diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs index 23d968fa..a7487816 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs @@ -79,13 +79,4 @@ pub mod perpetual_futures { pub fn collect_fees(context: Context) -> Result<()> { instructions::handle_collect_fees(context) } - - /// The pool operator retunes the per-second funding rate, accruing at the - /// old rate first. - pub fn set_funding_rate( - context: Context, - funding_rate_per_second: u64, - ) -> Result<()> { - instructions::handle_set_funding_rate(context, funding_rate_per_second) - } } diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs index c58b81b2..62876964 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -14,6 +14,11 @@ use { solana_signer::Signer, }; +// Matches `MAX_FUNDING_RATE_PER_SECOND` in the program's constants: the +// steepest funding rate `initialize_pool` accepts. +const MAX_FUNDING_RATE_PER_SECOND: u64 = 277; +// Ten years, in seconds. +const TEN_YEARS: i64 = 315_360_000; // Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 // base units. const ONE_USDC: u64 = 1_000_000; @@ -499,29 +504,6 @@ impl Market { .map_err(|_| ()) } - fn set_funding_rate(&mut self, authority: &Keypair, rate: u64) -> Result<(), ()> { - let instruction = Instruction::new_with_bytes( - perpetual_futures::id(), - &perpetual_futures::instruction::SetFundingRate { - funding_rate_per_second: rate, - } - .data(), - perpetual_futures::accounts::SetFundingRateAccountConstraints { - authority: authority.pubkey(), - pool: self.pool, - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut self.svm, - vec![instruction], - &[authority], - &authority.pubkey(), - ) - .map(|_| ()) - .map_err(|_| ()) - } - /// Deposit a large amount of liquidity so the pool can pay trader profits, /// returning the provider and its collateral account. fn seed_liquidity(&mut self, amount: u64) -> (Keypair, Pubkey) { @@ -660,8 +642,9 @@ fn test_add_and_remove_liquidity_round_trip() { /// funding they paid in. #[test] fn test_inflating_liquidity_through_own_trades_does_not_pay() { - // A steep funding rate: 1_000 of notional pays 1_000 USDC over 1_000 seconds. - let mut market = Market::new(dollars(100), 1_000_000_000_000); + // The steepest rate a pool may have, held for ten years. The position is + // tiny because a pool holding 1_001 can back only 1_001 of notional. + let mut market = Market::new(dollars(100), MAX_FUNDING_RATE_PER_SECOND); let (attacker, attacker_collateral) = market.funded_trader(10_000 * ONE_USDC); market @@ -686,13 +669,13 @@ fn test_inflating_liquidity_through_own_trades_does_not_pay() { 0, ) .unwrap(); - market.pass_seconds(1_000); + market.pass_seconds(TEN_YEARS); market.set_price(dollars(100)); market .close_position(&attacker, attacker_collateral, Side::Long, 0) .unwrap(); let pumped_liquidity = market.pool_state().liquidity; - assert!(pumped_liquidity > 1_000 * ONE_USDC); + assert!(pumped_liquidity > 50 * 1_001); let attacker_spent = 10_000 * ONE_USDC - get_token_account_balance(&market.svm, &attacker_collateral).unwrap(); @@ -997,7 +980,7 @@ fn test_wide_oracle_confidence_rejected() { #[test] fn test_funding_charged_to_long() { // Funding on: longs are the only side, so they pay funding to the pool. - let mut market = Market::new(dollars(100), 5_000); + let mut market = Market::new(dollars(100), MAX_FUNDING_RATE_PER_SECOND); market.seed_liquidity(100_000 * ONE_USDC); let collateral = 1_000 * ONE_USDC; @@ -1059,42 +1042,12 @@ fn funding_paid_over(rate: u64, window: i64, between: impl Fn(&mut Market)) -> u (collateral - fee - fee) - payout } -/// Retuning the rate settles the seconds already elapsed at the old rate -/// rather than repricing them at the new one. -#[test] -fn test_set_funding_rate_settles_at_the_old_rate_first() { - let rate = 5_000; - let window = 2_000; - - // Same position and the same total elapsed seconds in both runs. The only - // difference is that the second doubles the rate halfway through, so it - // should pay 1x for the first window and 2x for the second: 1.5x overall. - let flat = funding_paid_over(rate, window, |_| {}); - let retuned = funding_paid_over(rate, window, |market| { - let admin = market.admin.insecure_clone(); - market.set_funding_rate(&admin, rate * 2).unwrap(); - }); - assert!( - flat > 0, - "the flat run must pay some funding to compare against" - ); - - // Half the elapsed seconds at 1x and half at 2x is 1.5x the flat run. Had - // the handler skipped its accrual, the new rate would have applied to every - // second and this would be 2x. - assert_eq!( - retuned * 2, - flat * 3, - "retuning halfway should cost 1.5x the flat run: flat {flat}, retuned {retuned}" - ); -} - /// Funding is quoted per second of wall-clock time, so slots passing without /// the clock moving charge nothing. A million extra slots halfway through the /// window, as a much shorter slot would produce, leave the funding unchanged. #[test] fn test_funding_follows_seconds_not_slots() { - let rate = 5_000; + let rate = MAX_FUNDING_RATE_PER_SECOND; let window = 2_000; let flat = funding_paid_over(rate, window, |_| {}); let with_extra_slots = funding_paid_over(rate, window, |market| { @@ -1106,12 +1059,81 @@ fn test_funding_follows_seconds_not_slots() { } #[test] -fn test_only_authority_can_set_funding_rate() { - let mut market = Market::new(dollars(100), 5_000); - let (impostor, _) = market.funded_trader(ONE_USDC); +fn test_initialize_pool_rejects_funding_rate_above_the_maximum() { + // The rate is fixed at creation, so this is the only place it is checked. + let parameters = |funding_rate_per_second| PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_second, + open_fee_bps: 10, + close_fee_bps: 10, + max_leverage: 10, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, + }; + assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND + 1)).is_err()); + assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND)).is_ok()); +} + +/// The pool operator trading against their own pool. The lighter side of open +/// interest is paid funding out of `liquidity`, so an operator who could raise +/// the rate at will could open a small position on the lighter side, raise the +/// rate, and close it to take the liquidity providers' deposits. The rate is +/// fixed when the pool is created and capped, so a wallet the operator +/// controls earns exactly what any trader on that side would: at most the +/// maximum rate, here just under 0.1% of the position's size over an hour. +#[test] +fn test_operator_on_the_lighter_side_earns_only_the_fixed_rate() { + let mut market = Market::new(dollars(100), MAX_FUNDING_RATE_PER_SECOND); + market.seed_liquidity(100_000 * ONE_USDC); + + // Longs are the heavier side, so they pay and shorts are paid. + let (trader, trader_collateral) = market.funded_trader(2_000 * ONE_USDC); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + 2_000 * ONE_USDC, + 10_000 * ONE_USDC, + 0, + ) + .unwrap(); + + let collateral = 200 * ONE_USDC; + let size = 1_000 * ONE_USDC; + let (operator_wallet, operator_collateral) = market.funded_trader(collateral); + market + .open_position( + &operator_wallet, + operator_collateral, + Side::Short, + collateral, + size, + 0, + ) + .unwrap(); + let liquidity_before = market.pool_state().liquidity; + + let one_hour = 3_600; + market.pass_seconds(one_hour); + market.set_price(dollars(100)); + market + .close_position(&operator_wallet, operator_collateral, Side::Short, 0) + .unwrap(); + + let fees = 2 * (size / 1_000); // open and close, 0.1% of notional each + let payout = get_token_account_balance(&market.svm, &operator_collateral).unwrap(); + let funding_received = payout - (collateral - fees); + let expected = size * MAX_FUNDING_RATE_PER_SECOND * one_hour as u64 / 1_000_000_000; + assert_eq!(funding_received, expected); assert!( - market.set_funding_rate(&impostor, 1).is_err(), - "a non-authority must not be able to retune the funding rate" + funding_received * 1_000 < size, + "under 0.1% of size in an hour" + ); + assert_eq!( + market.pool_state().liquidity, + liquidity_before - funding_received ); } diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index 109eb9cc..d878f5d8 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 2026-09-30 + +Remove `set_funding_rate`. The pool's authority could change the funding rate at +any time, with no upper bound. The lighter side of open interest is paid funding +out of `liquidity`, so the authority could hold a small position on that side +from any wallet, raise the rate, and close it to take the liquidity providers' +deposits. The rate is now fixed by `initialize_pool`, which refuses a rate above +`MAX_FUNDING_RATE_PER_SECOND` (277, just under 0.1% of a position's size per +hour) with `InvalidParameter`. + +Tested by `test_initialize_pool_rejects_funding_rate_above_the_maximum` and +`test_operator_on_the_lighter_side_earns_only_the_fixed_rate`. +`test_set_funding_rate_settles_at_the_old_rate_first` and +`test_only_authority_can_set_funding_rate` are removed with the handler. + ## 2026-09-23 The mock oracle program is now `mock-price-feed` (library and program diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index f5559d6f..f048883b 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -48,7 +48,7 @@ So a winning trader can always be paid, the pool **reserves** liquidity to back [Funding](https://www.investopedia.com/terms/f/futurescontract.asp) anchors the pool's risk: the heavier side of [open interest](https://www.investopedia.com/terms/o/openinterest.asp) pays the pool over time. A cumulative funding index rises while longs are the larger side and falls while shorts are, advancing by `funding_rate_per_second` for each second on the Clock's `unix_timestamp`; a position records the index at open and settles the change when it closes. In a pool-based perp this is the equivalent of the borrow fee Jupiter Perpetuals charges. -Funding runs on the wall clock rather than the slot count, so what a position costs per hour is set by the rate alone and does not change when the cluster's slot time does. The timestamp is written by each block's leader, but the runtime bounds how far one block can move it, so the elapsed time behind a position's funding is out by a second or two at most; a timestamp at or before the stored `last_funding_timestamp` accrues nothing. `set_funding_rate(funding_rate_per_second)` lets the pool operator change the rate; it advances the index at the old rate first, so seconds already elapsed are charged at the rate that was in force for them. +Funding runs on the wall clock rather than the slot count, so what a position costs per hour is set by the rate alone and does not change when the cluster's slot time does. The timestamp is written by each block's leader, but the runtime bounds how far one block can move it, so the elapsed time behind a position's funding is out by a second or two at most; a timestamp at or before the stored `last_funding_timestamp` accrues nothing. The rate is set once, in `initialize_pool`, and cannot be changed afterwards; `initialize_pool` refuses a rate above `MAX_FUNDING_RATE_PER_SECOND` (277, just under 0.1% of a position's size per hour). The lighter side is paid funding out of `liquidity`, so an operator who could raise the rate at will could hold a small position on that side, raise the rate and close it to take the liquidity providers' deposits. `test_operator_on_the_lighter_side_earns_only_the_fixed_rate` runs that position and checks it earns only the fixed rate. ### Maintenance margin and liquidation diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs index ee3133dc..07bedc4f 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs @@ -38,6 +38,14 @@ pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; /// liquidatable on the smallest price move. pub const MAX_LEVERAGE_CEILING: u16 = 100; +/// Upper bound on the per-pool `funding_rate_per_second` parameter, in +/// `FUNDING_PRECISION` units: 277 billionths of a position's size per second, +/// just under 0.1% of its size per hour. The rate is fixed when the pool is +/// created, so everyone who opens a position or deposits liquidity has seen it, +/// and no position can be charged or paid funding faster than this. +#[constant] +pub const MAX_FUNDING_RATE_PER_SECOND: u64 = 277; + #[constant] pub const POOL_SEED: &[u8] = b"pool"; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs index 7bae54af..df3660d3 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -7,12 +7,14 @@ use anchor_spl::{ }; use crate::constants::{ - BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_LEVERAGE_CEILING, POOL_SEED, VAULT_SEED, + BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING, + POOL_SEED, VAULT_SEED, }; use crate::errors::PerpError; use crate::state::Pool; -/// Trading parameters set once at pool creation. Bundled into one struct so the +/// Trading parameters set once at pool creation. None of them can be changed +/// afterwards. Bundled into one struct so the /// instruction signature stays readable. #[derive(Clone, IdlType, wincode::SchemaRead, wincode::SchemaWrite)] pub struct PoolParameters { @@ -42,6 +44,12 @@ pub fn handle_initialize_pool( parameters.max_leverage >= 1 && parameters.max_leverage <= MAX_LEVERAGE_CEILING, PerpError::InvalidParameter ); + // The rate never changes after this, so bounding it here bounds it for the + // life of the pool. + require!( + parameters.funding_rate_per_second <= MAX_FUNDING_RATE_PER_SECOND, + PerpError::InvalidParameter + ); require!( parameters.open_fee_bps < denominator, PerpError::InvalidParameter diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs index 9a7210c2..88337e1d 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs @@ -5,7 +5,6 @@ pub mod initialize_pool; pub mod liquidate_position; pub mod open_position; pub mod remove_liquidity; -pub mod set_funding_rate; pub mod shared; pub use add_liquidity::*; @@ -15,4 +14,3 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; -pub use set_funding_rate::*; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/set_funding_rate.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/set_funding_rate.rs deleted file mode 100644 index 2f5cbdca..00000000 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/set_funding_rate.rs +++ /dev/null @@ -1,33 +0,0 @@ -use anchor_lang::prelude::*; - -use crate::constants::POOL_SEED; -use crate::instructions::shared::accrue_funding; -use crate::state::Pool; - -/// Retune the pool's funding rate, quoted per second of wall-clock time. -/// -/// Funding is accrued at the old rate first, so the seconds already elapsed are -/// charged at the rate that was in force for them rather than repriced by the -/// new one. -pub fn handle_set_funding_rate( - context: &mut Context, - funding_rate_per_second: u64, -) -> Result<()> { - let pool = &mut context.accounts.pool; - accrue_funding(pool, Clock::get()?.unix_timestamp)?; - pool.funding_rate_per_second = funding_rate_per_second; - Ok(()) -} - -#[derive(Accounts)] -pub struct SetFundingRateAccountConstraints { - #[account(address = pool.authority)] - pub authority: Signer, - - #[account( - mut, - seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], - bump = pool.bump, - )] - pub pool: Box>, -} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs index dbfe8d7a..6329dc85 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs @@ -82,13 +82,4 @@ pub mod perpetual_futures { pub fn collect_fees(context: &mut Context) -> Result<()> { instructions::handle_collect_fees(context) } - - /// The pool operator retunes the per-second funding rate, accruing at the - /// old rate first. - pub fn set_funding_rate( - context: &mut Context, - funding_rate_per_second: u64, - ) -> Result<()> { - instructions::handle_set_funding_rate(context, funding_rate_per_second) - } } diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs index 7bbb772f..9cfc0584 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -12,6 +12,11 @@ use { }, }; +// Matches `MAX_FUNDING_RATE_PER_SECOND` in the program's constants: the +// steepest funding rate `initialize_pool` accepts. +const MAX_FUNDING_RATE_PER_SECOND: u64 = 277; +// Ten years, in seconds. +const TEN_YEARS: i64 = 315_360_000; // Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 // base units. const ONE_USDC: u64 = 1_000_000; @@ -497,29 +502,6 @@ impl Market { .map_err(|_| ()) } - fn set_funding_rate(&mut self, authority: &Keypair, rate: u64) -> Result<(), ()> { - let instruction = Instruction::new_with_bytes( - perpetual_futures::id(), - &perpetual_futures::instruction::SetFundingRate { - funding_rate_per_second: rate, - } - .data(), - perpetual_futures::accounts::SetFundingRateAccountConstraints { - authority: authority.pubkey(), - pool: self.pool, - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut self.svm, - vec![instruction], - &[authority], - &authority.pubkey(), - ) - .map(|_| ()) - .map_err(|_| ()) - } - /// Deposit a large amount of liquidity so the pool can pay trader profits, /// returning the provider and its collateral account. fn seed_liquidity(&mut self, amount: u64) -> (Keypair, Address) { @@ -658,8 +640,9 @@ fn test_add_and_remove_liquidity_round_trip() { /// funding they paid in. #[test] fn test_inflating_liquidity_through_own_trades_does_not_pay() { - // A steep funding rate: 1_000 of notional pays 1_000 USDC over 1_000 seconds. - let mut market = Market::new(dollars(100), 1_000_000_000_000); + // The steepest rate a pool may have, held for ten years. The position is + // tiny because a pool holding 1_001 can back only 1_001 of notional. + let mut market = Market::new(dollars(100), MAX_FUNDING_RATE_PER_SECOND); let (attacker, attacker_collateral) = market.funded_trader(10_000 * ONE_USDC); market @@ -684,13 +667,13 @@ fn test_inflating_liquidity_through_own_trades_does_not_pay() { 0, ) .unwrap(); - market.pass_seconds(1_000); + market.pass_seconds(TEN_YEARS); market.set_price(dollars(100)); market .close_position(&attacker, attacker_collateral, Side::Long, 0) .unwrap(); let pumped_liquidity = market.pool_state().liquidity; - assert!(pumped_liquidity > 1_000 * ONE_USDC); + assert!(pumped_liquidity > 50 * 1_001); let attacker_spent = 10_000 * ONE_USDC - get_token_account_balance(&market.svm, &attacker_collateral).unwrap(); @@ -992,7 +975,7 @@ fn test_wide_oracle_confidence_rejected() { #[test] fn test_funding_charged_to_long() { // Funding on: longs are the only side, so they pay funding to the pool. - let mut market = Market::new(dollars(100), 5_000); + let mut market = Market::new(dollars(100), MAX_FUNDING_RATE_PER_SECOND); market.seed_liquidity(100_000 * ONE_USDC); let collateral = 1_000 * ONE_USDC; @@ -1054,42 +1037,12 @@ fn funding_paid_over(rate: u64, window: i64, between: impl Fn(&mut Market)) -> u (collateral - fee - fee) - payout } -/// Retuning the rate settles the seconds already elapsed at the old rate -/// rather than repricing them at the new one. -#[test] -fn test_set_funding_rate_settles_at_the_old_rate_first() { - let rate = 5_000; - let window = 2_000; - - // Same position and the same total elapsed seconds in both runs. The only - // difference is that the second doubles the rate halfway through, so it - // should pay 1x for the first window and 2x for the second: 1.5x overall. - let flat = funding_paid_over(rate, window, |_| {}); - let retuned = funding_paid_over(rate, window, |market| { - let admin = market.admin.insecure_clone(); - market.set_funding_rate(&admin, rate * 2).unwrap(); - }); - assert!( - flat > 0, - "the flat run must pay some funding to compare against" - ); - - // Half the elapsed seconds at 1x and half at 2x is 1.5x the flat run. Had - // the handler skipped its accrual, the new rate would have applied to every - // second and this would be 2x. - assert_eq!( - retuned * 2, - flat * 3, - "retuning halfway should cost 1.5x the flat run: flat {flat}, retuned {retuned}" - ); -} - /// Funding is quoted per second of wall-clock time, so slots passing without /// the clock moving charge nothing. A million extra slots halfway through the /// window, as a much shorter slot would produce, leave the funding unchanged. #[test] fn test_funding_follows_seconds_not_slots() { - let rate = 5_000; + let rate = MAX_FUNDING_RATE_PER_SECOND; let window = 2_000; let flat = funding_paid_over(rate, window, |_| {}); let with_extra_slots = funding_paid_over(rate, window, |market| { @@ -1101,12 +1054,81 @@ fn test_funding_follows_seconds_not_slots() { } #[test] -fn test_only_authority_can_set_funding_rate() { - let mut market = Market::new(dollars(100), 5_000); - let (impostor, _) = market.funded_trader(ONE_USDC); +fn test_initialize_pool_rejects_funding_rate_above_the_maximum() { + // The rate is fixed at creation, so this is the only place it is checked. + let parameters = |funding_rate_per_second| PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_second, + open_fee_bps: 10, + close_fee_bps: 10, + max_leverage: 10, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, + }; + assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND + 1)).is_err()); + assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND)).is_ok()); +} + +/// The pool operator trading against their own pool. The lighter side of open +/// interest is paid funding out of `liquidity`, so an operator who could raise +/// the rate at will could open a small position on the lighter side, raise the +/// rate, and close it to take the liquidity providers' deposits. The rate is +/// fixed when the pool is created and capped, so a wallet the operator +/// controls earns exactly what any trader on that side would: at most the +/// maximum rate, here just under 0.1% of the position's size over an hour. +#[test] +fn test_operator_on_the_lighter_side_earns_only_the_fixed_rate() { + let mut market = Market::new(dollars(100), MAX_FUNDING_RATE_PER_SECOND); + market.seed_liquidity(100_000 * ONE_USDC); + + // Longs are the heavier side, so they pay and shorts are paid. + let (trader, trader_collateral) = market.funded_trader(2_000 * ONE_USDC); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + 2_000 * ONE_USDC, + 10_000 * ONE_USDC, + 0, + ) + .unwrap(); + + let collateral = 200 * ONE_USDC; + let size = 1_000 * ONE_USDC; + let (operator_wallet, operator_collateral) = market.funded_trader(collateral); + market + .open_position( + &operator_wallet, + operator_collateral, + Side::Short, + collateral, + size, + 0, + ) + .unwrap(); + let liquidity_before = market.pool_state().liquidity; + + let one_hour = 3_600; + market.pass_seconds(one_hour); + market.set_price(dollars(100)); + market + .close_position(&operator_wallet, operator_collateral, Side::Short, 0) + .unwrap(); + + let fees = 2 * (size / 1_000); // open and close, 0.1% of notional each + let payout = get_token_account_balance(&market.svm, &operator_collateral).unwrap(); + let funding_received = payout - (collateral - fees); + let expected = size * MAX_FUNDING_RATE_PER_SECOND * one_hour as u64 / 1_000_000_000; + assert_eq!(funding_received, expected); assert!( - market.set_funding_rate(&impostor, 1).is_err(), - "a non-authority must not be able to retune the funding rate" + funding_received * 1_000 < size, + "under 0.1% of size in an hour" + ); + assert_eq!( + market.pool_state().liquidity, + liquidity_before - funding_received ); } diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index a9b23791..47453e0d 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 2026-09-30 + +Remove `set_funding_rate` (discriminator 7). The pool's authority could change +the funding rate at any time, with no upper bound. The lighter side of open +interest is paid funding out of `liquidity`, so the authority could hold a small +position on that side from any wallet, raise the rate, and close it to take the +liquidity providers' deposits. The rate is now fixed by `initialize_pool`, which +refuses a rate above `MAX_FUNDING_RATE_PER_SECOND` (277, just under 0.1% of a +position's size per hour) with `INVALID_PARAMETER`. + +Tested by `initialize_pool_rejects_funding_rate_above_the_maximum` and +`operator_on_the_lighter_side_earns_only_the_fixed_rate`. +`set_funding_rate_settles_at_the_old_rate_first` and +`only_the_authority_can_set_the_funding_rate` are removed with the handler. + ## 2026-09-23 Documentation only: a production feed is now described as a Pyth diff --git a/finance/perpetual-futures/quasar/README.md b/finance/perpetual-futures/quasar/README.md index 53493c13..4d533d4d 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -31,9 +31,9 @@ math. This page only covers what differs in the Quasar version. Tests run in-process with [`quasar-svm`](https://github.com/blueshift-gg/quasar-svm). They build the program, set up a collateral mint, oracle feed, and funded wallets, then exercise pool initialization, liquidity add/remove, opening and -closing a long in profit, leverage rejection, funding-rate retuning (including -that it settles elapsed seconds at the old rate), funding that follows seconds -rather than slots, liquidation, and fee collection. +closing a long in profit, leverage rejection, the funding-rate maximum, an +operator's wallet on the lighter side earning only the fixed rate, funding that +follows seconds rather than slots, liquidation, and fee collection. ```bash cargo build-sbf diff --git a/finance/perpetual-futures/quasar/src/constants.rs b/finance/perpetual-futures/quasar/src/constants.rs index f3f1ec62..c0ff398d 100644 --- a/finance/perpetual-futures/quasar/src/constants.rs +++ b/finance/perpetual-futures/quasar/src/constants.rs @@ -24,6 +24,13 @@ pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; /// Upper bound on a pool's configurable `max_leverage`. pub const MAX_LEVERAGE_CEILING: u16 = 100; +/// Upper bound on a pool's `funding_rate_per_second`, in `FUNDING_PRECISION` +/// units: 277 billionths of a position's size per second, just under 0.1% of +/// its size per hour. The rate is fixed when the pool is created, so everyone +/// who opens a position or deposits liquidity has seen it, and no position can +/// be charged or paid funding faster than this. +pub const MAX_FUNDING_RATE_PER_SECOND: u64 = 277; + /// Long / short discriminants, used both as the position-PDA seed byte and the /// `side` instruction argument. pub const SIDE_LONG: u8 = 0; diff --git a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs index 0266d545..6e0f5735 100644 --- a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs @@ -1,6 +1,6 @@ use { crate::{ - constants::{BASIS_POINTS_DENOMINATOR, MAX_LEVERAGE_CEILING}, + constants::{BASIS_POINTS_DENOMINATOR, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING}, instructions::shared::{err, error}, state::{Pool, PoolInner}, LpMintPda, VaultPda, @@ -62,6 +62,11 @@ pub fn handle_initialize_pool( bumps: &InitializePoolBumps, ) -> Result<(), ProgramError> { let denominator = BASIS_POINTS_DENOMINATOR as u16; + // The rate never changes after this, so bounding it here bounds it for the + // life of the pool. + if funding_rate_per_second > MAX_FUNDING_RATE_PER_SECOND { + return Err(err(error::INVALID_PARAMETER)); + } if !(1..=MAX_LEVERAGE_CEILING).contains(&max_leverage) { return Err(err(error::INVALID_PARAMETER)); } diff --git a/finance/perpetual-futures/quasar/src/instructions/mod.rs b/finance/perpetual-futures/quasar/src/instructions/mod.rs index 6ae5145f..00453e62 100644 --- a/finance/perpetual-futures/quasar/src/instructions/mod.rs +++ b/finance/perpetual-futures/quasar/src/instructions/mod.rs @@ -5,7 +5,6 @@ mod initialize_pool; mod liquidate_position; mod open_position; mod remove_liquidity; -mod set_funding_rate; pub mod shared; pub use add_liquidity::*; @@ -15,4 +14,3 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; -pub use set_funding_rate::*; diff --git a/finance/perpetual-futures/quasar/src/instructions/set_funding_rate.rs b/finance/perpetual-futures/quasar/src/instructions/set_funding_rate.rs deleted file mode 100644 index 09d72c95..00000000 --- a/finance/perpetual-futures/quasar/src/instructions/set_funding_rate.rs +++ /dev/null @@ -1,35 +0,0 @@ -use { - crate::{instructions::shared::accrue_funding, state::Pool}, - quasar_lang::{prelude::*, sysvars::Sysvar}, -}; - -#[derive(Accounts)] -pub struct SetFundingRate { - pub authority: Signer, - #[account( - mut, - has_one(authority), - address = Pool::seeds(collateral_mint.address(), oracle_feed.address()), - )] - pub pool: Account, - /// CHECK: bound to the pool via its seeds. - pub collateral_mint: UncheckedAccount, - /// CHECK: bound to the pool via its seeds. - pub oracle_feed: UncheckedAccount, -} - -/// Retune the pool's funding rate, quoted per second of wall-clock time. -/// -/// Funding is accrued at the old rate first, so the seconds already elapsed are -/// charged at the rate that was in force for them rather than repriced by the -/// new one. -#[inline(always)] -pub fn handle_set_funding_rate( - accounts: &mut SetFundingRate, - funding_rate_per_second: u64, -) -> Result<(), ProgramError> { - let pool = &mut accounts.pool; - accrue_funding(pool, i64::from(Clock::get()?.unix_timestamp))?; - pool.funding_rate_per_second.set(funding_rate_per_second); - Ok(()) -} diff --git a/finance/perpetual-futures/quasar/src/lib.rs b/finance/perpetual-futures/quasar/src/lib.rs index 978cb9b6..b21d656c 100644 --- a/finance/perpetual-futures/quasar/src/lib.rs +++ b/finance/perpetual-futures/quasar/src/lib.rs @@ -122,12 +122,4 @@ mod quasar_perpetual_futures { pub fn collect_fees(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_collect_fees(&mut ctx.accounts, &ctx.bumps) } - - #[instruction(discriminator = 7)] - pub fn set_funding_rate( - ctx: Ctx, - funding_rate_per_second: u64, - ) -> Result<(), ProgramError> { - instructions::handle_set_funding_rate(&mut ctx.accounts, funding_rate_per_second) - } } diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index 141d6bfa..e51ba944 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -4,10 +4,11 @@ use { crate::{ + constants::{MAX_FUNDING_RATE_PER_SECOND, SIDE_LONG, SIDE_SHORT}, cpi::{ AddLiquidityInstruction, ClosePositionInstruction, CollectFeesInstruction, InitializePoolInstruction, LiquidatePositionInstruction, OpenPositionInstruction, - RemoveLiquidityInstruction, SetFundingRateInstruction, + RemoveLiquidityInstruction, }, state::{Pool, Position}, LpMintPda, VaultPda, @@ -39,6 +40,11 @@ const ADMIN_COLLATERAL: Pubkey = Pubkey::new_from_array([11; 32]); const VICTIM: Pubkey = Pubkey::new_from_array([12; 32]); const VICTIM_COLLATERAL: Pubkey = Pubkey::new_from_array([13; 32]); const VICTIM_LP: Pubkey = Pubkey::new_from_array([14; 32]); +const OPERATOR_WALLET: Pubkey = Pubkey::new_from_array([15; 32]); +const OPERATOR_COLLATERAL: Pubkey = Pubkey::new_from_array([16; 32]); + +// Ten years, in seconds. +const TEN_YEARS: i64 = 315_360_000; fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) @@ -276,8 +282,9 @@ fn remove_liquidity_round_trip_returns_the_deposit_less_the_minimum(test: &mut T /// in is spread across shares nobody can redeem. #[quasar_test] fn inflating_liquidity_through_own_trades_does_not_pay(test: &mut Test) { - // A steep funding rate: 1_000 of notional pays 1_000 USDC over 1_000 seconds. - let env = setup_with_funding(test, 1_000_000_000_000); + // The steepest rate a pool may have, held for ten years. The position is + // tiny because a pool holding 1_001 can back only 1_001 of notional. + let env = setup_with_funding(test, MAX_FUNDING_RATE_PER_SECOND); fund(test, PROVIDER, PROVIDER_COLLATERAL, 1_001); add_liquidity(test, &env, 1_001) .succeeds() @@ -286,14 +293,15 @@ fn inflating_liquidity_through_own_trades_does_not_pay(test: &mut Test) { // The attacker's trading key: a 1_000 long, heavily collateralized. fund(test, TRADER, TRADER_COLLATERAL, 2_000 * ONE_USDC); open_position(test, &env, 0, 2_000 * ONE_USDC, 1_000).succeeds(); - set_clock_at(test, 1_000 * SLOTS_PER_SECOND, 1_000); - set_feed_at_slot(test, dollars(100), 1_000 * SLOTS_PER_SECOND, 0); + let ten_years_slots = TEN_YEARS as u64 * SLOTS_PER_SECOND; + set_clock_at(test, ten_years_slots, TEN_YEARS); + set_feed_at_slot(test, dollars(100), ten_years_slots, 0); close_position(test, &env).succeeds(); // Spent: the 1_001 deposit, the funding, and a 1-unit fee each way. All // but the two fees is now `liquidity`. let attacker_spent = 1_001 + 2_000 * ONE_USDC - test.tokens(TRADER_COLLATERAL); let pumped_liquidity = attacker_spent - 2; - assert!(pumped_liquidity > 1_000 * ONE_USDC); + assert!(pumped_liquidity > 50 * 1_001); // Just under twice the pumped liquidity: dividing by the bare supply of 1 // would mint a single share, and the attacker's share would redeem half. @@ -476,56 +484,6 @@ fn collect_fees_sweeps_the_open_fee_to_the_admin(test: &mut Test) { /// spanning position pays one window at the old rate plus one at the new (3 /// window-rates), and the position opened afterwards pays one window wholly at /// the new rate (2 window-rates). -#[quasar_test] -fn set_funding_rate_settles_at_the_old_rate_first(test: &mut Test) { - let rate = 5_000; - let window = 2_000; - let size = 5_000 * ONE_USDC; - let collateral = 1_000 * ONE_USDC; - let fees = 2 * (size / 1_000); // open and close, 0.1% of notional each - let window_slots = window as u64 * SLOTS_PER_SECOND; - - let env = setup_with_funding(test, rate); - fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); - add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); - fund(test, TRADER, TRADER_COLLATERAL, 10_000 * ONE_USDC); - - // A position held across the retune: one window at `rate`, one at `rate * 2`. - let before_spanning = test.tokens(TRADER_COLLATERAL); - open_position(test, &env, 0, collateral, size).succeeds(); - set_clock_at(test, window_slots, window); - test.send(SetFundingRateInstruction { - authority: ADMIN, - collateral_mint: COLLATERAL_MINT, - oracle_feed: FEED, - funding_rate_per_second: rate * 2, - }) - .succeeds(); - set_clock_at(test, 2 * window_slots, 2 * window); - set_feed_at_slot(test, dollars(100), 2 * window_slots, 0); - close_position(test, &env).succeeds(); - let spanning = (before_spanning - test.tokens(TRADER_COLLATERAL)) - fees; - - // A fresh position over one window, now wholly at the doubled rate. - let before_doubled = test.tokens(TRADER_COLLATERAL); - open_position(test, &env, 0, collateral, size).succeeds(); - set_clock_at(test, 3 * window_slots, 3 * window); - set_feed_at_slot(test, dollars(100), 3 * window_slots, 0); - close_position(test, &env).succeeds(); - let doubled = (before_doubled - test.tokens(TRADER_COLLATERAL)) - fees; - - assert!( - doubled > 0, - "the doubled-rate window must charge some funding" - ); - assert_eq!( - spanning * 2, - doubled * 3, - "a position spanning the retune should pay 1.5x one doubled window: \ - spanning {spanning}, doubled {doubled}" - ); -} - /// Funding is quoted per second of wall-clock time, so slots passing without /// the clock moving charge nothing. A million extra slots halfway through the /// window, as a much shorter slot would produce, leave the funding unchanged. @@ -534,7 +492,7 @@ fn set_funding_rate_settles_at_the_old_rate_first(test: &mut Test) { /// so they must pay the same funding; only the second sees the extra slots. #[quasar_test] fn funding_follows_seconds_not_slots(test: &mut Test) { - let rate = 5_000; + let rate = MAX_FUNDING_RATE_PER_SECOND; let window = 2_000; let size = 5_000 * ONE_USDC; let collateral = 1_000 * ONE_USDC; @@ -573,19 +531,72 @@ fn funding_follows_seconds_not_slots(test: &mut Test) { } #[quasar_test] -fn only_the_authority_can_set_the_funding_rate(test: &mut Test) { - let env = setup_with_funding(test, 5_000); - let _ = env; - fund(test, TRADER, TRADER_COLLATERAL, ONE_USDC); +fn initialize_pool_rejects_funding_rate_above_the_maximum(test: &mut Test) { + // The rate is fixed at creation, so this is the only place it is checked. + test.add(Wallet::new().at(ADMIN)); + test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); + set_feed(test, dollars(100), 0); + assert!( + init_pool_with_funding(test, 500, 10, MAX_FUNDING_RATE_PER_SECOND + 1).is_err(), + "a funding rate above the maximum must be rejected" + ); + init_pool_with_funding(test, 500, 10, MAX_FUNDING_RATE_PER_SECOND).succeeds(); +} + +/// The pool operator trading against their own pool. The lighter side of open +/// interest is paid funding out of `liquidity`, so an operator who could raise +/// the rate at will could open a small position on the lighter side, raise the +/// rate, and close it to take the liquidity providers' deposits. The rate is +/// fixed when the pool is created and capped, so a wallet the operator +/// controls earns exactly what any trader on that side would: at most the +/// maximum rate, here just under 0.1% of the position's size over an hour. +#[quasar_test] +fn operator_on_the_lighter_side_earns_only_the_fixed_rate(test: &mut Test) { + let env = setup_with_funding(test, MAX_FUNDING_RATE_PER_SECOND); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + + // Longs are the heavier side, so they pay and shorts are paid. + fund(test, TRADER, TRADER_COLLATERAL, 2_000 * ONE_USDC); + open_position(test, &env, SIDE_LONG, 2_000 * ONE_USDC, 10_000 * ONE_USDC).succeeds(); + + let collateral = 200 * ONE_USDC; + let size = 1_000 * ONE_USDC; + fund(test, OPERATOR_WALLET, OPERATOR_COLLATERAL, collateral); + test.send(OpenPositionInstruction { + owner: OPERATOR_WALLET, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + trader_collateral: OPERATOR_COLLATERAL, + side: SIDE_SHORT, + collateral_amount: collateral, + size, + acceptable_price: 0, + }) + .succeeds(); + + let one_hour = 3_600; + let one_hour_slots = one_hour as u64 * SLOTS_PER_SECOND; + set_clock_at(test, one_hour_slots, one_hour); + set_feed_at_slot(test, dollars(100), one_hour_slots, 0); + test.send(ClosePositionInstruction { + owner: OPERATOR_WALLET, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + trader_collateral: OPERATOR_COLLATERAL, + minimum_payout: 0, + }) + .succeeds(); + + let fees = 2 * (size / 1_000); // open and close, 0.1% of notional each + let funding_received = test.tokens(OPERATOR_COLLATERAL) - (collateral - fees); + let expected = size * MAX_FUNDING_RATE_PER_SECOND * one_hour as u64 / 1_000_000_000; + assert_eq!(funding_received, expected); assert!( - test.send(SetFundingRateInstruction { - authority: TRADER, - collateral_mint: COLLATERAL_MINT, - oracle_feed: FEED, - funding_rate_per_second: 1, - }) - .is_err(), - "a non-authority must not be able to retune the funding rate" + funding_received * 1_000 < size, + "under 0.1% of size in an hour" ); }