Skip to content

Commit efcfadf

Browse files
authored
Merge pull request #172 from quicknode/claude/perps-fixed-funding-rate
Perps: fix the funding rate at pool creation and cap it
2 parents 6d537db + 61f4d23 commit efcfadf

24 files changed

Lines changed: 351 additions & 340 deletions

File tree

‎finance/perpetual-futures/anchor-v1/CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## 2026-09-30
4+
5+
Remove `set_funding_rate`. The pool's authority could change the funding rate at
6+
any time, with no upper bound. The lighter side of open interest is paid funding
7+
out of `liquidity`, so the authority could hold a small position on that side
8+
from any wallet, raise the rate, and close it to take the liquidity providers'
9+
deposits. The rate is now fixed by `initialize_pool`, which refuses a rate above
10+
`MAX_FUNDING_RATE_PER_SECOND` (277, just under 0.1% of a position's size per
11+
hour) with `InvalidParameter`.
12+
13+
Tested by `test_initialize_pool_rejects_funding_rate_above_the_maximum` and
14+
`test_operator_on_the_lighter_side_earns_only_the_fixed_rate`.
15+
`test_set_funding_rate_settles_at_the_old_rate_first` and
16+
`test_only_authority_can_set_funding_rate` are removed with the handler.
17+
318
## 2026-09-23
419

520
The mock oracle program is now `mock-price-feed` (library and program

‎finance/perpetual-futures/anchor-v1/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ So a winning trader can always be paid, the pool **reserves** liquidity to back
4848

4949
[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.
5050

51-
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.
51+
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.
5252

5353
### Maintenance margin and liquidation
5454

‎finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150;
3838
/// liquidatable on the smallest price move.
3939
pub const MAX_LEVERAGE_CEILING: u16 = 100;
4040

41+
/// Upper bound on the per-pool `funding_rate_per_second` parameter, in
42+
/// `FUNDING_PRECISION` units: 277 billionths of a position's size per second,
43+
/// just under 0.1% of its size per hour. The rate is fixed when the pool is
44+
/// created, so everyone who opens a position or deposits liquidity has seen it,
45+
/// and no position can be charged or paid funding faster than this.
46+
#[constant]
47+
pub const MAX_FUNDING_RATE_PER_SECOND: u64 = 277;
48+
4149
#[constant]
4250
pub const POOL_SEED: &[u8] = b"pool";
4351

‎finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ use anchor_spl::{
55
};
66

77
use crate::constants::{
8-
BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_LEVERAGE_CEILING, POOL_SEED, VAULT_SEED,
8+
BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING,
9+
POOL_SEED, VAULT_SEED,
910
};
1011
use crate::errors::PerpError;
1112
use crate::state::Pool;
1213

13-
/// Trading parameters set once at pool creation. Bundled into one struct so the
14+
/// Trading parameters set once at pool creation. None of them can be changed
15+
/// afterwards. Bundled into one struct so the
1416
/// instruction signature stays readable.
1517
#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
1618
pub struct PoolParameters {
@@ -40,6 +42,12 @@ pub fn handle_initialize_pool(
4042
parameters.max_leverage >= 1 && parameters.max_leverage <= MAX_LEVERAGE_CEILING,
4143
PerpError::InvalidParameter
4244
);
45+
// The rate never changes after this, so bounding it here bounds it for the
46+
// life of the pool.
47+
require!(
48+
parameters.funding_rate_per_second <= MAX_FUNDING_RATE_PER_SECOND,
49+
PerpError::InvalidParameter
50+
);
4351
require!(
4452
parameters.open_fee_bps < denominator,
4553
PerpError::InvalidParameter

‎finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ pub mod initialize_pool;
55
pub mod liquidate_position;
66
pub mod open_position;
77
pub mod remove_liquidity;
8-
pub mod set_funding_rate;
98
pub mod shared;
109

1110
pub use add_liquidity::*;
@@ -15,4 +14,3 @@ pub use initialize_pool::*;
1514
pub use liquidate_position::*;
1615
pub use open_position::*;
1716
pub use remove_liquidity::*;
18-
pub use set_funding_rate::*;

‎finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/set_funding_rate.rs‎

Lines changed: 0 additions & 33 deletions
This file was deleted.

‎finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,4 @@ pub mod perpetual_futures {
7979
pub fn collect_fees(context: Context<CollectFeesAccountConstraints>) -> Result<()> {
8080
instructions::handle_collect_fees(context)
8181
}
82-
83-
/// The pool operator retunes the per-second funding rate, accruing at the
84-
/// old rate first.
85-
pub fn set_funding_rate(
86-
context: Context<SetFundingRateAccountConstraints>,
87-
funding_rate_per_second: u64,
88-
) -> Result<()> {
89-
instructions::handle_set_funding_rate(context, funding_rate_per_second)
90-
}
9182
}

‎finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs‎

Lines changed: 86 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ use {
1414
solana_signer::Signer,
1515
};
1616

17+
// Matches `MAX_FUNDING_RATE_PER_SECOND` in the program's constants: the
18+
// steepest funding rate `initialize_pool` accepts.
19+
const MAX_FUNDING_RATE_PER_SECOND: u64 = 277;
20+
// Ten years, in seconds.
21+
const TEN_YEARS: i64 = 315_360_000;
1722
// Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000
1823
// base units.
1924
const ONE_USDC: u64 = 1_000_000;
@@ -499,29 +504,6 @@ impl Market {
499504
.map_err(|_| ())
500505
}
501506

502-
fn set_funding_rate(&mut self, authority: &Keypair, rate: u64) -> Result<(), ()> {
503-
let instruction = Instruction::new_with_bytes(
504-
perpetual_futures::id(),
505-
&perpetual_futures::instruction::SetFundingRate {
506-
funding_rate_per_second: rate,
507-
}
508-
.data(),
509-
perpetual_futures::accounts::SetFundingRateAccountConstraints {
510-
authority: authority.pubkey(),
511-
pool: self.pool,
512-
}
513-
.to_account_metas(None),
514-
);
515-
send_transaction_from_instructions(
516-
&mut self.svm,
517-
vec![instruction],
518-
&[authority],
519-
&authority.pubkey(),
520-
)
521-
.map(|_| ())
522-
.map_err(|_| ())
523-
}
524-
525507
/// Deposit a large amount of liquidity so the pool can pay trader profits,
526508
/// returning the provider and its collateral account.
527509
fn seed_liquidity(&mut self, amount: u64) -> (Keypair, Pubkey) {
@@ -660,8 +642,9 @@ fn test_add_and_remove_liquidity_round_trip() {
660642
/// funding they paid in.
661643
#[test]
662644
fn test_inflating_liquidity_through_own_trades_does_not_pay() {
663-
// A steep funding rate: 1_000 of notional pays 1_000 USDC over 1_000 seconds.
664-
let mut market = Market::new(dollars(100), 1_000_000_000_000);
645+
// The steepest rate a pool may have, held for ten years. The position is
646+
// tiny because a pool holding 1_001 can back only 1_001 of notional.
647+
let mut market = Market::new(dollars(100), MAX_FUNDING_RATE_PER_SECOND);
665648

666649
let (attacker, attacker_collateral) = market.funded_trader(10_000 * ONE_USDC);
667650
market
@@ -686,13 +669,13 @@ fn test_inflating_liquidity_through_own_trades_does_not_pay() {
686669
0,
687670
)
688671
.unwrap();
689-
market.pass_seconds(1_000);
672+
market.pass_seconds(TEN_YEARS);
690673
market.set_price(dollars(100));
691674
market
692675
.close_position(&attacker, attacker_collateral, Side::Long, 0)
693676
.unwrap();
694677
let pumped_liquidity = market.pool_state().liquidity;
695-
assert!(pumped_liquidity > 1_000 * ONE_USDC);
678+
assert!(pumped_liquidity > 50 * 1_001);
696679
let attacker_spent =
697680
10_000 * ONE_USDC - get_token_account_balance(&market.svm, &attacker_collateral).unwrap();
698681

@@ -997,7 +980,7 @@ fn test_wide_oracle_confidence_rejected() {
997980
#[test]
998981
fn test_funding_charged_to_long() {
999982
// Funding on: longs are the only side, so they pay funding to the pool.
1000-
let mut market = Market::new(dollars(100), 5_000);
983+
let mut market = Market::new(dollars(100), MAX_FUNDING_RATE_PER_SECOND);
1001984
market.seed_liquidity(100_000 * ONE_USDC);
1002985

1003986
let collateral = 1_000 * ONE_USDC;
@@ -1059,42 +1042,12 @@ fn funding_paid_over(rate: u64, window: i64, between: impl Fn(&mut Market)) -> u
10591042
(collateral - fee - fee) - payout
10601043
}
10611044

1062-
/// Retuning the rate settles the seconds already elapsed at the old rate
1063-
/// rather than repricing them at the new one.
1064-
#[test]
1065-
fn test_set_funding_rate_settles_at_the_old_rate_first() {
1066-
let rate = 5_000;
1067-
let window = 2_000;
1068-
1069-
// Same position and the same total elapsed seconds in both runs. The only
1070-
// difference is that the second doubles the rate halfway through, so it
1071-
// should pay 1x for the first window and 2x for the second: 1.5x overall.
1072-
let flat = funding_paid_over(rate, window, |_| {});
1073-
let retuned = funding_paid_over(rate, window, |market| {
1074-
let admin = market.admin.insecure_clone();
1075-
market.set_funding_rate(&admin, rate * 2).unwrap();
1076-
});
1077-
assert!(
1078-
flat > 0,
1079-
"the flat run must pay some funding to compare against"
1080-
);
1081-
1082-
// Half the elapsed seconds at 1x and half at 2x is 1.5x the flat run. Had
1083-
// the handler skipped its accrual, the new rate would have applied to every
1084-
// second and this would be 2x.
1085-
assert_eq!(
1086-
retuned * 2,
1087-
flat * 3,
1088-
"retuning halfway should cost 1.5x the flat run: flat {flat}, retuned {retuned}"
1089-
);
1090-
}
1091-
10921045
/// Funding is quoted per second of wall-clock time, so slots passing without
10931046
/// the clock moving charge nothing. A million extra slots halfway through the
10941047
/// window, as a much shorter slot would produce, leave the funding unchanged.
10951048
#[test]
10961049
fn test_funding_follows_seconds_not_slots() {
1097-
let rate = 5_000;
1050+
let rate = MAX_FUNDING_RATE_PER_SECOND;
10981051
let window = 2_000;
10991052
let flat = funding_paid_over(rate, window, |_| {});
11001053
let with_extra_slots = funding_paid_over(rate, window, |market| {
@@ -1106,12 +1059,81 @@ fn test_funding_follows_seconds_not_slots() {
11061059
}
11071060

11081061
#[test]
1109-
fn test_only_authority_can_set_funding_rate() {
1110-
let mut market = Market::new(dollars(100), 5_000);
1111-
let (impostor, _) = market.funded_trader(ONE_USDC);
1062+
fn test_initialize_pool_rejects_funding_rate_above_the_maximum() {
1063+
// The rate is fixed at creation, so this is the only place it is checked.
1064+
let parameters = |funding_rate_per_second| PoolParameters {
1065+
oracle_scale: ORACLE_SCALE,
1066+
funding_rate_per_second,
1067+
open_fee_bps: 10,
1068+
close_fee_bps: 10,
1069+
max_leverage: 10,
1070+
maintenance_margin_bps: 500,
1071+
liquidation_fee_bps: 100,
1072+
max_confidence_bps: 100,
1073+
};
1074+
assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND + 1)).is_err());
1075+
assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND)).is_ok());
1076+
}
1077+
1078+
/// The pool operator trading against their own pool. The lighter side of open
1079+
/// interest is paid funding out of `liquidity`, so an operator who could raise
1080+
/// the rate at will could open a small position on the lighter side, raise the
1081+
/// rate, and close it to take the liquidity providers' deposits. The rate is
1082+
/// fixed when the pool is created and capped, so a wallet the operator
1083+
/// controls earns exactly what any trader on that side would: at most the
1084+
/// maximum rate, here just under 0.1% of the position's size over an hour.
1085+
#[test]
1086+
fn test_operator_on_the_lighter_side_earns_only_the_fixed_rate() {
1087+
let mut market = Market::new(dollars(100), MAX_FUNDING_RATE_PER_SECOND);
1088+
market.seed_liquidity(100_000 * ONE_USDC);
1089+
1090+
// Longs are the heavier side, so they pay and shorts are paid.
1091+
let (trader, trader_collateral) = market.funded_trader(2_000 * ONE_USDC);
1092+
market
1093+
.open_position(
1094+
&trader,
1095+
trader_collateral,
1096+
Side::Long,
1097+
2_000 * ONE_USDC,
1098+
10_000 * ONE_USDC,
1099+
0,
1100+
)
1101+
.unwrap();
1102+
1103+
let collateral = 200 * ONE_USDC;
1104+
let size = 1_000 * ONE_USDC;
1105+
let (operator_wallet, operator_collateral) = market.funded_trader(collateral);
1106+
market
1107+
.open_position(
1108+
&operator_wallet,
1109+
operator_collateral,
1110+
Side::Short,
1111+
collateral,
1112+
size,
1113+
0,
1114+
)
1115+
.unwrap();
1116+
let liquidity_before = market.pool_state().liquidity;
1117+
1118+
let one_hour = 3_600;
1119+
market.pass_seconds(one_hour);
1120+
market.set_price(dollars(100));
1121+
market
1122+
.close_position(&operator_wallet, operator_collateral, Side::Short, 0)
1123+
.unwrap();
1124+
1125+
let fees = 2 * (size / 1_000); // open and close, 0.1% of notional each
1126+
let payout = get_token_account_balance(&market.svm, &operator_collateral).unwrap();
1127+
let funding_received = payout - (collateral - fees);
1128+
let expected = size * MAX_FUNDING_RATE_PER_SECOND * one_hour as u64 / 1_000_000_000;
1129+
assert_eq!(funding_received, expected);
11121130
assert!(
1113-
market.set_funding_rate(&impostor, 1).is_err(),
1114-
"a non-authority must not be able to retune the funding rate"
1131+
funding_received * 1_000 < size,
1132+
"under 0.1% of size in an hour"
1133+
);
1134+
assert_eq!(
1135+
market.pool_state().liquidity,
1136+
liquidity_before - funding_received
11151137
);
11161138
}
11171139

‎finance/perpetual-futures/anchor/CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## 2026-09-30
4+
5+
Remove `set_funding_rate`. The pool's authority could change the funding rate at
6+
any time, with no upper bound. The lighter side of open interest is paid funding
7+
out of `liquidity`, so the authority could hold a small position on that side
8+
from any wallet, raise the rate, and close it to take the liquidity providers'
9+
deposits. The rate is now fixed by `initialize_pool`, which refuses a rate above
10+
`MAX_FUNDING_RATE_PER_SECOND` (277, just under 0.1% of a position's size per
11+
hour) with `InvalidParameter`.
12+
13+
Tested by `test_initialize_pool_rejects_funding_rate_above_the_maximum` and
14+
`test_operator_on_the_lighter_side_earns_only_the_fixed_rate`.
15+
`test_set_funding_rate_settles_at_the_old_rate_first` and
16+
`test_only_authority_can_set_funding_rate` are removed with the handler.
17+
318
## 2026-09-23
419

520
The mock oracle program is now `mock-price-feed` (library and program

‎finance/perpetual-futures/anchor/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ So a winning trader can always be paid, the pool **reserves** liquidity to back
4848

4949
[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.
5050

51-
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.
51+
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.
5252

5353
### Maintenance margin and liquidation
5454

0 commit comments

Comments
 (0)