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
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ pub trait IPositions<TContractState> {
new_public_key: PublicKey,
expiration: Timestamp,
);

fn enable_owner_protection(
ref self: TContractState,
operator_nonce: u64,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub mod Positions {
IterableMapIntoIterImpl, IterableMapReadAccessImpl, IterableMapWriteAccessImpl,
};
use starkware_utils::storage::utils::AddToStorage;
use starkware_utils::time::time::{Timestamp, validate_expiration};
use starkware_utils::time::time::{Time, Timestamp, validate_expiration};
use crate::core::components::snip::SNIP12MetadataImpl;
use crate::core::errors::{
INVALID_AMOUNT_SIGN, INVALID_BASE_CHANGE, INVALID_SAME_POSITIONS, INVALID_ZERO_AMOUNT,
Expand Down Expand Up @@ -185,9 +185,11 @@ pub mod Positions {
let mut position = self.positions.entry(position_id);
assert(position.version.read().is_zero(), POSITION_ALREADY_EXISTS);
assert(owner_public_key.is_non_zero(), INVALID_ZERO_PUBLIC_KEY);
let current_time = Time::now();
position.version.write(POSITION_VERSION);
position.owner_public_key.write(owner_public_key);
position.owner_protection_enabled.write(owner_protection_enabled);
position.last_interest_applied_time.write(current_time);
if owner_account.is_non_zero() {
position.owner_account.write(Option::Some(owner_account));
}
Expand Down
132 changes: 126 additions & 6 deletions workspace/apps/perpetuals/contracts/src/core/core.cairo
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#[starknet::contract]
pub mod Core {
use core::dict::{Felt252Dict, Felt252DictTrait};
use core::num::traits::Zero;
use core::num::traits::{Pow, Zero};
use core::panic_with_felt252;
use openzeppelin::access::accesscontrol::AccessControlComponent;
use openzeppelin::interfaces::erc20::IERC20DispatcherTrait;
Expand All @@ -18,19 +18,20 @@ pub mod Core {
FEE_POSITION, InternalTrait as PositionsInternalTrait,
};
use perpetuals::core::errors::{
AMOUNT_OVERFLOW, FORCED_WAIT_REQUIRED, INVALID_ZERO_TIMEOUT, LENGTH_MISMATCH,
NON_MONOTONIC_TIME, ORDER_IS_NOT_EXPIRED, STALE_TIME, TRADE_ASSET_NOT_SYNTHETIC,
TRANSFER_FAILED,
AMOUNT_OVERFLOW, FORCED_WAIT_REQUIRED, INVALID_INTEREST_RATE, INVALID_ZERO_TIMEOUT,
LENGTH_MISMATCH, NON_MONOTONIC_TIME, ORDER_IS_NOT_EXPIRED, STALE_TIME,
TRADE_ASSET_NOT_SYNTHETIC, TRANSFER_FAILED, ZERO_MAX_INTEREST_RATE,
};
use perpetuals::core::events;
use perpetuals::core::interface::{ICore, Settlement};
use perpetuals::core::types::asset::AssetId;
use perpetuals::core::types::asset::synthetic::AssetType;
use perpetuals::core::types::balance::Balance;
use perpetuals::core::types::order::{ForcedTrade, LimitOrder, Order};
use perpetuals::core::types::position::{PositionDiff, PositionId, PositionTrait};
use perpetuals::core::types::price::PriceMulTrait;
use perpetuals::core::types::vault::ConvertPositionToVault;
use perpetuals::core::value_risk_calculator::PositionTVTR;
use perpetuals::core::value_risk_calculator::{PositionTVTR, calculate_position_pnl};
use starknet::event::EventEmitter;
use starknet::storage::{
StorageMapReadAccess, StoragePointerReadAccess, StoragePointerWriteAccess,
Expand All @@ -46,10 +47,13 @@ pub mod Core {
use starkware_utils::components::roles::RolesComponent::InternalTrait as RolesInternal;
use starkware_utils::components::roles::interface::IRoles;
use starkware_utils::hash::message_hash::OffchainMessageHash;
use starkware_utils::math::abs::Abs;
use starkware_utils::math::utils::mul_wide_and_floor_div;
use starkware_utils::signature::stark::{PublicKey, Signature};
use starkware_utils::storage::iterable_map::{
IterableMapIntoIterImpl, IterableMapReadAccessImpl, IterableMapWriteAccessImpl,
};
use starkware_utils::storage::utils::AddToStorage;
use starkware_utils::time::time::{Time, TimeDelta, Timestamp};
use crate::core::components::assets::interface::IAssets;
use crate::core::components::deleverage::deleverage_manager::IDeleverageManagerDispatcherTrait;
Expand All @@ -65,7 +69,6 @@ pub mod Core {
use crate::core::components::vaults::vaults::{IVaults, Vaults as VaultsComponent};
use crate::core::components::vaults::vaults_contract::IVaultExternalDispatcherTrait;
use crate::core::components::withdrawal::withdrawal_manager::IWithdrawalManagerDispatcherTrait;
use crate::core::types::asset::synthetic::AssetType;
use crate::core::utils::{validate_signature, validate_trade};


Expand Down Expand Up @@ -163,6 +166,10 @@ pub mod Core {
// Cost for executing forced actions.
premium_cost: u64,
system_time: Timestamp,
// Maximum interest rate per second (32-bit fixed-point with 32-bit fractional part).
// Example: max_interest_rate_per_sec = 10 means the rate is 10 / 2^32 ≈ 0.000000232 per
// second, which is approximately 7.4% per year.
max_interest_rate_per_sec: u32,
}

#[event]
Expand Down Expand Up @@ -228,6 +235,7 @@ pub mod Core {
insurance_fund_position_owner_public_key: PublicKey,
forced_action_timelock: u64,
premium_cost: u64,
max_interest_rate_per_sec: u32,
) {
self.roles.initialize(:governance_admin);
self.replaceability.initialize(:upgrade_delay);
Expand All @@ -250,6 +258,8 @@ pub mod Core {
assert(forced_action_timelock.is_non_zero(), INVALID_ZERO_TIMEOUT);
self.forced_action_timelock.write(TimeDelta { seconds: forced_action_timelock });
self.premium_cost.write(premium_cost);
assert(max_interest_rate_per_sec.is_non_zero(), ZERO_MAX_INTEREST_RATE);
self.max_interest_rate_per_sec.write(max_interest_rate_per_sec);
}

#[abi(embed_v0)]
Expand Down Expand Up @@ -994,6 +1004,38 @@ pub mod Core {
fn get_system_time(self: @ContractState) -> Timestamp {
self.system_time.read()
}

fn apply_interests(
ref self: ContractState,
operator_nonce: u64,
position_ids: Span<PositionId>,
interest_amounts: Span<i64>,
) {
assert(position_ids.len() == interest_amounts.len(), LENGTH_MISMATCH);
self.pausable.assert_not_paused();
self.assets.validate_assets_integrity();
self.operator_nonce.use_checked_nonce(:operator_nonce);

// Read once and pass as arguments to avoid redundant storage reads
let current_time = Time::now();
let max_interest_rate_per_sec = self.max_interest_rate_per_sec.read();
let interest_rate_scale: u64 = 2_u64.pow(32);

let mut i: usize = 0;
for position_id in position_ids {
let interest_amount = *interest_amounts[i];
self
._apply_interest_to_position(
position_id: *position_id,
:interest_amount,
:current_time,
:max_interest_rate_per_sec,
:interest_rate_scale,
);
i += 1;
}
}

fn liquidate_spot_asset(
ref self: ContractState,
operator_nonce: u64,
Expand Down Expand Up @@ -1175,5 +1217,83 @@ pub mod Core {
fn _is_vault(ref self: ContractState, vault_position: PositionId) -> bool {
self.vaults.is_vault_position(vault_position)
}

/// Calculates the position PnL (profit and loss) as the total value of synthetic assets
/// plus base collateral. Similar to TV calculation but without vault and spot assets.
/// Includes funding ticks in the calculation.
fn _calculate_position_pnl(
ref self: ContractState, position_id: PositionId, collateral_balance: Balance,
) -> i64 {
let position = self.positions.get_position_snapshot(:position_id);

// Use existing function to derive funding delta and unchanged assets
// This already calculates funding and builds AssetBalanceInfo array
let (funding_delta, unchanged_assets) = self
.positions
.derive_funding_delta_and_unchanged_assets(
:position, position_diff: Default::default(),
);

// Filter to only include synthetic assets (exclude vault and spot)
let mut synthetic_assets = array![];
for asset in unchanged_assets {
let asset_config = self.assets.get_asset_config(*asset.id);
if asset_config.asset_type == AssetType::SYNTHETIC {
synthetic_assets.append(*asset);
}
}

let collateral_balance_with_funding = collateral_balance + funding_delta;
calculate_position_pnl(
assets: synthetic_assets.span(),
collateral_balance: collateral_balance_with_funding,
)
}

fn _apply_interest_to_position(
ref self: ContractState,
position_id: PositionId,
interest_amount: i64,
current_time: Timestamp,
max_interest_rate_per_sec: u32,
interest_rate_scale: u64,
) {
// Check that position exists
let position = self.positions.get_position_mut(:position_id);

let previous_timestamp = position.last_interest_applied_time.read();

// Calculate position PnL (total value of synthetic assets + base collateral)
let pnl = self._calculate_position_pnl(position_id, position.collateral_balance.read());

// Validate interest rate
if pnl.is_non_zero() && previous_timestamp.is_non_zero() {
// Calculate time difference
let time_diff: u64 = current_time.seconds - previous_timestamp.seconds;

// Calculate maximum allowed change: |pnl| * time_diff *
// max_interest_rate_per_sec / 2^32.
let balance_time_product: u128 = pnl.abs().into() * time_diff.into();
let max_allowed_change = mul_wide_and_floor_div(
balance_time_product,
max_interest_rate_per_sec.into(),
interest_rate_scale.into(),
)
.expect(AMOUNT_OVERFLOW);

// Check: |interest_amount| <= max_allowed_change
assert(interest_amount.abs().into() <= max_allowed_change, INVALID_INTEREST_RATE);

// Apply interest
position.collateral_balance.add_and_write(interest_amount.into());
} else {
// If old balance is zero, only allow zero interest.
// If `previous_timestamp` is zero, this indicates the first interest calculation,
// and the interest amount is required to be zero.
assert(interest_amount.is_zero(), INVALID_INTEREST_RATE);
}

position.last_interest_applied_time.write(current_time);
}
}
}
3 changes: 2 additions & 1 deletion workspace/apps/perpetuals/contracts/src/core/errors.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ pub const ORDER_IS_NOT_EXPIRED: felt252 = 'ORDER_IS_NOT_EXPIRED';
pub const LENGTH_MISMATCH: felt252 = 'LENGTH_MISMATCH';
pub const NON_MONOTONIC_TIME: felt252 = 'NON_MONOTONIC_TIME';
pub const STALE_TIME: felt252 = 'STALE_TIME';

pub const INVALID_INTEREST_RATE: felt252 = 'INVALID_INTEREST_RATE';
pub const ZERO_MAX_INTEREST_RATE: felt252 = 'ZERO_MAX_INTEREST_RATE';

pub fn fulfillment_exceeded_err(position_id: PositionId) -> ByteArray {
format!("FULFILLMENT_EXCEEDED position_id: {:?}", position_id)
Expand Down
6 changes: 6 additions & 0 deletions workspace/apps/perpetuals/contracts/src/core/interface.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ pub trait ICore<TContractState> {
order_b: Order,
);
fn forced_trade(ref self: TContractState, operator_nonce: u64, order_a: Order, order_b: Order);
fn apply_interests(
ref self: TContractState,
operator_nonce: u64,
position_ids: Span<PositionId>,
interest_amounts: Span<i64>,
);
fn update_system_time(ref self: TContractState, operator_nonce: u64, new_timestamp: Timestamp);
fn get_system_time(self: @TContractState) -> Timestamp;
fn liquidate_spot_asset(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use starkware_utils::signature::stark::PublicKey;
use starkware_utils::storage::iterable_map::{
IterableMap, IterableMapIntoIterImpl, IterableMapReadAccessImpl, IterableMapWriteAccessImpl,
};
use starkware_utils::time::time::Timestamp;

pub const POSITION_VERSION: u8 = 1;

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

/// Synthetic asset in a position.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use core::num::traits::{One, Zero};
use core::panics::panic_with_byte_array;
use perpetuals::core::errors::{
position_not_deleveragable, position_not_fair_deleverage, position_not_healthy_nor_healthier,
position_not_liquidatable,
AMOUNT_OVERFLOW, position_not_deleveragable, position_not_fair_deleverage,
position_not_healthy_nor_healthier, position_not_liquidatable,
};
use perpetuals::core::types::asset::synthetic::AssetBalanceInfo;
use perpetuals::core::types::balance::{Balance, BalanceDiff};
Expand Down Expand Up @@ -166,6 +166,34 @@ pub fn calculate_position_tvtr(
calculate_position_tvtr_before(:unchanged_assets, :position_diff_enriched)
}

/// Calculates the position PnL (profit and loss) as the total value of synthetic assets
/// plus base collateral. Similar to TV calculation but without vault and spot assets.
///
/// # Arguments
///
/// * `assets` - Span of AssetBalanceInfo for synthetic assets only (vault and spot
/// excluded)
/// * `collateral_balance` - Base collateral balance
///
/// # Returns
///
/// The position PnL in units of 10^-6 USD
pub fn calculate_position_pnl(assets: Span<AssetBalanceInfo>, collateral_balance: Balance) -> i64 {
let mut pnl: i128 = 0_i128;

// Add base collateral value.
let collateral_price: Price = One::one();
pnl += collateral_price.mul(rhs: collateral_balance);

// Vault and spot assets should already be excluded.
for synthetic in assets {
let asset_value: i128 = (*synthetic.price).mul(rhs: *synthetic.balance);
pnl += asset_value;
}

pnl.try_into().expect(AMOUNT_OVERFLOW)
}

/// Calculates the total value and total risk change for a position, taking into account both
/// unchanged assets and position changes (collateral and synthetic assets).
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ pub const SYNTHETIC_BALANCE_AMOUNT: i64 = 20;
pub const CONTRACT_INIT_BALANCE: u128 = 1_000_000_000;
pub const USER_INIT_BALANCE: u128 = 10_000_000_000;
pub const VAULT_SHARE_QUANTUM: u64 = 1_000;
pub const MAX_INTEREST_RATE_PER_SEC: u32 = 1200; // 0.1% per hour.

pub const POSITION_ID_100: PositionId = PositionId { value: 100 };
pub const POSITION_ID_200: PositionId = PositionId { value: 200 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ struct PerpetualsConfig {
insurance_fund_position_owner_public_key: PublicKey,
forced_action_timelock: u64,
premium_cost: u64,
max_interest_rate_per_sec: u32,
}

#[generate_trait]
Expand All @@ -290,6 +291,7 @@ pub impl PerpetualsConfigImpl of PerpetualsConfigTrait {
insurance_fund_position_owner_public_key: operator.key_pair.public_key,
forced_action_timelock: FORCED_ACTION_TIMELOCK,
premium_cost: PREMIUM_COST,
max_interest_rate_per_sec: MAX_INTEREST_RATE_PER_SEC,
}
}
}
Expand All @@ -311,6 +313,7 @@ impl PerpetualsContractStateImpl of Deployable<PerpetualsConfig, ContractAddress
self.insurance_fund_position_owner_public_key.serialize(ref calldata);
self.forced_action_timelock.serialize(ref calldata);
self.premium_cost.serialize(ref calldata);
self.max_interest_rate_per_sec.serialize(ref calldata);

let perpetuals_contract = snforge_std::declare("Core").unwrap().contract_class();
let (address, _) = perpetuals_contract.deploy(@calldata).unwrap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ use crate::core::components::external_components::interface::{
EXTERNAL_COMPONENT_WITHDRAWALS, IExternalComponents, IExternalComponentsDispatcher,
IExternalComponentsDispatcherTrait,
};
use super::constants::{FORCED_ACTION_TIMELOCK, PREMIUM_COST};
use super::constants::{FORCED_ACTION_TIMELOCK, MAX_INTEREST_RATE_PER_SEC, PREMIUM_COST};

/// The `User` struct represents a user corresponding to a position in the state of the Core
/// contract.
Expand Down Expand Up @@ -148,6 +148,7 @@ pub struct PerpetualsInitConfig {
pub insurance_fund_position_owner_public_key: felt252,
pub forced_action_timelock: u64,
pub premium_cost: u64,
pub max_interest_rate_per_sec: u32,
pub collateral_cfg: CollateralCfg,
pub synthetic_cfg: SyntheticCfg,
pub vault_share_cfg: VaultCollateralCfg,
Expand All @@ -171,6 +172,7 @@ pub impl CoreImpl of CoreTrait {
self.insurance_fund_position_owner_public_key.serialize(ref calldata);
self.forced_action_timelock.serialize(ref calldata);
self.premium_cost.serialize(ref calldata);
self.max_interest_rate_per_sec.serialize(ref calldata);

let core_contract = snforge_std::declare("Core").unwrap().contract_class();
let (core_contract_address, _) = core_contract.deploy(@calldata).unwrap();
Expand Down Expand Up @@ -223,6 +225,7 @@ impl PerpetualsInitConfigDefault of Default<PerpetualsInitConfig> {
insurance_fund_position_owner_public_key: OPERATOR_PUBLIC_KEY(),
forced_action_timelock: FORCED_ACTION_TIMELOCK,
premium_cost: PREMIUM_COST,
max_interest_rate_per_sec: MAX_INTEREST_RATE_PER_SEC,
collateral_cfg: CollateralCfg {
token_cfg: TokenConfig {
name: COLLATERAL_NAME(),
Expand Down Expand Up @@ -720,6 +723,7 @@ pub fn initialized_contract_state(
insurance_fund_position_owner_public_key: OPERATOR_PUBLIC_KEY(),
forced_action_timelock: FORCED_ACTION_TIMELOCK,
premium_cost: PREMIUM_COST,
max_interest_rate_per_sec: MAX_INTEREST_RATE_PER_SEC,
);
state
}
Expand Down