Skip to content

Commit b42bb6f

Browse files
feat(multi-collateral): impl apply interests
1 parent 02b8bea commit b42bb6f

File tree

10 files changed

+187
-10
lines changed

10 files changed

+187
-10
lines changed

workspace/apps/perpetuals/contracts/src/core/components/positions/interface.cairo

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ pub trait IPositions<TContractState> {
4848
new_public_key: PublicKey,
4949
expiration: Timestamp,
5050
);
51-
5251
fn enable_owner_protection(
5352
ref self: TContractState,
5453
operator_nonce: u64,

workspace/apps/perpetuals/contracts/src/core/components/positions/positions.cairo

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ pub mod Positions {
4848
IterableMapIntoIterImpl, IterableMapReadAccessImpl, IterableMapWriteAccessImpl,
4949
};
5050
use starkware_utils::storage::utils::AddToStorage;
51-
use starkware_utils::time::time::{Timestamp, validate_expiration};
51+
use starkware_utils::time::time::{Time, Timestamp, validate_expiration};
5252
use crate::core::components::snip::SNIP12MetadataImpl;
5353
use crate::core::errors::{
5454
INVALID_AMOUNT_SIGN, INVALID_BASE_CHANGE, INVALID_SAME_POSITIONS, INVALID_ZERO_AMOUNT,
@@ -185,9 +185,11 @@ pub mod Positions {
185185
let mut position = self.positions.entry(position_id);
186186
assert(position.version.read().is_zero(), POSITION_ALREADY_EXISTS);
187187
assert(owner_public_key.is_non_zero(), INVALID_ZERO_PUBLIC_KEY);
188+
let current_time = Time::now();
188189
position.version.write(POSITION_VERSION);
189190
position.owner_public_key.write(owner_public_key);
190191
position.owner_protection_enabled.write(owner_protection_enabled);
192+
position.last_interest_applied_time.write(current_time);
191193
if owner_account.is_non_zero() {
192194
position.owner_account.write(Option::Some(owner_account));
193195
}

workspace/apps/perpetuals/contracts/src/core/core.cairo

Lines changed: 135 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#[starknet::contract]
22
pub mod Core {
33
use core::dict::{Felt252Dict, Felt252DictTrait};
4-
use core::num::traits::Zero;
4+
use core::num::traits::{Pow, Zero};
55
use core::panic_with_felt252;
66
use openzeppelin::access::accesscontrol::AccessControlComponent;
77
use openzeppelin::interfaces::erc20::IERC20DispatcherTrait;
@@ -18,19 +18,20 @@ pub mod Core {
1818
FEE_POSITION, InternalTrait as PositionsInternalTrait,
1919
};
2020
use perpetuals::core::errors::{
21-
AMOUNT_OVERFLOW, FORCED_WAIT_REQUIRED, INVALID_ZERO_TIMEOUT, LENGTH_MISMATCH,
22-
NON_MONOTONIC_TIME, ORDER_IS_NOT_EXPIRED, STALE_TIME, TRADE_ASSET_NOT_SYNTHETIC,
23-
TRANSFER_FAILED,
21+
AMOUNT_OVERFLOW, FORCED_WAIT_REQUIRED, INVALID_INTEREST_RATE, INVALID_ZERO_TIMEOUT,
22+
LENGTH_MISMATCH, NON_MONOTONIC_TIME, ORDER_IS_NOT_EXPIRED, STALE_TIME,
23+
TRADE_ASSET_NOT_SYNTHETIC, TRANSFER_FAILED, ZERO_MAX_INTEREST_RATE,
2424
};
2525
use perpetuals::core::events;
2626
use perpetuals::core::interface::{ICore, Settlement};
2727
use perpetuals::core::types::asset::AssetId;
28+
use perpetuals::core::types::asset::synthetic::AssetType;
2829
use perpetuals::core::types::balance::Balance;
2930
use perpetuals::core::types::order::{ForcedTrade, LimitOrder, Order};
3031
use perpetuals::core::types::position::{PositionDiff, PositionId, PositionTrait};
3132
use perpetuals::core::types::price::PriceMulTrait;
3233
use perpetuals::core::types::vault::ConvertPositionToVault;
33-
use perpetuals::core::value_risk_calculator::PositionTVTR;
34+
use perpetuals::core::value_risk_calculator::{PositionTVTR, calculate_position_pnl};
3435
use starknet::event::EventEmitter;
3536
use starknet::storage::{
3637
StorageMapReadAccess, StoragePointerReadAccess, StoragePointerWriteAccess,
@@ -46,6 +47,8 @@ pub mod Core {
4647
use starkware_utils::components::roles::RolesComponent::InternalTrait as RolesInternal;
4748
use starkware_utils::components::roles::interface::IRoles;
4849
use starkware_utils::hash::message_hash::OffchainMessageHash;
50+
use starkware_utils::math::abs::Abs;
51+
use starkware_utils::math::utils::mul_wide_and_floor_div;
4952
use starkware_utils::signature::stark::{PublicKey, Signature};
5053
use starkware_utils::storage::iterable_map::{
5154
IterableMapIntoIterImpl, IterableMapReadAccessImpl, IterableMapWriteAccessImpl,
@@ -65,7 +68,6 @@ pub mod Core {
6568
use crate::core::components::vaults::vaults::{IVaults, Vaults as VaultsComponent};
6669
use crate::core::components::vaults::vaults_contract::IVaultExternalDispatcherTrait;
6770
use crate::core::components::withdrawal::withdrawal_manager::IWithdrawalManagerDispatcherTrait;
68-
use crate::core::types::asset::synthetic::AssetType;
6971
use crate::core::utils::{validate_signature, validate_trade};
7072

7173

@@ -163,6 +165,10 @@ pub mod Core {
163165
// Cost for executing forced actions.
164166
premium_cost: u64,
165167
system_time: Timestamp,
168+
// Maximum interest rate per second (32-bit fixed-point with 32-bit fractional part).
169+
// Example: max_interest_rate_per_sec = 1000000 means the rate is 1000000 / 2^32 ≈
170+
// 0.000232 per second, which is approximately 7.3% per year.
171+
max_interest_rate_per_sec: u32,
166172
}
167173

168174
#[event]
@@ -228,6 +234,7 @@ pub mod Core {
228234
insurance_fund_position_owner_public_key: PublicKey,
229235
forced_action_timelock: u64,
230236
premium_cost: u64,
237+
max_interest_rate_per_sec: u32,
231238
) {
232239
self.roles.initialize(:governance_admin);
233240
self.replaceability.initialize(:upgrade_delay);
@@ -250,6 +257,8 @@ pub mod Core {
250257
assert(forced_action_timelock.is_non_zero(), INVALID_ZERO_TIMEOUT);
251258
self.forced_action_timelock.write(TimeDelta { seconds: forced_action_timelock });
252259
self.premium_cost.write(premium_cost);
260+
assert(max_interest_rate_per_sec.is_non_zero(), ZERO_MAX_INTEREST_RATE);
261+
self.max_interest_rate_per_sec.write(max_interest_rate_per_sec);
253262
}
254263

255264
#[abi(embed_v0)]
@@ -994,6 +1003,38 @@ pub mod Core {
9941003
fn get_system_time(self: @ContractState) -> Timestamp {
9951004
self.system_time.read()
9961005
}
1006+
1007+
fn apply_interests(
1008+
ref self: ContractState,
1009+
operator_nonce: u64,
1010+
position_ids: Span<PositionId>,
1011+
interest_amounts: Span<i64>,
1012+
) {
1013+
assert(position_ids.len() == interest_amounts.len(), LENGTH_MISMATCH);
1014+
self.pausable.assert_not_paused();
1015+
self.assets.validate_assets_integrity();
1016+
self.operator_nonce.use_checked_nonce(:operator_nonce);
1017+
1018+
// Read once and pass as arguments to avoid redundant storage reads
1019+
let current_time = Time::now();
1020+
let max_interest_rate_per_sec = self.max_interest_rate_per_sec.read();
1021+
let interest_rate_scale: u64 = 2_u64.pow(32);
1022+
1023+
let mut i: usize = 0;
1024+
for position_id in position_ids {
1025+
let interest_amount = *interest_amounts[i];
1026+
self
1027+
._apply_interest_to_position(
1028+
position_id: *position_id,
1029+
:interest_amount,
1030+
:current_time,
1031+
:max_interest_rate_per_sec,
1032+
:interest_rate_scale,
1033+
);
1034+
i += 1;
1035+
}
1036+
}
1037+
9971038
fn liquidate_spot_asset(
9981039
ref self: ContractState,
9991040
operator_nonce: u64,
@@ -1175,5 +1216,93 @@ pub mod Core {
11751216
fn _is_vault(ref self: ContractState, vault_position: PositionId) -> bool {
11761217
self.vaults.is_vault_position(vault_position)
11771218
}
1219+
1220+
/// Calculates the position PnL (profit and loss) as the total value of synthetic assets
1221+
/// plus base collateral. Similar to TV calculation but without vault and spot assets.
1222+
/// Includes funding ticks in the calculation.
1223+
fn _calculate_position_pnl(
1224+
ref self: ContractState, position_id: PositionId, collateral_balance: Balance,
1225+
) -> i128 {
1226+
let position = self.positions.get_position_snapshot(:position_id);
1227+
1228+
// Use existing function to derive funding delta and unchanged assets
1229+
// This already calculates funding and builds AssetBalanceInfo array
1230+
let (funding_delta, unchanged_assets) = self
1231+
.positions
1232+
.derive_funding_delta_and_unchanged_assets(
1233+
:position, position_diff: Default::default(),
1234+
);
1235+
1236+
// Filter to only include synthetic assets (exclude vault and spot)
1237+
let mut synthetic_assets = array![];
1238+
for asset in unchanged_assets {
1239+
let asset_config = self.assets.get_asset_config(*asset.id);
1240+
if asset_config.asset_type == AssetType::SYNTHETIC {
1241+
synthetic_assets.append(*asset);
1242+
}
1243+
}
1244+
1245+
let collateral_balance_with_funding = collateral_balance + funding_delta;
1246+
calculate_position_pnl(synthetic_assets.span(), collateral_balance_with_funding)
1247+
}
1248+
1249+
fn _apply_interest_to_position(
1250+
ref self: ContractState,
1251+
position_id: PositionId,
1252+
interest_amount: i64,
1253+
current_time: Timestamp,
1254+
max_interest_rate_per_sec: u32,
1255+
interest_rate_scale: u64,
1256+
) {
1257+
// Check that position exists
1258+
let position = self.positions.get_position_mut(:position_id);
1259+
1260+
// Get old balance and last interest applied time
1261+
let current_balance = position.collateral_balance.read();
1262+
let previous_timestamp = position.last_interest_applied_time.read();
1263+
1264+
// Calculate new balance
1265+
let new_balance = current_balance + interest_amount.into();
1266+
1267+
// Calculate position PnL (total value of synthetic assets + base collateral)
1268+
let pnl = self._calculate_position_pnl(position_id, current_balance);
1269+
1270+
// Validate interest rate
1271+
let pnl: i64 = pnl.try_into().expect(AMOUNT_OVERFLOW);
1272+
if pnl.is_non_zero() && previous_timestamp.is_non_zero() {
1273+
// Calculate time difference
1274+
let time_diff: u64 = current_time.seconds - previous_timestamp.seconds;
1275+
1276+
// Calculate maximum allowed change: |old_balance| * time_diff *
1277+
// max_interest_rate_per_sec / 2^32 Formula: |interest_amount| <= |old_balance| *
1278+
// time_diff * max_interest_rate_per_sec / 2^32
1279+
let pnl_abs: u128 = pnl.abs().into();
1280+
let max_rate: u128 = max_interest_rate_per_sec.into();
1281+
let time_diff_u128: u128 = time_diff.into();
1282+
// Calculate: (|old_balance| * time_diff * max_interest_rate_per_sec) / 2^32
1283+
// To preserve precision, multiply all factors first, then divide.
1284+
// Use mul_wide_and_floor_div to safely compute (balance * time_diff * max_rate) /
1285+
// scale which handles wide multiplication to avoid overflow.
1286+
let balance_time_product = pnl_abs * time_diff_u128;
1287+
let max_allowed_change = mul_wide_and_floor_div(
1288+
balance_time_product, max_rate, interest_rate_scale.into(),
1289+
)
1290+
.expect(AMOUNT_OVERFLOW);
1291+
1292+
// Check: |interest_amount| <= max_allowed_change
1293+
let interest_amount_abs: u128 = interest_amount.abs().into();
1294+
assert(interest_amount_abs <= max_allowed_change, INVALID_INTEREST_RATE);
1295+
1296+
// Apply interest
1297+
position.collateral_balance.write(new_balance);
1298+
} else {
1299+
// If old balance is zero, only allow zero interest.
1300+
// If `prev_ts` is zero, this indicates the first interest calculation, and the
1301+
// interest amount is required to be zero.
1302+
assert(interest_amount.is_zero(), INVALID_INTEREST_RATE);
1303+
}
1304+
1305+
position.last_interest_applied_time.write(current_time);
1306+
}
11781307
}
11791308
}

workspace/apps/perpetuals/contracts/src/core/errors.cairo

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ pub const ORDER_IS_NOT_EXPIRED: felt252 = 'ORDER_IS_NOT_EXPIRED';
2727
pub const LENGTH_MISMATCH: felt252 = 'LENGTH_MISMATCH';
2828
pub const NON_MONOTONIC_TIME: felt252 = 'NON_MONOTONIC_TIME';
2929
pub const STALE_TIME: felt252 = 'STALE_TIME';
30-
30+
pub const INVALID_INTEREST_RATE: felt252 = 'INVALID_INTEREST_RATE';
31+
pub const ZERO_MAX_INTEREST_RATE: felt252 = 'ZERO_MAX_INTEREST_RATE';
3132

3233
pub fn fulfillment_exceeded_err(position_id: PositionId) -> ByteArray {
3334
format!("FULFILLMENT_EXCEEDED position_id: {:?}", position_id)

workspace/apps/perpetuals/contracts/src/core/interface.cairo

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,12 @@ pub trait ICore<TContractState> {
166166
order_b: Order,
167167
);
168168
fn forced_trade(ref self: TContractState, operator_nonce: u64, order_a: Order, order_b: Order);
169+
fn apply_interests(
170+
ref self: TContractState,
171+
operator_nonce: u64,
172+
position_ids: Span<PositionId>,
173+
interest_amounts: Span<i64>,
174+
);
169175
fn update_system_time(ref self: TContractState, operator_nonce: u64, new_timestamp: Timestamp);
170176
fn get_system_time(self: @TContractState) -> Timestamp;
171177
fn liquidate_spot_asset(

workspace/apps/perpetuals/contracts/src/core/types/position.cairo

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use starkware_utils::signature::stark::PublicKey;
99
use starkware_utils::storage::iterable_map::{
1010
IterableMap, IterableMapIntoIterImpl, IterableMapReadAccessImpl, IterableMapWriteAccessImpl,
1111
};
12+
use starkware_utils::time::time::Timestamp;
1213

1314
pub const POSITION_VERSION: u8 = 1;
1415

@@ -21,6 +22,7 @@ pub struct Position {
2122
#[rename("synthetic_balance")]
2223
pub asset_balances: IterableMap<AssetId, AssetBalance>,
2324
pub owner_protection_enabled: bool,
25+
pub last_interest_applied_time: Timestamp,
2426
}
2527

2628
/// Synthetic asset in a position.

workspace/apps/perpetuals/contracts/src/core/value_risk_calculator.cairo

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,36 @@ pub fn calculate_position_tvtr(
166166
calculate_position_tvtr_before(:unchanged_assets, :position_diff_enriched)
167167
}
168168

169+
/// Calculates the position PnL (profit and loss) as the total value of synthetic assets
170+
/// plus base collateral. Similar to TV calculation but without vault and spot assets.
171+
///
172+
/// # Arguments
173+
///
174+
/// * `unchanged_assets` - Span of AssetBalanceInfo for synthetic assets only (vault and spot
175+
/// excluded)
176+
/// * `collateral_balance` - Base collateral balance
177+
///
178+
/// # Returns
179+
///
180+
/// * `i128` - The position PnL in units of 10^-6 USD
181+
pub fn calculate_position_pnl(
182+
unchanged_assets: Span<AssetBalanceInfo>, collateral_balance: Balance,
183+
) -> i128 {
184+
let mut pnl: i128 = 0_i128;
185+
186+
// Add base collateral value.
187+
let collateral_price: Price = One::one();
188+
pnl += collateral_price.mul(rhs: collateral_balance);
189+
190+
// Vault and spot assets should already be excluded.
191+
for synthetic in unchanged_assets {
192+
let asset_value: i128 = (*synthetic.price).mul(rhs: *synthetic.balance);
193+
pnl += asset_value;
194+
}
195+
196+
pnl
197+
}
198+
169199
/// Calculates the total value and total risk change for a position, taking into account both
170200
/// unchanged assets and position changes (collateral and synthetic assets).
171201
///

workspace/apps/perpetuals/contracts/src/tests/constants.cairo

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ pub const SYNTHETIC_BALANCE_AMOUNT: i64 = 20;
8181
pub const CONTRACT_INIT_BALANCE: u128 = 1_000_000_000;
8282
pub const USER_INIT_BALANCE: u128 = 10_000_000_000;
8383
pub const VAULT_SHARE_QUANTUM: u64 = 1_000;
84+
pub const MAX_INTEREST_RATE_PER_SEC: u32 = 1200; // 0.1% per hour.
8485

8586
pub const POSITION_ID_100: PositionId = PositionId { value: 100 };
8687
pub const POSITION_ID_200: PositionId = PositionId { value: 200 };

workspace/apps/perpetuals/contracts/src/tests/flow_tests/perps_tests_facade.cairo

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ struct PerpetualsConfig {
264264
insurance_fund_position_owner_public_key: PublicKey,
265265
forced_action_timelock: u64,
266266
premium_cost: u64,
267+
max_interest_rate_per_sec: u32,
267268
}
268269

269270
#[generate_trait]
@@ -290,6 +291,7 @@ pub impl PerpetualsConfigImpl of PerpetualsConfigTrait {
290291
insurance_fund_position_owner_public_key: operator.key_pair.public_key,
291292
forced_action_timelock: FORCED_ACTION_TIMELOCK,
292293
premium_cost: PREMIUM_COST,
294+
max_interest_rate_per_sec: MAX_INTEREST_RATE_PER_SEC,
293295
}
294296
}
295297
}
@@ -311,6 +313,7 @@ impl PerpetualsContractStateImpl of Deployable<PerpetualsConfig, ContractAddress
311313
self.insurance_fund_position_owner_public_key.serialize(ref calldata);
312314
self.forced_action_timelock.serialize(ref calldata);
313315
self.premium_cost.serialize(ref calldata);
316+
self.max_interest_rate_per_sec.serialize(ref calldata);
314317

315318
let perpetuals_contract = snforge_std::declare("Core").unwrap().contract_class();
316319
let (address, _) = perpetuals_contract.deploy(@calldata).unwrap();

workspace/apps/perpetuals/contracts/src/tests/test_utils.cairo

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ use crate::core::components::external_components::interface::{
4747
EXTERNAL_COMPONENT_WITHDRAWALS, IExternalComponents, IExternalComponentsDispatcher,
4848
IExternalComponentsDispatcherTrait,
4949
};
50-
use super::constants::{FORCED_ACTION_TIMELOCK, PREMIUM_COST};
50+
use super::constants::{FORCED_ACTION_TIMELOCK, MAX_INTEREST_RATE_PER_SEC, PREMIUM_COST};
5151

5252
/// The `User` struct represents a user corresponding to a position in the state of the Core
5353
/// contract.
@@ -148,6 +148,7 @@ pub struct PerpetualsInitConfig {
148148
pub insurance_fund_position_owner_public_key: felt252,
149149
pub forced_action_timelock: u64,
150150
pub premium_cost: u64,
151+
pub max_interest_rate_per_sec: u32,
151152
pub collateral_cfg: CollateralCfg,
152153
pub synthetic_cfg: SyntheticCfg,
153154
pub vault_share_cfg: VaultCollateralCfg,
@@ -171,6 +172,7 @@ pub impl CoreImpl of CoreTrait {
171172
self.insurance_fund_position_owner_public_key.serialize(ref calldata);
172173
self.forced_action_timelock.serialize(ref calldata);
173174
self.premium_cost.serialize(ref calldata);
175+
self.max_interest_rate_per_sec.serialize(ref calldata);
174176

175177
let core_contract = snforge_std::declare("Core").unwrap().contract_class();
176178
let (core_contract_address, _) = core_contract.deploy(@calldata).unwrap();
@@ -223,6 +225,7 @@ impl PerpetualsInitConfigDefault of Default<PerpetualsInitConfig> {
223225
insurance_fund_position_owner_public_key: OPERATOR_PUBLIC_KEY(),
224226
forced_action_timelock: FORCED_ACTION_TIMELOCK,
225227
premium_cost: PREMIUM_COST,
228+
max_interest_rate_per_sec: MAX_INTEREST_RATE_PER_SEC,
226229
collateral_cfg: CollateralCfg {
227230
token_cfg: TokenConfig {
228231
name: COLLATERAL_NAME(),
@@ -720,6 +723,7 @@ pub fn initialized_contract_state(
720723
insurance_fund_position_owner_public_key: OPERATOR_PUBLIC_KEY(),
721724
forced_action_timelock: FORCED_ACTION_TIMELOCK,
722725
premium_cost: PREMIUM_COST,
726+
max_interest_rate_per_sec: MAX_INTEREST_RATE_PER_SEC,
723727
);
724728
state
725729
}

0 commit comments

Comments
 (0)