Skip to content

Commit 4fa0b69

Browse files
feat(multi-collateral): impl apply interests
1 parent fcc684e commit 4fa0b69

File tree

9 files changed

+124
-6
lines changed

9 files changed

+124
-6
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: 102 additions & 3 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,8 +18,9 @@ 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-
ORDER_IS_NOT_EXPIRED, TRADE_ASSET_NOT_SYNTHETIC, TRANSFER_FAILED,
21+
AMOUNT_OVERFLOW, FORCED_WAIT_REQUIRED, INVALID_INTEREST_RATE, INVALID_ZERO_TIMEOUT,
22+
LENGTH_MISMATCH, ORDER_IS_NOT_EXPIRED, TRADE_ASSET_NOT_SYNTHETIC, TRANSFER_FAILED,
23+
ZERO_MAX_INTEREST_RATE,
2324
};
2425
use perpetuals::core::events;
2526
use perpetuals::core::interface::{ICore, Settlement};
@@ -45,6 +46,8 @@ pub mod Core {
4546
use starkware_utils::components::roles::RolesComponent::InternalTrait as RolesInternal;
4647
use starkware_utils::components::roles::interface::IRoles;
4748
use starkware_utils::hash::message_hash::OffchainMessageHash;
49+
use starkware_utils::math::abs::Abs;
50+
use starkware_utils::math::utils::mul_wide_and_floor_div;
4851
use starkware_utils::signature::stark::{PublicKey, Signature};
4952
use starkware_utils::storage::iterable_map::{
5053
IterableMapIntoIterImpl, IterableMapReadAccessImpl, IterableMapWriteAccessImpl,
@@ -161,6 +164,8 @@ pub mod Core {
161164
forced_action_timelock: TimeDelta,
162165
// Cost for executing forced actions.
163166
premium_cost: u64,
167+
// Maximum interest rate per second (32-bit fixed-point with 32-bit fractional part).
168+
max_interest_rate_per_sec: u32,
164169
}
165170

166171
#[event]
@@ -226,6 +231,7 @@ pub mod Core {
226231
insurance_fund_position_owner_public_key: PublicKey,
227232
forced_action_timelock: u64,
228233
premium_cost: u64,
234+
max_interest_rate_per_sec: u32,
229235
) {
230236
self.roles.initialize(:governance_admin);
231237
self.replaceability.initialize(:upgrade_delay);
@@ -248,6 +254,8 @@ pub mod Core {
248254
assert(forced_action_timelock.is_non_zero(), INVALID_ZERO_TIMEOUT);
249255
self.forced_action_timelock.write(TimeDelta { seconds: forced_action_timelock });
250256
self.premium_cost.write(premium_cost);
257+
assert(max_interest_rate_per_sec.is_non_zero(), ZERO_MAX_INTEREST_RATE);
258+
self.max_interest_rate_per_sec.write(max_interest_rate_per_sec);
251259
}
252260

253261
#[abi(embed_v0)]
@@ -953,6 +961,36 @@ pub mod Core {
953961
},
954962
);
955963
}
964+
fn apply_interests(
965+
ref self: ContractState,
966+
operator_nonce: u64,
967+
position_ids: Span<PositionId>,
968+
interest_amounts: Span<i64>,
969+
) {
970+
assert(position_ids.len() == interest_amounts.len(), LENGTH_MISMATCH);
971+
self.pausable.assert_not_paused();
972+
self.assets.validate_assets_integrity();
973+
self.operator_nonce.use_checked_nonce(:operator_nonce);
974+
975+
// Read once and pass as arguments to avoid redundant storage reads
976+
let current_time = Time::now();
977+
let max_interest_rate_per_sec = self.max_interest_rate_per_sec.read();
978+
let interest_rate_scale: u64 = 2_u64.pow(32);
979+
980+
let mut i: usize = 0;
981+
for position_id in position_ids {
982+
let interest_amount = *interest_amounts[i];
983+
self
984+
._apply_interest_to_position(
985+
position_id: *position_id,
986+
:interest_amount,
987+
:current_time,
988+
:max_interest_rate_per_sec,
989+
:interest_rate_scale,
990+
);
991+
i += 1;
992+
}
993+
}
956994
}
957995

958996
#[generate_trait]
@@ -1106,5 +1144,66 @@ pub mod Core {
11061144
fn _is_vault(ref self: ContractState, vault_position: PositionId) -> bool {
11071145
self.vaults.is_vault_position(vault_position)
11081146
}
1147+
1148+
fn _apply_interest_to_position(
1149+
ref self: ContractState,
1150+
position_id: PositionId,
1151+
interest_amount: i64,
1152+
current_time: Timestamp,
1153+
max_interest_rate_per_sec: u32,
1154+
interest_rate_scale: u64,
1155+
) {
1156+
// Check that position exists
1157+
let position = self.positions.get_position_mut(:position_id);
1158+
1159+
// Get old balance and last interest applied time
1160+
let old_balance = position.collateral_balance.read();
1161+
let prev_ts = position.last_interest_applied_time.read();
1162+
1163+
// Calculate new balance
1164+
let new_balance = old_balance + interest_amount.into();
1165+
1166+
// Validate interest rate
1167+
let old_balance_value: i64 = old_balance.into();
1168+
if old_balance_value.is_non_zero() && prev_ts.is_non_zero() {
1169+
// Calculate time difference
1170+
let time_diff: u64 = if current_time.seconds >= prev_ts.seconds {
1171+
current_time.seconds - prev_ts.seconds
1172+
} else {
1173+
// If current time is before previous time, something is wrong
1174+
panic_with_felt252('INVALID_TIMESTAMP');
1175+
};
1176+
1177+
// Calculate maximum allowed change: |old_balance| * time_diff *
1178+
// max_interest_rate_per_sec / 2^32 Formula: |interest_amount| <= |old_balance| *
1179+
// time_diff * max_interest_rate_per_sec / 2^32
1180+
let old_balance_abs: u128 = old_balance_value.abs().into();
1181+
let max_rate: u128 = max_interest_rate_per_sec.into();
1182+
let time_diff_u128: u128 = time_diff.into();
1183+
// Calculate: (|old_balance| * time_diff * max_interest_rate_per_sec) / 2^32
1184+
// To preserve precision, multiply all factors first, then divide.
1185+
// Use mul_wide_and_floor_div to safely compute (balance * time_diff * max_rate) /
1186+
// scale which handles wide multiplication to avoid overflow.
1187+
let balance_time_product = old_balance_abs * time_diff_u128;
1188+
let max_allowed_change = mul_wide_and_floor_div(
1189+
balance_time_product, max_rate, interest_rate_scale.into(),
1190+
)
1191+
.expect(AMOUNT_OVERFLOW);
1192+
1193+
// Check: |interest_amount| <= max_allowed_change
1194+
let interest_amount_abs: u128 = interest_amount.abs().into();
1195+
assert(interest_amount_abs <= max_allowed_change, INVALID_INTEREST_RATE);
1196+
1197+
// Apply interest
1198+
position.collateral_balance.write(new_balance);
1199+
} else {
1200+
// If old balance is zero, only allow zero interest.
1201+
// If `prev_ts` is zero, this indicates the first interest calculation, and the
1202+
// interest amount is required to be zero.
1203+
assert(interest_amount.is_zero(), INVALID_INTEREST_RATE);
1204+
}
1205+
1206+
position.last_interest_applied_time.write(current_time);
1207+
}
11091208
}
11101209
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ pub const TRANSFER_FAILED: felt252 = 'TRANSFER_FAILED';
2525
pub const SAME_BASE_QUOTE_ASSET_IDS: felt252 = 'SAME_BASE_QUOTE_ASSET_IDS';
2626
pub const ORDER_IS_NOT_EXPIRED: felt252 = 'ORDER_IS_NOT_EXPIRED';
2727
pub const LENGTH_MISMATCH: felt252 = 'LENGTH_MISMATCH';
28+
pub const INVALID_INTEREST_RATE: felt252 = 'INVALID_INTEREST_RATE';
29+
pub const ZERO_MAX_INTEREST_RATE: felt252 = 'ZERO_MAX_INTEREST_RATE';
2830

2931
pub fn fulfillment_exceeded_err(position_id: PositionId) -> ByteArray {
3032
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,4 +166,10 @@ pub trait ICore<TContractState> {
166166
order_b: Order,
167167
);
168168
fn forced_trade(ref self: TContractState, 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
}

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/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 = 14; // 10% per year.
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)