From 85c6d6c0ecbfbf2a0a164167a577b111327a54ec Mon Sep 17 00:00:00 2001 From: EmperorOrokuSaki Date: Wed, 17 Jul 2024 17:54:14 +0400 Subject: [PATCH] feat: add recharging cycles impl --- ir_manager/src/api.rs | 1 - ir_manager/src/canister.rs | 10 +-- ir_manager/src/charger.rs | 159 +++++++++++++++++++++++-------------- ir_manager/src/state.rs | 5 +- ir_manager/src/timers.rs | 22 ++++- ir_manager/src/types.rs | 132 ++++++++++++++++++++++++++++++ ir_manager/src/utils.rs | 62 ++++++++++++++- 7 files changed, 316 insertions(+), 75 deletions(-) diff --git a/ir_manager/src/api.rs b/ir_manager/src/api.rs index baba97c..aae69b6 100644 --- a/ir_manager/src/api.rs +++ b/ir_manager/src/api.rs @@ -3,7 +3,6 @@ use std::str::FromStr; use alloy_primitives::U256; use ic_exports::ic_kit::ic::time; -use crate::gas::estimate_transaction_fees; use crate::process::LiquityProcess; use crate::types::*; use crate::utils::{lock, unlock}; diff --git a/ir_manager/src/canister.rs b/ir_manager/src/canister.rs index 6ac6695..811b83d 100644 --- a/ir_manager/src/canister.rs +++ b/ir_manager/src/canister.rs @@ -1,11 +1,9 @@ use crate::{ - evm_rpc::Service, - state::*, - types::{DerivationPath, InitArgs, StrategyData, StrategyQueryData}, + charger::check_threshold, evm_rpc::Service, state::*, types::{DerivationPath, InitArgs, ManagerError, StrategyData, StrategyQueryData, SwapResponse}, utils::{fetch_cketh_balance, fetch_ether_cycles_rate} }; use alloy_primitives::U256; use ic_canister::{generate_idl, init, query, update, Canister, Idl, PreUpdate}; -use ic_exports::{candid::Principal, ic_kit::ic::time}; +use ic_exports::{candid::Principal, ic_cdk::{api::call::msg_cycles_available, caller}, ic_kit::ic::time}; use std::{collections::HashMap, str::FromStr}; #[derive(Canister)] @@ -75,8 +73,10 @@ impl IrManager { } #[update] - pub async fn swap_cketh(&self) { + pub async fn swap_cketh(&self) -> Result { // lock / unlock based on the current cycles balance of the canister + check_threshold().await?; + transfer_cketh(caller()).await? } pub fn idl() -> Idl { diff --git a/ir_manager/src/charger.rs b/ir_manager/src/charger.rs index 1c71bae..fad8461 100644 --- a/ir_manager/src/charger.rs +++ b/ir_manager/src/charger.rs @@ -2,77 +2,59 @@ use std::{str::FromStr, time::Duration}; use alloy_primitives::{Bytes, FixedBytes, U256}; use alloy_sol_types::SolCall; +use candid::Principal; use ic_exports::{ candid::Nat, ic_cdk::{ - api::{self, call}, + api::{ + self, + call::{self, msg_cycles_accept, msg_cycles_available}, + canister_balance, canister_balance128, + }, call, }, ic_cdk_timers::set_timer, - ic_kit::ic::{self, id}, + ic_kit::{ + ic::{self, id}, + CallResult, + }, +}; +use icrc_ledger_types::icrc1::{ + account::Account, + transfer::{TransferArg, TransferError}, }; -use icrc_ledger_types::icrc1::account::Account; use serde_json::json; use crate::{ evm_rpc::{RpcService, Service}, signer::get_canister_public_key, state::{ - CKETH_HELPER, CKETH_LEDGER, ETHER_RECHARGE_VALUE, RPC_CANISTER, RPC_URL, STRATEGY_DATA, + CKETH_HELPER, CKETH_LEDGER, CKETH_THRESHOLD, CYCLES_THRESHOLD, ETHER_RECHARGE_VALUE, + RPC_CANISTER, RPC_URL, STRATEGY_DATA, + }, + types::{depositCall, depositReturn, DerivationPath, ManagerError, StrategyData, SwapResponse}, + utils::{ + decode_request_response, decode_response, fetch_cketh_balance, fetch_ether_cycles_rate, + rpc_provider, send_raw_transaction, }, - types::{depositCall, depositReturn, DerivationPath, ManagerError, StrategyData}, - utils::{decode_request_response, rpc_provider, send_raw_transaction}, }; -pub async fn recharge() { - // The canister cycles balance has fallen below threshold - - // Deposit ether from one of the EOAs that has enough balance - ether_deposit().await; - - // Set a one-off timer for the next 20 minutes (the time cketh takes to load balance on the ic side) - set_timer(Duration::from_secs(1200), || { - ic::spawn(async { - let _ = resume_recharging().await; - }) - }); - // Burn cketh for cycles and recharge -} - -async fn resume_recharging() { - let cketh_ledger = CKETH_LEDGER.with(|ledger| ledger.borrow().clone()); - let account = Account { - owner: id(), - subaccount: None, - }; - - let (balance,): (Nat,) = call(cketh_ledger, "icrc1_balance_of", (account,)) - .await - .unwrap(); - - // Todo: check if the balance matches the deposit value minus fee +pub async fn check_threshold() -> Result<(), ManagerError> { + let threshold = CYCLES_THRESHOLD.get(); + if canister_balance() <= threshold { + return Ok(()); + } + Err(ManagerError::CyclesBalanceAboveRechargingThreshold) } -async fn fetch_balance(rpc_canister: &Service, rpc_url: &str, pk: String) -> U256 { - let rpc: RpcService = rpc_provider(rpc_url); - let json_args = json!({ - "id": 1, - "jsonrpc": "2.0", - "params": [ - pk, - "latest" - ], - "method": "eth_getBalance" - }) - .to_string(); - let request_response = rpc_canister.request(rpc, json_args, 50000, 10000000).await; - - let decoded_hex = decode_request_response(request_response).unwrap(); - let mut padded = [0u8; 32]; - let start = 32 - decoded_hex.len(); - padded[start..].copy_from_slice(&decoded_hex); - - U256::from_be_bytes(padded) +pub async fn recharge_cketh() -> Result<(), ManagerError> { + let current_balance = fetch_cketh_balance().await?; + let cketh_threshold = CKETH_THRESHOLD.with(|threshold| threshold.borrow().clone()); + if current_balance < cketh_threshold { + // Deposit ether from one of the EOAs that has enough balance + return ether_deposit().await; + } + Ok(()) } async fn ether_deposit() -> Result<(), ManagerError> { @@ -103,7 +85,7 @@ async fn ether_deposit() -> Result<(), ManagerError> { let transaction_data = deposit_call.abi_encode(); // todo: fetch the cycles with estimation - let submission_result = send_raw_transaction( + send_raw_transaction( cketh_helper, transaction_data, ether_value, @@ -113,13 +95,68 @@ async fn ether_deposit() -> Result<(), ManagerError> { &rpc_url, 100000000, ) - .await; + .await + .map(|_| Ok(())) + .unwrap_or_else(|e| Err(e)) +} - let rpc_canister_response = rpc_canister - .request(rpc, json_data, 500000, 10_000_000_000) - .await; +async fn fetch_balance(rpc_canister: &Service, rpc_url: &str, pk: String) -> U256 { + let rpc: RpcService = rpc_provider(rpc_url); + let json_args = json!({ + "id": 1, + "jsonrpc": "2.0", + "params": [ + pk, + "latest" + ], + "method": "eth_getBalance" + }) + .to_string(); + let request_response = rpc_canister.request(rpc, json_args, 50000, 10000000).await; + + let decoded_hex = decode_request_response(request_response).unwrap(); + let mut padded = [0u8; 32]; + let start = 32 - decoded_hex.len(); + padded[start..].copy_from_slice(&decoded_hex); + + U256::from_be_bytes(padded) +} - decode_response::(rpc_canister_response) - .map(|data| Ok(data)) - .unwrap_or_else(|e| Err(e)) +pub async fn transfer_cketh(receiver: Principal) -> Result { + // todo: account for the fee + let rate = fetch_ether_cycles_rate().await?; + let attached_cycles = msg_cycles_available(); + let maximum_returned_ether_amount = attached_cycles * rate; + + // first check if the balance permits the max transfer amount + let balance = fetch_cketh_balance().await?; + // second calculate the amount to transfer and accept cycles first + let (transfer_amount, cycles_to_accept) = if balance > maximum_returned_ether_amount { + (maximum_returned_ether_amount, attached_cycles) + } else { + (balance, balance / rate) + }; + msg_cycles_accept(cycles_to_accept); + // third send the cketh to the user + let ledger_principal = CKETH_LEDGER.with(|ledger| ledger.borrow().clone()); + + let args = TransferArg { + from_subaccount: None, + to: receiver.into(), + fee: todo!(), + created_at_time: None, + memo: None, + amount: transfer_amount, + }; + + let call_response: CallResult<(Result,)> = + call(ledger_principal, "icrc1_transfer", (args,)).await; + + match call_response { + Ok(response) => Ok(SwapResponse { + accepted_cycles: cycles_to_accept, + returning_ether: transfer_amount, + }), + Err(err) => Err(ManagerError::Custom(err.1)), + } } diff --git a/ir_manager/src/state.rs b/ir_manager/src/state.rs index bd175b2..0d6e8c3 100644 --- a/ir_manager/src/state.rs +++ b/ir_manager/src/state.rs @@ -1,9 +1,10 @@ use std::{ cell::{Cell, RefCell}, - collections::HashMap, + collections::HashMap, str::FromStr, }; use alloy_primitives::U256; +use candid::Nat; use ic_exports::candid::Principal; use crate::{evm_rpc::Service, types::StrategyData}; @@ -22,5 +23,7 @@ thread_local! { pub static CKETH_HELPER: RefCell = RefCell::new("0x7574eB42cA208A4f6960ECCAfDF186D627dCC175".to_string()); pub static CKETH_LEDGER: RefCell = RefCell::new(Principal::from_text("ss2fx-dyaaa-aaaar-qacoq-cai").unwrap()); pub static ETHER_RECHARGE_VALUE: RefCell = RefCell::new(U256::from(30000000000000000)); // 0.03 ETH in WEI + pub static CKETH_THRESHOLD: RefCell = RefCell::new(Nat::from_str("30000000000000000").unwrap()); // 0.03 ETH in WEI pub static MAX_RETRY_ATTEMPTS: Cell = Cell::new(3); + pub static EXCHANGE_RATE_CANISTER: RefCell = RefCell::new(Principal::from_text("uf6dk-hyaaa-aaaaq-qaaaq-cai").unwrap()); } diff --git a/ir_manager/src/timers.rs b/ir_manager/src/timers.rs index 0a378e1..f22efa6 100644 --- a/ir_manager/src/timers.rs +++ b/ir_manager/src/timers.rs @@ -6,10 +6,7 @@ use ic_exports::{ }; use crate::{ - api::execute_strategy, - state::{MAX_RETRY_ATTEMPTS, STRATEGY_DATA}, - types::StrategyData, - utils::{retry, set_public_keys}, + api::execute_strategy, charger::recharge_cketh, state::{MAX_RETRY_ATTEMPTS, STRATEGY_DATA}, types::StrategyData, utils::{retry, set_public_keys} }; pub fn start_timers() { @@ -24,6 +21,7 @@ pub fn start_timers() { let max_retry_attempts = MAX_RETRY_ATTEMPTS.with(|max_value| max_value.get()); + // STRATEGY TIMER | EVERY 1 HOUR for (key, strategy) in strategies { set_timer_interval(Duration::from_secs(3600), move || { spawn(async { @@ -40,4 +38,20 @@ pub fn start_timers() { }); }); } + + // CKETH RECHARGER | EVERY 24 HOURS + set_timer_interval(Duration::from_secs(86_400), move || { + spawn(async { + for _ in 0..=max_retry_attempts { + let result = match recharge_cketh().await { + Ok(()) => Ok(()), + Err(error) => recharge_cketh().await, + }; + + if result.is_ok() { + break; + } + } + }); + }); } diff --git a/ir_manager/src/types.rs b/ir_manager/src/types.rs index f2b64c0..2a0a1d9 100644 --- a/ir_manager/src/types.rs +++ b/ir_manager/src/types.rs @@ -1,6 +1,7 @@ use alloy_primitives::U256; use alloy_sol_types::sol; use candid::{CandidType, Principal}; +use serde::{Deserialize, Serialize}; use crate::evm_rpc::RpcError; @@ -11,6 +12,7 @@ pub enum ManagerError { DecodingError(String), Locked, Custom(String), + CyclesBalanceAboveRechargingThreshold } pub type DerivationPath = Vec>; @@ -66,6 +68,136 @@ pub struct InitArgs { pub strategies: Vec, } + +/// The enum defining the different asset classes. +#[derive(CandidType, Clone, Debug, PartialEq, Deserialize)] +pub enum AssetClass { + /// The cryptocurrency asset class. + Cryptocurrency, + /// The fiat currency asset class. + FiatCurrency, +} + +/// Exchange rates are derived for pairs of assets captured in this struct. +#[derive(CandidType, Clone, Debug, PartialEq, Deserialize)] +pub struct Asset { + /// The symbol/code of the asset. + pub symbol: String, + /// The asset class. + pub class: AssetClass, +} + +/// Exchange Rate Canister's Fetch API Type +#[derive(CandidType, Clone, Debug, Deserialize)] +pub struct GetExchangeRateRequest { + /// The asset to be used as the resulting asset. For example, using + /// ICP/USD, ICP would be the base asset. + pub base_asset: Asset, + /// The asset to be used as the starting asset. For example, using + /// ICP/USD, USD would be the quote asset. + pub quote_asset: Asset, + /// An optional parameter used to find a rate at a specific time. + pub timestamp: Option, +} + + +/// Metadata information to give background on how the rate was determined. +#[derive(CandidType, Clone, Debug, Deserialize, PartialEq)] +pub struct ExchangeRateMetadata { + /// The scaling factor for the exchange rate and the standard deviation. + pub decimals: u32, + /// The number of queried exchanges for the base asset. + pub base_asset_num_queried_sources: usize, + /// The number of rates successfully received from the queried sources for the base asset. + pub base_asset_num_received_rates: usize, + /// The number of queried exchanges for the quote asset. + pub quote_asset_num_queried_sources: usize, + /// The number of rates successfully received from the queried sources for the quote asset. + pub quote_asset_num_received_rates: usize, + /// The standard deviation of the received rates, scaled by the factor `10^decimals`. + pub standard_deviation: u64, + /// The timestamp of the beginning of the day for which the forex rates were retrieved, if any. + pub forex_timestamp: Option, +} + +/// When a rate is determined, this struct is used to present the information +/// to the user. +#[derive(CandidType, Clone, Debug, Deserialize, PartialEq)] +pub struct ExchangeRate { + /// The base asset. + pub base_asset: Asset, + /// The quote asset. + pub quote_asset: Asset, + /// The timestamp associated with the returned rate. + pub timestamp: u64, + /// The median rate from the received rates, scaled by the factor `10^decimals` in the metadata. + pub rate: u64, + /// Metadata providing additional information about the exchange rate calculation. + pub metadata: ExchangeRateMetadata, +} + +/// Returned to the user when something goes wrong retrieving the exchange rate. +#[derive(CandidType, Clone, Debug, Deserialize)] +pub enum ExchangeRateError { + /// Returned when the canister receives a call from the anonymous principal. + AnonymousPrincipalNotAllowed, + /// Returned when the canister is in process of retrieving a rate from an exchange. + Pending, + /// Returned when the base asset rates are not found from the exchanges HTTP outcalls. + CryptoBaseAssetNotFound, + /// Returned when the quote asset rates are not found from the exchanges HTTP outcalls. + CryptoQuoteAssetNotFound, + /// Returned when the stablecoin rates are not found from the exchanges HTTP outcalls needed for computing a crypto/fiat pair. + StablecoinRateNotFound, + /// Returned when there are not enough stablecoin rates to determine the forex/USDT rate. + StablecoinRateTooFewRates, + /// Returned when the stablecoin rate is zero. + StablecoinRateZeroRate, + /// Returned when a rate for the provided forex asset could not be found at the provided timestamp. + ForexInvalidTimestamp, + /// Returned when the forex base asset is found. + ForexBaseAssetNotFound, + /// Returned when the forex quote asset is found. + ForexQuoteAssetNotFound, + /// Returned when neither forex asset is found. + ForexAssetsNotFound, + /// Returned when the caller is not the CMC and there are too many active requests. + RateLimited, + /// Returned when the caller does not send enough cycles to make a request. + NotEnoughCycles, + /// Returned if too many collected rates deviate substantially. + InconsistentRatesReceived, + /// Until candid bug is fixed, new errors after launch will be placed here. + Other(OtherError), +} + +/// Used to provide details for the [ExchangeRateError::Other] variant field. +#[derive(CandidType, Clone, Debug, Deserialize)] +pub struct OtherError { + /// The identifier for the error that occurred. + pub code: u32, + /// A description of the error that occurred. + pub description: String, +} + +#[derive(CandidType, Debug, Serialize, Deserialize)] +pub struct SwapResponse { + pub accepted_cycles: u64, + pub returning_ether: u64 +} + +/// Short-hand for returning the result of a `get_exchange_rate` request. +pub type GetExchangeRateResult = Result; + +pub type Subaccount = [u8; 32]; + +// Account representation of ledgers supporting the ICRC1 standard +#[derive(Serialize, CandidType, Deserialize, Clone, Debug, Copy)] +pub struct Account { + pub owner: Principal, + pub subaccount: Option, +} + sol!( // Liquity types struct CombinedTroveData { diff --git a/ir_manager/src/utils.rs b/ir_manager/src/utils.rs index 1f28ffc..19baaf0 100644 --- a/ir_manager/src/utils.rs +++ b/ir_manager/src/utils.rs @@ -2,12 +2,14 @@ use std::str::FromStr; use alloy_primitives::{Address, Bytes, TxKind, U256}; use alloy_sol_types::SolCall; +use candid::Nat; use ic_exports::ic_cdk::{ + self, api::{ call::CallResult, management_canister::ecdsa::{EcdsaCurve, EcdsaKeyId}, }, - print, + call, id, print, }; use serde_json::json; @@ -20,10 +22,64 @@ use crate::{ signer::{ get_canister_public_key, pubkey_bytes_to_address, sign_eip1559_transaction, SignRequest, }, - state::STRATEGY_DATA, - types::{DerivationPath, ManagerError, StrategyData}, + state::{CKETH_LEDGER, EXCHANGE_RATE_CANISTER, STRATEGY_DATA}, + types::{ + Account, Asset, AssetClass, DerivationPath, GetExchangeRateRequest, GetExchangeRateResult, + ManagerError, StrategyData, + }, }; +pub async fn fetch_cketh_balance() -> Result { + let ledger_principal = CKETH_LEDGER.with(|ledger| ledger.borrow().clone()); + let args = Account { + owner: id(), + subaccount: None, + }; + + let call_response: CallResult<(Nat,)> = + call(ledger_principal, "icrc1_balance_of", (args,)).await; + + match call_response { + Ok(response) => Ok(response.0), + Err(err) => Err(ManagerError::Custom(err.1)), + } +} + +pub async fn fetch_ether_cycles_rate() -> Result { + let exchange_rate_canister = + EXCHANGE_RATE_CANISTER.with(|principal_id| principal_id.borrow().clone()); + let fetch_args = GetExchangeRateRequest { + base_asset: Asset { + symbol: "ETH".to_string(), + class: AssetClass::Cryptocurrency, + }, + quote_asset: Asset { + symbol: "CXDR".to_string(), + class: AssetClass::FiatCurrency, + }, + timestamp: None, + }; + + let fetch_result: CallResult<(GetExchangeRateResult,)> = + ic_cdk::api::call::call_with_payment128( + exchange_rate_canister, + "get_exchange_rate", + (fetch_args,), + 1_000_000_000, + ) + .await; + match fetch_result { + Ok(result) => match result { + (Ok(response),) => Ok(response.rate), + (Err(err),) => Err(ManagerError::Custom(format!( + "Error from the exchange rate canister: {:#?}", + err + ))), + }, + Err(err) => Err(ManagerError::Custom(err.1)), + } +} + /// Logs the error, and sets off a zero second timer to re-run pub async fn retry( key: u32,