From a4a6cdb55b871dc8e94ecf8ed8e39f8e9b45f19f Mon Sep 17 00:00:00 2001 From: Jordy Romuald <87231934+JordyRo1@users.noreply.github.com> Date: Fri, 6 Dec 2024 18:10:49 +0100 Subject: [PATCH] feat: conversion rate price for tokenized vaults (#132) * feat: conversion rate pricing impl * feat: mock erc4626 + tests * feat: `delete` operation * feat: additional tests + corrections * fix: revert decimals --- pragma-oracle/src/entry/entry.cairo | 5 + pragma-oracle/src/entry/structs.cairo | 6 +- pragma-oracle/src/erc4626/erc4626.cairo | 186 +++++++++++++++++++++ pragma-oracle/src/lib.cairo | 3 + pragma-oracle/src/oracle/oracle.cairo | 75 ++++++++- pragma-oracle/src/tests/test_oracle.cairo | 189 ++++++++++++++++++++++ 6 files changed, 458 insertions(+), 6 deletions(-) create mode 100644 pragma-oracle/src/erc4626/erc4626.cairo diff --git a/pragma-oracle/src/entry/entry.cairo b/pragma-oracle/src/entry/entry.cairo index d59b8598..c6309fe0 100644 --- a/pragma-oracle/src/entry/entry.cairo +++ b/pragma-oracle/src/entry/entry.cairo @@ -44,6 +44,11 @@ mod Entry { let value: u128 = entries_mean(entries); value }, + AggregationMode::ConversionRate => { + panic_with_felt252('No agg for conversion rate'); + // No aggregation needed for conversion rate + 0 + }, AggregationMode::Error(()) => { panic_with_felt252('Wrong aggregation mode'); 0 diff --git a/pragma-oracle/src/entry/structs.cairo b/pragma-oracle/src/entry/structs.cairo index 35d99e9a..07728646 100644 --- a/pragma-oracle/src/entry/structs.cairo +++ b/pragma-oracle/src/entry/structs.cairo @@ -180,10 +180,11 @@ struct PragmaPricesResponse { expiration_timestamp: Option, } -#[derive(Serde, Drop, Copy, starknet::Store)] +#[derive(Serde, Drop, Copy, starknet::Store, PartialEq)] enum AggregationMode { Median: (), Mean: (), + ConversionRate, Error: (), } @@ -306,6 +307,7 @@ impl AggregationModeIntoU8 of TryInto { match self { AggregationMode::Median(()) => Option::Some(0_u8), AggregationMode::Mean(()) => Option::Some(1_u8), + AggregationMode::ConversionRate => Option::Some(2_u8), AggregationMode::Error(()) => Option::None(()), } } @@ -316,6 +318,8 @@ impl u8IntoAggregationMode of Into { AggregationMode::Median(()) } else if self == 1_u8 { AggregationMode::Mean(()) + } else if self == 2_u8 { + AggregationMode::ConversionRate } else { AggregationMode::Error(()) } diff --git a/pragma-oracle/src/erc4626/erc4626.cairo b/pragma-oracle/src/erc4626/erc4626.cairo new file mode 100644 index 00000000..5bbfee96 --- /dev/null +++ b/pragma-oracle/src/erc4626/erc4626.cairo @@ -0,0 +1,186 @@ +// Forked from https://github.com/0xEniotna/ERC4626/blob/main/src/erc4626/interface.cairo +// Mock contract to be used for TESTING PURPOSE ONLY +use starknet::ContractAddress; + +#[starknet::interface] +trait IERC4626 { + // Metadata (match implementation) + fn name(self: @TContractState) -> felt252; + fn symbol(self: @TContractState) -> felt252; + fn decimals(self: @TContractState) -> u8; + + // ERC20-like methods (match implementation) + fn total_supply(self: @TContractState) -> u256; + fn balance_of(self: @TContractState, account: ContractAddress) -> u256; + fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256; + fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; + fn transfer_from( + ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256 + ) -> bool; + fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool; + + // Remove camelCase methods as they're not in the implementation + + // Additional ERC4626 methods (match implementation) + fn asset(self: @TContractState) -> ContractAddress; + fn total_assets(self: @TContractState) -> u256; + fn convert_to_shares(self: @TContractState, assets: u256) -> u256; + fn convert_to_assets(self: @TContractState, shares: u256) -> u256; + fn max_deposit(self: @TContractState, receiver: ContractAddress) -> u256; + fn preview_deposit(self: @TContractState, assets: u256) -> u256; + fn deposit(ref self: TContractState, assets: u256, receiver: ContractAddress) -> u256; + fn max_mint(self: @TContractState, receiver: ContractAddress) -> u256; + fn preview_mint(self: @TContractState, shares: u256) -> u256; + fn mint(ref self: TContractState, shares: u256, receiver: ContractAddress) -> u256; + fn max_withdraw(self: @TContractState, owner: ContractAddress) -> u256; + fn preview_withdraw(self: @TContractState, assets: u256) -> u256; + fn withdraw( + ref self: TContractState, assets: u256, receiver: ContractAddress, owner: ContractAddress + ) -> u256; + fn max_redeem(self: @TContractState, owner: ContractAddress) -> u256; + fn preview_redeem(self: @TContractState, shares: u256) -> u256; + fn redeem( + ref self: TContractState, shares: u256, receiver: ContractAddress, owner: ContractAddress + ) -> u256; +} + +#[starknet::contract] +mod ERC4626 { + use super::IERC4626; + use starknet::get_caller_address; + use starknet::get_contract_address; + use starknet::ContractAddress; + use starknet::contract_address::ContractAddressZeroable; + use zeroable::Zeroable; + use traits::Into; + use traits::TryInto; + use option::OptionTrait; + use integer::BoundedInt; + use debug::PrintTrait; + + #[storage] + struct Storage {} + + #[external(v0)] + impl ERC4626Impl of IERC4626 { + //////////////////////////////// + // ERC20 implementation + //////////////////////////////// + + fn name(self: @ContractState) -> felt252 { + 0 + } + + fn symbol(self: @ContractState) -> felt252 { + 0 + } + + fn decimals(self: @ContractState) -> u8 { + 0 + } + + fn total_supply(self: @ContractState) -> u256 { + 0 + } + + fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { + 0 + } + + fn allowance( + self: @ContractState, owner: ContractAddress, spender: ContractAddress + ) -> u256 { + 0 + } + + fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { + true + } + + fn transfer_from( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u256 + ) -> bool { + true + } + + fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool { + true + } + + //////////////////////////////// + // ERC4626-specific implementation + //////////////////////////////// + + fn asset(self: @ContractState) -> ContractAddress { + 0.try_into().unwrap() + } + + fn total_assets(self: @ContractState) -> u256 { + 0 + } + + fn convert_to_shares(self: @ContractState, assets: u256) -> u256 { + 0 + } + + fn convert_to_assets(self: @ContractState, shares: u256) -> u256 { + 0 + } + + fn max_deposit(self: @ContractState, receiver: ContractAddress) -> u256 { + 0 + } + + fn preview_deposit(self: @ContractState, assets: u256) -> u256 { + 0 + } + + fn deposit(ref self: ContractState, assets: u256, receiver: ContractAddress) -> u256 { + 0 + } + + fn max_mint(self: @ContractState, receiver: ContractAddress) -> u256 { + BoundedInt::::max() + } + + fn preview_mint(self: @ContractState, shares: u256) -> u256 { + // TESTING + 1002465544733197129 + } + + fn mint(ref self: ContractState, shares: u256, receiver: ContractAddress) -> u256 { + 0 + } + + fn max_withdraw(self: @ContractState, owner: ContractAddress) -> u256 { + 0 + } + + fn preview_withdraw(self: @ContractState, assets: u256) -> u256 { + 0 + } + + fn withdraw( + ref self: ContractState, assets: u256, receiver: ContractAddress, owner: ContractAddress + ) -> u256 { + 0 + } + + fn max_redeem(self: @ContractState, owner: ContractAddress) -> u256 { + 0 + } + + fn preview_redeem(self: @ContractState, shares: u256) -> u256 { + 0 + } + + fn redeem( + ref self: ContractState, shares: u256, receiver: ContractAddress, owner: ContractAddress + ) -> u256 { + 0 + } + } +} diff --git a/pragma-oracle/src/lib.cairo b/pragma-oracle/src/lib.cairo index c719521d..e2a1c35e 100644 --- a/pragma-oracle/src/lib.cairo +++ b/pragma-oracle/src/lib.cairo @@ -9,6 +9,9 @@ mod utils { mod bitwise; mod strings; } +mod erc4626 { + mod erc4626; +} mod operations { mod sorting { mod merge_sort; diff --git a/pragma-oracle/src/oracle/oracle.cairo b/pragma-oracle/src/oracle/oracle.cairo index 1f238b11..d20dc3e1 100644 --- a/pragma-oracle/src/oracle/oracle.cairo +++ b/pragma-oracle/src/oracle/oracle.cairo @@ -90,6 +90,7 @@ trait IOracleABI { fn add_currency(ref self: TContractState, new_currency: Currency); fn update_currency(ref self: TContractState, currency_id: felt252, currency: Currency); fn get_currency(self: @TContractState, currency_id: felt252) -> Currency; + fn get_tokenized_vaults(self: @TContractState, token: felt252) -> ContractAddress; fn update_pair(ref self: TContractState, pair_id: felt252, pair: Pair); fn add_pair(ref self: TContractState, new_pair: Pair); fn get_pair(self: @TContractState, pair_id: felt252) -> Pair; @@ -101,6 +102,9 @@ trait IOracleABI { ); fn remove_source(ref self: TContractState, source: felt252, data_type: DataType) -> bool; fn set_sources_threshold(ref self: TContractState, threshold: u32); + fn register_tokenized_vault( + ref self: TContractState, token: felt252, token_address: ContractAddress + ); fn upgrade(ref self: TContractState, impl_hash: ClassHash); } @@ -192,11 +196,13 @@ mod Oracle { IPublisherRegistryABIDispatcher, IPublisherRegistryABIDispatcherTrait }; use starknet::{get_block_timestamp, Felt252TryIntoContractAddress}; + use pragma::erc4626::erc4626::{IERC4626Dispatcher, IERC4626DispatcherTrait}; use cmp::{max, min}; use option::OptionTrait; const BACKWARD_TIMESTAMP_BUFFER: u64 = 3600; // 1 hour const FORWARD_TIMESTAMP_BUFFER: u64 = 420; // 7 minutes + const ONE_E18: u256 = 1000000000000000000; #[storage] @@ -235,6 +241,8 @@ mod Oracle { //oracle_checkpoint_index, legacyMap between (pair_id, (SPOT/FUTURES/OPTIONS), expiration_timestamp (0 for SPOT)) and the index of the last checkpoint oracle_checkpoint_index: LegacyMap::<(felt252, felt252, u64, u8), u64>, oracle_sources_threshold_storage: u32, + // registry containing registered tokenized vaults and the corresponding address + tokenized_vault: LegacyMap<(felt252, felt252), ContractAddress> } @@ -514,18 +522,57 @@ mod Oracle { } // @notice aggregate all the entries for a given data type, with a given aggregation mode + // @notice The aggregation mode `ConversionRate` is set for tokenized vault, with pricing made based on STRK price and associated conversion rate fetched + // @notice onchain. // @param data_type: an enum of DataType (e.g : DataType::SpotEntry(ASSET_ID) or DataType::FutureEntry((ASSSET_ID, expiration_timestamp))) // @param aggregation_mode: the aggregation method to be used (e.g. AggregationMode::Median(())) // @returns a PragmaPricesResponse, a structure providing the main information for an asset (see entry/structs for details) fn get_data( self: @ContractState, data_type: DataType, aggregation_mode: AggregationMode ) -> PragmaPricesResponse { - let sources = IOracleABI::get_all_sources(self, data_type); - let prices_response: PragmaPricesResponse = IOracleABI::get_data_for_sources( - self, data_type, aggregation_mode, sources - ); + if aggregation_mode == AggregationMode::ConversionRate { + // Query median for STRK/USD + let sources = IOracleABI::get_all_sources(self, DataType::SpotEntry('STRK/USD')); + let response: PragmaPricesResponse = IOracleABI::get_data_for_sources( + self, DataType::SpotEntry('STRK/USD'), AggregationMode::Median(()), sources + ); - prices_response + // Extract base asset + let asset: felt252 = match data_type { + DataType::SpotEntry(asset) => asset, + DataType::FutureEntry(_) => panic_with_felt252('Set only for Spot entries'), + DataType::GenericEntry(_) => panic_with_felt252('Set only for Spot entries'), + }; + + // Get base currency and pool + let base_asset: felt252 = self.get_pair(asset).base_currency_id; + assert(base_asset != 0, 'Asset not registered'); + let pool_address: ContractAddress = self.tokenized_vault.read((base_asset, 'STRK')); + assert( + pool_address != starknet::contract_address_const::<0>(), + 'No pool address for given token' + ); + let pool = IERC4626Dispatcher { contract_address: pool_address }; + + // Compute adjusted price + // `preview_mint` takes as argument an e18 and returns an e18 + // We operate under u256 to avoid overflow + let price: u256 = response.price.into() * pool.preview_mint(ONE_E18) / ONE_E18; + + // The conversion should not fail because we scaled the price to response.decimals + let converted_price: u128 = price.try_into().expect('Conversion should not fail'); + assert(converted_price != 0, 'Price conversion failed'); + PragmaPricesResponse { + price: converted_price, + decimals: response.decimals, + last_updated_timestamp: response.last_updated_timestamp, + num_sources_aggregated: response.num_sources_aggregated, + expiration_timestamp: response.expiration_timestamp + } + } else { + let sources = IOracleABI::get_all_sources(self, data_type); + IOracleABI::get_data_for_sources(self, data_type, aggregation_mode, sources) + } } // @notice aggregate all the entries for a given data type and given sources, with a given aggregation mode @@ -927,6 +974,10 @@ mod Oracle { Ownable::OwnableImpl::owner(@state) } + fn get_tokenized_vaults(self: @ContractState, token: felt252) -> ContractAddress { + self.tokenized_vault.read((token, 'STRK')) + } + // @notice retrieve the last checkpoint before a given timestamp // @param data_type: an enum of DataType (e.g : DataType::SpotEntry(ASSET_ID) or DataType::FutureEntry((ASSSET_ID, expiration_timestamp))) @@ -1771,6 +1822,20 @@ mod Oracle { } } + // @notice register a new tokenized vault into the regisry (priced with STRK) + // @dev Callable only by the owner + // @dev the token must be registered as currency and pair in the oracle registry + // @dev We reserve the owner of the contract the right to overwrite an existing token address + // @param token The token to register + // @param token_address Token address to register + fn register_tokenized_vault( + ref self: ContractState, token: felt252, token_address: ContractAddress + ) { + OracleInternal::assert_only_admin(); + assert(token != 0, 'Token cannot be 0'); + self.tokenized_vault.write((token, 'STRK'), token_address) + } + // @notice set a new checkpoint for a given data type and and aggregation mode // @param data_type: an enum of DataType (e.g : DataType::SpotEntry(ASSET_ID) or DataType::FutureEntry((ASSSET_ID, expiration_timestamp))) // @param aggregation_mode: the aggregation method to be used diff --git a/pragma-oracle/src/tests/test_oracle.cairo b/pragma-oracle/src/tests/test_oracle.cairo index c4082769..c45305b7 100644 --- a/pragma-oracle/src/tests/test_oracle.cairo +++ b/pragma-oracle/src/tests/test_oracle.cairo @@ -1,6 +1,7 @@ use array::{ArrayTrait, SpanTrait}; use option::OptionTrait; use result::ResultTrait; +use integer::BoundedInt; use starknet::ContractAddress; use pragma::entry::structs::{ BaseEntry, SpotEntry, Currency, Pair, DataType, PragmaPricesResponse, Checkpoint, @@ -11,6 +12,7 @@ use starknet::class_hash::class_hash_const; use traits::Into; use traits::TryInto; use pragma::oracle::oracle::Oracle; +use pragma::erc4626::erc4626::ERC4626; use pragma::oracle::oracle::{IOracleABIDispatcher, IOracleABIDispatcherTrait}; use pragma::publisher_registry::publisher_registry::{ IPublisherRegistryABIDispatcher, IPublisherRegistryABIDispatcherTrait @@ -319,6 +321,14 @@ fn setup() -> (IPublisherRegistryABIDispatcher, IOracleABIDispatcher) { (publisher_registry, oracle) } + +fn deploy_erc4626() -> ContractAddress { + let (erc4626_address, _) = deploy_syscall( + ERC4626::TEST_CLASS_HASH.try_into().unwrap(), 0, array![].span(), true + ) + .unwrap_syscall(); + return erc4626_address; +} #[test] #[available_gas(200000000000000)] fn test_get_decimals() { @@ -1371,3 +1381,182 @@ fn test_update_pair() { assert(pair.quote_currency_id == 111, 'wrong recorded pair'); assert(pair.base_currency_id == 12345, 'wrong recorded pair'); } + + +#[test] +#[available_gas(20000000000000)] +fn test_register_tokenized_vault() { + let (publisher_registry, oracle) = setup(); + let token: felt252 = 'xStrk'; + let token_address: ContractAddress = contract_address_const::<0x12345566>(); + let admin = contract_address_const::<0x123456789>(); + set_contract_address(admin); + oracle.register_tokenized_vault(token, token_address); + assert(oracle.get_tokenized_vaults(token) == token_address, 'Failed to register token'); +} + +#[test] +#[available_gas(20000000000000)] +fn test_delete_tokenized_vault() { + let (publisher_registry, oracle) = setup(); + let token: felt252 = 'xStrk'; + let token_address: ContractAddress = contract_address_const::<0x12345566>(); + let admin = contract_address_const::<0x123456789>(); + set_contract_address(admin); + oracle.register_tokenized_vault(token, token_address); + assert(oracle.get_tokenized_vaults(token) == token_address, 'Failed to register token'); + oracle.register_tokenized_vault(token, contract_address_const::<0>()); + assert( + oracle.get_tokenized_vaults(token) == contract_address_const::<0>(), + 'Failed to delete token' + ); +} + + +#[test] +#[available_gas(20000000000000)] +#[should_panic(expected: ('Admin: unauthorized', 'ENTRYPOINT_FAILED'))] +fn test_register_tokenized_vault_panics_if_not_owner() { + let (publisher_registry, oracle) = setup(); + let token: felt252 = 'xStrk'; + let token_address: ContractAddress = contract_address_const::<0x12345566>(); + set_contract_address(contract_address_const::<0x12>()); + oracle.register_tokenized_vault(token, token_address); +} + + +#[test] +#[available_gas(20000000000000)] +fn test_get_conversion_rate_price() { + let now = 100000; + let (publisher_registry, oracle) = setup(); + let admin = contract_address_const::<0x123456789>(); + set_contract_address(admin); + oracle + .add_currency( + Currency { + id: 'STRK', + decimals: 18, + is_abstract_currency: false, + starknet_address: 0.try_into().unwrap(), + ethereum_address: 0.try_into().unwrap(), + } + ); + oracle + .add_currency( + Currency { + id: 'xSTRK', + decimals: 18, + is_abstract_currency: false, + starknet_address: 0.try_into().unwrap(), + ethereum_address: 0.try_into().unwrap(), + } + ); + oracle.add_pair(Pair { id: 'STRK/USD', base_currency_id: 'STRK', quote_currency_id: 'USD', }); + oracle.add_pair(Pair { id: 'xSTRK/USD', base_currency_id: 'xSTRK', quote_currency_id: 'USD', }); + oracle + .publish_data( + PossibleEntries::Spot( + SpotEntry { + base: BaseEntry { timestamp: now, source: 2, publisher: 1 }, + pair_id: 'STRK/USD', + price: 68250000, + volume: 0 + } + ) + ); + let erc4626 = deploy_erc4626(); + oracle.register_tokenized_vault('xSTRK', erc4626); + let res = oracle.get_data(DataType::SpotEntry('xSTRK/USD'), AggregationMode::ConversionRate); + assert( + res.price == (68250000 * 1002465544733197129) / 1000000000000000000, 'Computation failed' + ); +} + +#[test] +#[should_panic(expected: ('No pool address for given token', 'ENTRYPOINT_FAILED'))] +#[available_gas(20000000000000)] +fn test_get_conversion_rate_price_fails_if_pool_address_not_given() { + let now = 100000; + let (publisher_registry, oracle) = setup(); + let admin = contract_address_const::<0x123456789>(); + set_contract_address(admin); + oracle + .add_currency( + Currency { + id: 'STRK', + decimals: 18, + is_abstract_currency: false, + starknet_address: 0.try_into().unwrap(), + ethereum_address: 0.try_into().unwrap(), + } + ); + oracle + .add_currency( + Currency { + id: 'xSTRK', + decimals: 18, + is_abstract_currency: false, + starknet_address: 0.try_into().unwrap(), + ethereum_address: 0.try_into().unwrap(), + } + ); + oracle.add_pair(Pair { id: 'STRK/USD', base_currency_id: 'STRK', quote_currency_id: 'USD', }); + oracle.add_pair(Pair { id: 'xSTRK/USD', base_currency_id: 'xSTRK', quote_currency_id: 'USD', }); + oracle + .publish_data( + PossibleEntries::Spot( + SpotEntry { + base: BaseEntry { timestamp: now, source: 2, publisher: 1 }, + pair_id: 'STRK/USD', + price: 68250000, + volume: 0 + } + ) + ); + let res = oracle.get_data(DataType::SpotEntry('xSTRK/USD'), AggregationMode::ConversionRate); +} + + +#[test] +#[should_panic(expected: ('Asset not registered', 'ENTRYPOINT_FAILED'))] +#[available_gas(20000000000000)] +fn test_get_conversion_rate_price_fails_if_asset_not_registered() { + let now = 100000; + let (publisher_registry, oracle) = setup(); + let admin = contract_address_const::<0x123456789>(); + set_contract_address(admin); + oracle + .add_currency( + Currency { + id: 'STRK', + decimals: 8, + is_abstract_currency: false, + starknet_address: 0.try_into().unwrap(), + ethereum_address: 0.try_into().unwrap(), + } + ); + oracle + .add_currency( + Currency { + id: 'xSTRK', + decimals: 8, + is_abstract_currency: false, + starknet_address: 0.try_into().unwrap(), + ethereum_address: 0.try_into().unwrap(), + } + ); + oracle.add_pair(Pair { id: 'STRK/USD', base_currency_id: 'STRK', quote_currency_id: 'USD', }); + oracle + .publish_data( + PossibleEntries::Spot( + SpotEntry { + base: BaseEntry { timestamp: now, source: 2, publisher: 1 }, + pair_id: 'STRK/USD', + price: 68250000, + volume: 0 + } + ) + ); + let res = oracle.get_data(DataType::SpotEntry('xSTRK/USD'), AggregationMode::ConversionRate); +}