From c5298130af5a42e7d593da94b54297781eb210cb Mon Sep 17 00:00:00 2001 From: Oleksandr Zahorodnyi Date: Tue, 24 Feb 2026 16:54:12 +0200 Subject: [PATCH] Implement active options positions feature --- crates/cli-client/src/cli/positions.rs | 350 +++++++++++++++++++++++-- crates/cli-client/src/cli/tables.rs | 24 +- 2 files changed, 355 insertions(+), 19 deletions(-) diff --git a/crates/cli-client/src/cli/positions.rs b/crates/cli-client/src/cli/positions.rs index 018068e..9703374 100644 --- a/crates/cli-client/src/cli/positions.rs +++ b/crates/cli-client/src/cli/positions.rs @@ -1,24 +1,69 @@ +use std::collections::{HashMap, HashSet}; + use crate::cli::Cli; use crate::cli::interactive::{ - EnrichedTokenEntry, GRANTOR_TOKEN_TAG, OPTION_TOKEN_TAG, TokenDisplay, format_asset_value_with_tag, - format_asset_with_tag, format_relative_time, format_settlement_asset, format_time_ago, + EnrichedTokenEntry, GRANTOR_TOKEN_TAG, OPTION_TOKEN_TAG, TokenDisplay, current_timestamp, + format_asset_value_with_tag, format_asset_with_tag, format_relative_time, format_settlement_asset, format_time_ago, get_grantor_tokens_from_wallet, get_option_tokens_from_wallet, truncate_with_ellipsis, }; -use crate::cli::tables::{display_collateral_table, display_token_table, display_user_token_table}; +use crate::cli::tables::{ + display_active_options_table, display_collateral_table, display_token_table, display_user_token_table, +}; use crate::config::Config; use crate::error::Error; use crate::metadata::ContractMetadata; - use crate::price_fetcher::{CoingeckoPriceFetcher, PriceFetcherError, fetch_btc_usd_price}; + use coin_store::{Store, UtxoEntry, UtxoFilter, UtxoQueryResult, UtxoStore}; use contracts::option_offer::{OPTION_OFFER_SOURCE, OptionOfferArguments, get_option_offer_address}; use contracts::options::{OPTION_SOURCE, OptionsArguments, get_options_address}; use contracts::sdk::taproot_pubkey_gen::TaprootPubkeyGen; use simplicityhl::elements::Address; +use simplicityhl::elements::AssetId; /// Result type for contract info queries: (metadata, arguments, `taproot_pubkey_gen`) type ContractInfoResult = Result, Vec, String)>, coin_store::StoreError>; +/// Aggregated state for a single options contract. +struct ContractState { + args: OptionsArguments, + address: Address, + user_options: u64, + user_grantors: u64, + locked_in_offers: u64, + total_collateral: u64, +} + +impl ContractState { + /// A contract is valid if it has not expired and has collateral locked. + fn is_valid(&self, now: i64) -> bool { + self.expiry_time() > now && self.total_collateral > 0 + } + + /// A contract is active if someone other than the user holds at least one token. + fn is_active(&self) -> bool { + self.try_is_active().unwrap_or(false) + } + + fn try_is_active(&self) -> Option { + let collateral_per_contract = self.args.collateral_per_contract(); + if collateral_per_contract == 0 { + return Some(false); + } + let total_issued = self.total_collateral / collateral_per_contract; + let user_share = self + .user_options + .checked_add(self.user_grantors)? + .checked_add(self.locked_in_offers)?; + let held_by_others = total_issued.checked_mul(2)?.checked_sub(user_share)?; + Some(held_by_others > 0) + } + + fn expiry_time(&self) -> i64 { + i64::from(self.args.expiry_time()) + } +} + impl Cli { #[allow(clippy::too_many_lines)] pub(crate) async fn run_positions(&self, config: Config) -> Result<(), Error> { @@ -33,21 +78,26 @@ impl Cli { .await .unwrap_or_else(|e| Err(PriceFetcherError::Internal(e.to_string()))); - let btc_price = match btc_result { - Ok(price) => format!("${price:.2}"), - Err(PriceFetcherError::RateLimit) => "Rate limit exceeded".to_string(), - Err(e) => { - eprintln!("Fetcher error: {e}"); - "Price fetcher service unavailable".to_string() - } - }; - - println!("Current btc price: {btc_price}"); - println!("-----------------------------"); + match btc_result { + Ok(price) => println!("BTC/USD: ${price:.2}"), + Err(PriceFetcherError::RateLimit) => eprintln!("BTC price unavailable: rate limit exceeded"), + Err(e) => eprintln!("BTC price unavailable: {e}"), + } println!(); let user_script_pubkey = wallet.signer().p2pk_address(config.network())?.script_pubkey(); + let option_tokens = get_option_tokens_from_wallet(&wallet, OPTION_SOURCE, &user_script_pubkey).await?; + let grantor_tokens = get_grantor_tokens_from_wallet(&wallet, OPTION_SOURCE, &user_script_pubkey).await?; + + let active_options_displays = + build_active_options_displays(&wallet, &option_tokens, &grantor_tokens, config.network()).await; + + println!("Your Active Options Positions:"); + println!("-----------------------------"); + display_active_options_table(&active_options_displays); + println!(); + let options_filter = UtxoFilter::new().source(OPTION_SOURCE); let options_results = <_ as UtxoStore>::query_utxos(wallet.store(), &[options_filter]).await?; let option_entries = extract_entries(options_results); @@ -59,9 +109,6 @@ impl Cli { display_collateral_table(&collateral_displays); println!(); - let option_tokens = get_option_tokens_from_wallet(&wallet, OPTION_SOURCE, &user_script_pubkey).await?; - let grantor_tokens = get_grantor_tokens_from_wallet(&wallet, OPTION_SOURCE, &user_script_pubkey).await?; - let user_token_displays = build_user_token_displays(&option_tokens, &grantor_tokens, config.network()); println!("Your Option/Grantor Tokens:"); @@ -167,6 +214,16 @@ fn extract_entries(results: Vec) -> Vec { .collect() } +/// Display struct for users' active options positions +#[derive(Debug, Clone)] +pub struct ActiveOptionsDisplay { + pub index: usize, + pub option_tokens: u64, + pub grantor_tokens: u64, + pub expires: String, + pub contract_id: String, +} + /// Display struct for contract collateral #[derive(Debug, Clone)] pub struct CollateralDisplay { @@ -188,6 +245,263 @@ pub struct UserTokenDisplay { pub contract: String, } +/// Query contract tokens locked as collateral in option-offer contracts. +async fn query_contract_tokens_in_offers( + wallet: &crate::wallet::Wallet, + contract_token_ids: &HashSet, + network: simplicityhl_core::SimplicityNetwork, +) -> HashMap { + let mut result = HashMap::new(); + + let Ok(option_offer_contracts) = + <_ as UtxoStore>::list_contracts_by_source_with_metadata(wallet.store(), OPTION_OFFER_SOURCE).await + else { + return result; + }; + + let (all_filters, filter_to_asset): (Vec<_>, Vec<_>) = option_offer_contracts + .into_iter() + .filter_map(|(args_bytes, tpg_str, _)| { + let arguments = bincode::serde::decode_from_slice::( + &args_bytes, + bincode::config::standard(), + ) + .ok()? + .0; + + let args = OptionOfferArguments::from_arguments(&arguments).ok()?; + let collateral_id = args.get_collateral_asset_id(); + + if !contract_token_ids.contains(&collateral_id) { + return None; + } + + let tpg = TaprootPubkeyGen::build_from_str(&tpg_str, &args, network, &get_option_offer_address).ok()?; + + Some(( + UtxoFilter::new().taproot_pubkey_gen(tpg).asset_id(collateral_id), + collateral_id, + )) + }) + .unzip(); + + if all_filters.is_empty() { + return result; + } + + let Ok(results) = <_ as UtxoStore>::query_utxos(wallet.store(), &all_filters).await else { + return result; + }; + + for (query_result, asset_id) in results.into_iter().zip(filter_to_asset) { + if let UtxoQueryResult::Found(entries, _) | UtxoQueryResult::InsufficientValue(entries, _) = query_result { + let total: u64 = entries.iter().filter_map(UtxoEntry::value).sum(); + if total > 0 { + result + .entry(asset_id) + .and_modify(|v| *v = v.saturating_add(total)) + .or_insert(total); + } + } + } + + result +} + +/// Parsed contract data before collateral query. +struct ParsedContract { + args: OptionsArguments, + tpg: TaprootPubkeyGen, +} + +/// Raw contract data from DB: (`args_bytes`, `tpg_string`, `metadata_bytes`). +type RawContractData = (Vec, String, Option>); + +/// Returns (`parsed_contracts_by_id`, `token_to_contract_id`). +fn parse_option_contracts( + option_contracts: &[RawContractData], + network: simplicityhl_core::SimplicityNetwork, +) -> (HashMap, HashMap) { + let mut parsed_contracts: HashMap = HashMap::new(); + let mut token_to_contract: HashMap = HashMap::new(); + + for (args_bytes, tpg_str, _metadata_bytes) in option_contracts { + let Ok((arguments, _)) = + bincode::serde::decode_from_slice::(args_bytes, bincode::config::standard()) + else { + continue; + }; + let Ok(args) = OptionsArguments::from_arguments(&arguments) else { + continue; + }; + let Ok(tpg) = TaprootPubkeyGen::build_from_str(tpg_str, &args, network, &get_options_address) else { + continue; + }; + + let contract_id = tpg_str.clone(); + token_to_contract.insert(args.option_token(), contract_id.clone()); + let (grantor_token_id, _) = args.get_grantor_token_ids(); + token_to_contract.insert(grantor_token_id, contract_id.clone()); + parsed_contracts.insert(contract_id, ParsedContract { args, tpg }); + } + + (parsed_contracts, token_to_contract) +} + +fn aggregate_token_balances(tokens: &[EnrichedTokenEntry]) -> HashMap { + let mut balances: HashMap = HashMap::new(); + for entry in tokens { + let contract_id = entry.taproot_pubkey_gen_str.clone(); + let amount = entry.entry.value().unwrap_or(0); + *balances.entry(contract_id).or_default() += amount; + } + balances +} + +fn map_offers_to_contracts( + tokens_in_offers: &HashMap, + token_to_contract: &HashMap, +) -> HashMap { + let mut locked_in_offers_by_contract: HashMap = HashMap::new(); + for (token_asset_id, amount) in tokens_in_offers { + if let Some(contract_id) = token_to_contract.get(token_asset_id) { + *locked_in_offers_by_contract.entry(contract_id.clone()).or_default() += amount; + } + } + locked_in_offers_by_contract +} + +async fn query_collateral_for_candidates( + wallet: &crate::wallet::Wallet, + candidate_ids: &HashSet, + parsed_contracts: &HashMap, +) -> HashMap { + let (query_ids, query_filters): (Vec<_>, Vec<_>) = candidate_ids + .iter() + .filter_map(|contract_id| { + let parsed = parsed_contracts.get(contract_id)?; + let collateral_asset_id = parsed.args.get_collateral_asset_id(); + let filter = UtxoFilter::new() + .taproot_pubkey_gen(parsed.tpg.clone()) + .asset_id(collateral_asset_id); + Some((contract_id.clone(), filter)) + }) + .unzip(); + + let mut total_collateral_by_contract: HashMap = HashMap::new(); + + if !query_filters.is_empty() + && let Ok(results) = <_ as UtxoStore>::query_utxos(wallet.store(), &query_filters).await + { + for (contract_id, result) in query_ids.into_iter().zip(results.into_iter()) { + let entries = match result { + UtxoQueryResult::Found(entries, _) | UtxoQueryResult::InsufficientValue(entries, _) => entries, + UtxoQueryResult::Empty => Vec::new(), + }; + let total: u64 = entries.iter().filter_map(UtxoEntry::value).sum(); + total_collateral_by_contract.insert(contract_id, total); + } + } + + total_collateral_by_contract +} + +fn build_and_filter_contract_states( + candidate_ids: HashSet, + mut parsed_contracts: HashMap, + option_balances: &HashMap, + grantor_balances: &HashMap, + locked_in_offers_by_contract: &HashMap, + total_collateral_by_contract: &HashMap, + now: i64, +) -> Vec { + candidate_ids + .into_iter() + .filter_map(|contract_id| { + let parsed = parsed_contracts.remove(&contract_id)?; + let state = ContractState { + args: parsed.args, + address: parsed.tpg.address, + user_options: option_balances.get(&contract_id).copied().unwrap_or(0), + user_grantors: grantor_balances.get(&contract_id).copied().unwrap_or(0), + locked_in_offers: locked_in_offers_by_contract.get(&contract_id).copied().unwrap_or(0), + total_collateral: total_collateral_by_contract.get(&contract_id).copied().unwrap_or(0), + }; + Some(state) + }) + .filter(|state| state.is_valid(now) && state.is_active()) + .collect() +} + +async fn fetch_active_contract_states( + wallet: &crate::wallet::Wallet, + option_tokens: &[EnrichedTokenEntry], + grantor_tokens: &[EnrichedTokenEntry], + network: simplicityhl_core::SimplicityNetwork, + now: i64, +) -> Vec { + let Ok(option_contracts) = + <_ as UtxoStore>::list_contracts_by_source_with_metadata(wallet.store(), OPTION_SOURCE).await + else { + return Vec::new(); + }; + + let (parsed_contracts, token_to_contract) = parse_option_contracts(&option_contracts, network); + let contract_token_ids: HashSet = token_to_contract.keys().copied().collect(); + + let option_balances = aggregate_token_balances(option_tokens); + let grantor_balances = aggregate_token_balances(grantor_tokens); + let tokens_in_offers = query_contract_tokens_in_offers(wallet, &contract_token_ids, network).await; + + let locked_in_offers_by_contract = map_offers_to_contracts(&tokens_in_offers, &token_to_contract); + + let candidate_ids: HashSet = option_balances + .keys() + .chain(grantor_balances.keys()) + .chain(locked_in_offers_by_contract.keys()) + .cloned() + .collect(); + + let total_collateral_by_contract = query_collateral_for_candidates(wallet, &candidate_ids, &parsed_contracts).await; + + build_and_filter_contract_states( + candidate_ids, + parsed_contracts, + &option_balances, + &grantor_balances, + &locked_in_offers_by_contract, + &total_collateral_by_contract, + now, + ) +} + +async fn build_active_options_displays( + wallet: &crate::wallet::Wallet, + option_tokens: &[EnrichedTokenEntry], + grantor_tokens: &[EnrichedTokenEntry], + network: simplicityhl_core::SimplicityNetwork, +) -> Vec { + let now = current_timestamp(); + let mut contract_states = fetch_active_contract_states(wallet, option_tokens, grantor_tokens, network, now).await; + + contract_states.sort_by_key(ContractState::expiry_time); + + contract_states + .into_iter() + .enumerate() + .map(|(idx, state)| { + let contract_addr = truncate_with_ellipsis(&state.address.to_string(), 12); + ActiveOptionsDisplay { + index: idx + 1, + option_tokens: state.user_options, + grantor_tokens: state.user_grantors, + expires: format_relative_time(state.expiry_time()), + contract_id: contract_addr, + } + }) + .collect() +} + /// Build locked asset displays, filtering to only show collateral or settlement assets (not reissuance tokens) async fn build_collateral_displays( wallet: &crate::wallet::Wallet, diff --git a/crates/cli-client/src/cli/tables.rs b/crates/cli-client/src/cli/tables.rs index 9252709..2e335dd 100644 --- a/crates/cli-client/src/cli/tables.rs +++ b/crates/cli-client/src/cli/tables.rs @@ -2,7 +2,7 @@ use crate::cli::interactive::{TokenDisplay, WalletAssetDisplay}; use crate::cli::option_offer::{ ActiveOptionOfferDisplay, CancellableOptionOfferDisplay, WithdrawableOptionOfferDisplay, }; -use crate::cli::positions::{CollateralDisplay, UserTokenDisplay}; +use crate::cli::positions::{ActiveOptionsDisplay, CollateralDisplay, UserTokenDisplay}; use comfy_table::presets::UTF8_FULL; use comfy_table::{Attribute, Cell, Table}; @@ -11,6 +11,24 @@ trait TableData { fn to_row(&self) -> Vec; } +impl TableData for ActiveOptionsDisplay { + fn get_header() -> Vec { + vec!["#", "Option-tokens", "Grantor-tokens", "Expiry", "Contract"] + .into_iter() + .map(String::from) + .collect() + } + fn to_row(&self) -> Vec { + vec![ + self.index.to_string(), + self.option_tokens.to_string(), + self.grantor_tokens.to_string(), + self.expires.clone(), + self.contract_id.clone(), + ] + } +} + impl TableData for TokenDisplay { fn get_header() -> Vec { vec!["#", "Collateral/Token", "Strike/Token", "Expires", "Contract"] @@ -177,6 +195,10 @@ fn render_table(items: &[T], empty_msg: &str) { } } +pub fn display_active_options_table(active_options: &[ActiveOptionsDisplay]) { + render_table(active_options, "No active options found"); +} + pub fn display_token_table(tokens: &[TokenDisplay]) { render_table(tokens, "No tokens found"); }