Skip to content

Commit 6817b8a

Browse files
feat(multi-collateral): impl apply interests
1 parent bb1d0b9 commit 6817b8a

File tree

10 files changed

+178
-12
lines changed

10 files changed

+178
-12
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: 126 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,10 +47,13 @@ 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,
5255
};
56+
use starkware_utils::storage::utils::AddToStorage;
5357
use starkware_utils::time::time::{Time, TimeDelta, Timestamp};
5458
use crate::core::components::assets::interface::IAssets;
5559
use crate::core::components::deleverage::deleverage_manager::IDeleverageManagerDispatcherTrait;
@@ -65,7 +69,6 @@ pub mod Core {
6569
use crate::core::components::vaults::vaults::{IVaults, Vaults as VaultsComponent};
6670
use crate::core::components::vaults::vaults_contract::IVaultExternalDispatcherTrait;
6771
use crate::core::components::withdrawal::withdrawal_manager::IWithdrawalManagerDispatcherTrait;
68-
use crate::core::types::asset::synthetic::AssetType;
6972
use crate::core::utils::{validate_signature, validate_trade};
7073

7174

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

168175
#[event]
@@ -228,6 +235,7 @@ pub mod Core {
228235
insurance_fund_position_owner_public_key: PublicKey,
229236
forced_action_timelock: u64,
230237
premium_cost: u64,
238+
max_interest_rate_per_sec: u32,
231239
) {
232240
self.roles.initialize(:governance_admin);
233241
self.replaceability.initialize(:upgrade_delay);
@@ -250,6 +258,8 @@ pub mod Core {
250258
assert(forced_action_timelock.is_non_zero(), INVALID_ZERO_TIMEOUT);
251259
self.forced_action_timelock.write(TimeDelta { seconds: forced_action_timelock });
252260
self.premium_cost.write(premium_cost);
261+
assert(max_interest_rate_per_sec.is_non_zero(), ZERO_MAX_INTEREST_RATE);
262+
self.max_interest_rate_per_sec.write(max_interest_rate_per_sec);
253263
}
254264

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

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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use core::num::traits::{One, Zero};
22
use core::panics::panic_with_byte_array;
33
use perpetuals::core::errors::{
4-
position_not_deleveragable, position_not_fair_deleverage, position_not_healthy_nor_healthier,
5-
position_not_liquidatable,
4+
AMOUNT_OVERFLOW, position_not_deleveragable, position_not_fair_deleverage,
5+
position_not_healthy_nor_healthier, position_not_liquidatable,
66
};
77
use perpetuals::core::types::asset::synthetic::AssetBalanceInfo;
88
use perpetuals::core::types::balance::{Balance, BalanceDiff};
@@ -166,6 +166,34 @@ 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+
/// * `assets` - Span of AssetBalanceInfo for synthetic assets only (vault and spot
175+
/// excluded)
176+
/// * `collateral_balance` - Base collateral balance
177+
///
178+
/// # Returns
179+
///
180+
/// The position PnL in units of 10^-6 USD
181+
pub fn calculate_position_pnl(assets: Span<AssetBalanceInfo>, collateral_balance: Balance) -> i64 {
182+
let mut pnl: i128 = 0_i128;
183+
184+
// Add base collateral value.
185+
let collateral_price: Price = One::one();
186+
pnl += collateral_price.mul(rhs: collateral_balance);
187+
188+
// Vault and spot assets should already be excluded.
189+
for synthetic in assets {
190+
let asset_value: i128 = (*synthetic.price).mul(rhs: *synthetic.balance);
191+
pnl += asset_value;
192+
}
193+
194+
pnl.try_into().expect(AMOUNT_OVERFLOW)
195+
}
196+
169197
/// Calculates the total value and total risk change for a position, taking into account both
170198
/// unchanged assets and position changes (collateral and synthetic assets).
171199
///

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)