Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions finance/perpetual-futures/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion finance/perpetual-futures/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand All @@ -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::*;

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,4 @@ pub mod perpetual_futures {
pub fn collect_fees(context: Context<CollectFeesAccountConstraints>) -> 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<SetFundingRateAccountConstraints>,
funding_rate_per_second: u64,
) -> Result<()> {
instructions::handle_set_funding_rate(context, funding_rate_per_second)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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();

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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| {
Expand All @@ -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
);
}

Expand Down
15 changes: 15 additions & 0 deletions finance/perpetual-futures/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion finance/perpetual-futures/anchor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Loading
Loading