Skip to main content

qualia_client_core/api/
tokens.rs

1//! Token registry, wallet, identity, coins, transactions
2
3#![allow(non_snake_case)]
4
5use super::*;
6
7use crate::engine::llm_offload;
8use crate::state::*;
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::Ordering;
12use std::time::Duration;
13use tokio::time::sleep;
14
15#[derive(Serialize, Deserialize, Clone)]
16pub struct TokenEntry {
17    id: String,
18    chain: String,      // "eCash" | "Ethereum" | "Nyx"
19    token_type: String, // "ALP" | "SLP" | "ERC-20" | "CW-20"
20    contract: String,   // token ID / contract address
21    symbol: String,
22    name: String,
23    balance: String,
24    decimals: u8,
25    fiat_usd: f64,
26}
27
28pub fn tokens_file_path(storage_path: &str) -> PathBuf {
29    PathBuf::from(storage_path).join("tokens.json")
30}
31
32pub fn default_tokens() -> Vec<TokenEntry> {
33    vec![
34        TokenEntry {
35            id: "alp-lion".into(),
36            chain: "eCash".into(),
37            token_type: "ALP".into(),
38            contract: "alp:0x1A2B3C4D...".into(),
39            symbol: "LION".into(),
40            name: "Lion Rampant (Heraldry)".into(),
41            balance: "1.00".into(),
42            decimals: 8,
43            fiat_usd: 0.0,
44        },
45        TokenEntry {
46            id: "alp-horus".into(),
47            chain: "eCash".into(),
48            token_type: "ALP".into(),
49            contract: "alp:0x9B4C5D6E...".into(),
50            symbol: "HORUS".into(),
51            name: "Eye of Horus (Artifact)".into(),
52            balance: "50.00".into(),
53            decimals: 8,
54            fiat_usd: 0.0,
55        },
56        TokenEntry {
57            id: "slp-meme".into(),
58            chain: "eCash".into(),
59            token_type: "SLP".into(),
60            contract: "slp:0x44F1A2B3...".into(),
61            symbol: "MEME".into(),
62            name: "Early Beta Meme Coin".into(),
63            balance: "150000.00".into(),
64            decimals: 2,
65            fiat_usd: 0.0,
66        },
67        TokenEntry {
68            id: "erc20-usdt".into(),
69            chain: "Ethereum".into(),
70            token_type: "ERC-20".into(),
71            contract: "0xdAC17F958D2ee523a2206206994597C13D831ec7".into(),
72            symbol: "USDT".into(),
73            name: "Tether USD".into(),
74            balance: "250.00".into(),
75            decimals: 6,
76            fiat_usd: 250.0,
77        },
78        TokenEntry {
79            id: "erc20-usdc".into(),
80            chain: "Ethereum".into(),
81            token_type: "ERC-20".into(),
82            contract: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".into(),
83            symbol: "USDC".into(),
84            name: "USD Coin".into(),
85            balance: "100.00".into(),
86            decimals: 6,
87            fiat_usd: 100.0,
88        },
89        TokenEntry {
90            id: "erc20-link".into(),
91            chain: "Ethereum".into(),
92            token_type: "ERC-20".into(),
93            contract: "0x514910771AF9Ca656af840dff83E8264EcF986CA".into(),
94            symbol: "LINK".into(),
95            name: "Chainlink Token".into(),
96            balance: "12.50".into(),
97            decimals: 18,
98            fiat_usd: 162.5,
99        },
100        TokenEntry {
101            id: "cw20-vnym".into(),
102            chain: "Nyx".into(),
103            token_type: "CW-20".into(),
104            contract: "nyx1staking000000000000000000000000000000000000".into(),
105            symbol: "vNYM".into(),
106            name: "Vested NYM (Staking)".into(),
107            balance: "100.00".into(),
108            decimals: 6,
109            fiat_usd: 2.0,
110        },
111    ]
112}
113
114pub fn load_tokens_from_disk(storage_path: &str) -> Vec<TokenEntry> {
115    let path = tokens_file_path(storage_path);
116    std::fs::read_to_string(&path)
117        .ok()
118        .and_then(|s| serde_json::from_str(&s).ok())
119        .unwrap_or_else(default_tokens)
120}
121
122pub fn save_tokens_to_disk(storage_path: &str, tokens: &[TokenEntry]) -> Result<(), String> {
123    let json = serde_json::to_string_pretty(tokens).map_err(|e| e.to_string())?;
124    std::fs::write(tokens_file_path(storage_path), json).map_err(|e| e.to_string())
125}
126
127pub fn get_tokens() -> Vec<TokenEntry> {
128    let state = crate::state::APP_STATE.get().unwrap();
129    let storage_path = state.config.lock().unwrap().storage_path.clone();
130    let mut tokens = load_tokens_from_disk(&storage_path);
131
132    let id = read_identity();
133    if let Some(hash160) = id
134        .as_ref()
135        .and_then(|v| v.get("ecash_hash160"))
136        .and_then(|v| v.as_str())
137    {
138        let client = crate::wallet::chronik::ChronikClient::new("https://chronik.be.cash");
139        if let Ok(utxos) = client.fetch_utxos_p2pkh(hash160) {
140            let mut balances: std::collections::HashMap<String, u64> =
141                std::collections::HashMap::new();
142            for utxo in utxos {
143                if let Some(meta) = utxo.slp_meta {
144                    if let Some(token) = utxo.slp_token {
145                        if let Ok(amount) = token.amount.parse::<u64>() {
146                            *balances.entry(meta.token_id).or_insert(0) += amount;
147                        }
148                    }
149                }
150            }
151
152            for t in tokens.iter_mut() {
153                if t.chain == "eCash" {
154                    // Extract token ID from contract e.g. "slp:0x123..."
155                    let token_id = t.contract.split("0x").nth(1).unwrap_or("").to_string();
156                    if let Some(&amt) = balances.get(&token_id.to_lowercase()) {
157                        let float_amt = amt as f64 / 10f64.powi(t.decimals as i32);
158                        t.balance = format!("{:.2}", float_amt);
159                    }
160                }
161            }
162        }
163    }
164
165    tokens
166}
167
168pub fn add_token(
169    chain: String,
170    token_type: String,
171    contract: String,
172    symbol: String,
173    name: String,
174    decimals: u8,
175) -> Result<TokenEntry, String> {
176    let state = crate::state::APP_STATE.get().unwrap();
177    let storage_path = state.config.lock().unwrap().storage_path.clone();
178    let mut tokens = load_tokens_from_disk(&storage_path);
179
180    if tokens
181        .iter()
182        .any(|t| t.contract.to_lowercase() == contract.to_lowercase() && t.chain == chain)
183    {
184        return Err("Token already in wallet".to_string());
185    }
186    let slug: String = contract
187        .chars()
188        .rev()
189        .take(8)
190        .collect::<String>()
191        .chars()
192        .rev()
193        .collect();
194    let id = format!(
195        "{}-{}",
196        chain.to_lowercase().replace(' ', "-"),
197        slug.to_lowercase()
198    );
199    let entry = TokenEntry {
200        id,
201        chain,
202        token_type,
203        contract,
204        symbol,
205        name,
206        balance: "0.00".into(),
207        decimals,
208        fiat_usd: 0.0,
209    };
210    tokens.push(entry.clone());
211    save_tokens_to_disk(&storage_path, &tokens)?;
212    Ok(entry)
213}
214
215pub fn send_ecash_token(
216    token_id: &str,
217    destination_address: &str,
218    amount: u64,
219) -> Result<String, String> {
220    use crate::wallet::coin_select;
221    use crate::wallet::signer::{hash160, sign_p2pkh_input};
222    use crate::wallet::transaction::{Transaction, TxIn, TxOut};
223    use bip32::XPrv;
224    use std::str::FromStr;
225
226    let id = read_identity().ok_or("No identity set — generate a seed first")?;
227    let hash160_hex = id
228        .get("ecash_hash160")
229        .and_then(|v| v.as_str())
230        .ok_or("No ecash_hash160 in identity")?;
231
232    // Derive the private key from stored seed
233    let mnemonic_str = load_mnemonic_from_vault()?;
234    let mnemonic = bip39::Mnemonic::parse_in(bip39::Language::English, &mnemonic_str)
235        .map_err(|_| "Invalid stored mnemonic")?;
236    let seed_bytes = mnemonic.to_seed("");
237    let master = XPrv::new(&seed_bytes).map_err(|e| e.to_string())?;
238    let xec_path =
239        bip32::DerivationPath::from_str("m/44'/899'/0'/0/0").map_err(|e| e.to_string())?;
240    let mut child = master.clone();
241    for c in xec_path.iter() {
242        child = child.derive_child(c).map_err(|e| e.to_string())?;
243    }
244
245    // Fetch UTXOs
246    let client = crate::wallet::chronik::ChronikClient::new("https://chronik.be.cash");
247    let utxos = client.fetch_utxos_p2pkh(hash160_hex)?;
248
249    // Select token UTXOs + funding UTXOs
250    let (token_utxos, xec_selection) = coin_select::select_token_utxos(&utxos, token_id, amount)?;
251
252    // Build the transaction
253    let mut tx = Transaction::new();
254
255    // Add token inputs
256    for utxo in &token_utxos {
257        tx.inputs.push(TxIn {
258            prev_txid: utxo.outpoint.txid.clone(),
259            prev_out_idx: utxo.outpoint.out_idx,
260            signature_script: Vec::new(),
261            sequence: 0xFFFFFFFF,
262        });
263    }
264    // Add funding inputs
265    for utxo in &xec_selection.selected {
266        tx.inputs.push(TxIn {
267            prev_txid: utxo.outpoint.txid.clone(),
268            prev_out_idx: utxo.outpoint.out_idx,
269            signature_script: Vec::new(),
270            sequence: 0xFFFFFFFF,
271        });
272    }
273
274    // Output 0: OP_RETURN with SLP SEND
275    let op_return_script =
276        crate::wallet::semantic_tokens::generate_slp_send_op_return(token_id, &[amount]);
277    tx.outputs.push(TxOut {
278        value: 0,
279        pk_script: op_return_script,
280    });
281
282    // Output 1: Token recipient (dust amount)
283    let dest_pubkey_hash = decode_ecash_address(destination_address)?;
284    let mut p2pkh_script = vec![0x76, 0xa9, 0x14];
285    p2pkh_script.extend_from_slice(&dest_pubkey_hash);
286    p2pkh_script.extend_from_slice(&[0x88, 0xac]);
287    tx.outputs.push(TxOut {
288        value: coin_select::DUST_THRESHOLD_SATS as u64,
289        pk_script: p2pkh_script,
290    });
291
292    // Output 2: Change (if any)
293    if xec_selection.change_sats > 0 {
294        let own_pubkey_hash = hash160(&child.public_key().to_bytes());
295        let mut change_script = vec![0x76, 0xa9, 0x14];
296        change_script.extend_from_slice(&own_pubkey_hash);
297        change_script.extend_from_slice(&[0x88, 0xac]);
298        tx.outputs.push(TxOut {
299            value: xec_selection.change_sats as u64,
300            pk_script: change_script,
301        });
302    }
303
304    // Sign all inputs
305    let all_utxos: Vec<&crate::wallet::chronik::ChronikUtxo> = token_utxos
306        .iter()
307        .chain(xec_selection.selected.iter())
308        .collect();
309    for (i, utxo) in all_utxos.iter().enumerate() {
310        let script_sig = sign_p2pkh_input(&tx, i, utxo.value as u64, &child);
311        tx.inputs[i].signature_script = script_sig;
312    }
313
314    // Broadcast
315    let raw_hex = hex::encode(tx.serialize());
316    let txid = client.broadcast_tx(&raw_hex)?;
317
318    // Record in ledger
319    let state = crate::state::APP_STATE.get().unwrap();
320    let storage = state.config.lock().unwrap().storage_path.clone();
321    let _ = crate::wallet::ledger::append_entry(
322        std::path::Path::new(&storage),
323        &crate::wallet::ledger::new_entry(crate::wallet::ledger::LedgerEntryKind::TxBroadcast {
324            chain: "XEC".into(),
325            txid: txid.clone(),
326            amount_sats: amount,
327            direction: "out".into(),
328        }),
329    );
330
331    Ok(txid)
332}
333
334/// Build a native XEC send transaction (preview only — does not broadcast).
335/// Returns the raw transaction hex and a fee estimate for user confirmation.
336#[derive(Serialize, Clone)]
337pub struct SendPreview {
338    pub raw_hex: String,
339    pub fee_sats: i64,
340    pub total_input_sats: i64,
341    pub change_sats: i64,
342    pub target_sats: i64,
343}
344
345pub fn build_send_xec(destination_address: &str, amount_sats: i64) -> Result<SendPreview, String> {
346    use crate::wallet::coin_select;
347    use crate::wallet::signer::{hash160, sign_p2pkh_input};
348    use crate::wallet::transaction::{Transaction, TxIn, TxOut};
349    use bip32::XPrv;
350    use std::str::FromStr;
351
352    if amount_sats <= 0 {
353        return Err("Amount must be positive".into());
354    }
355
356    let id = read_identity().ok_or("No identity set — generate a seed first")?;
357    let hash160_hex = id
358        .get("ecash_hash160")
359        .and_then(|v| v.as_str())
360        .ok_or("No ecash_hash160 in identity")?;
361
362    let mnemonic_str = load_mnemonic_from_vault()?;
363    let mnemonic = bip39::Mnemonic::parse_in(bip39::Language::English, &mnemonic_str)
364        .map_err(|_| "Invalid stored mnemonic")?;
365    let seed_bytes = mnemonic.to_seed("");
366    let master = XPrv::new(&seed_bytes).map_err(|e| e.to_string())?;
367    let xec_path =
368        bip32::DerivationPath::from_str("m/44'/899'/0'/0/0").map_err(|e| e.to_string())?;
369    let mut child = master.clone();
370    for c in xec_path.iter() {
371        child = child.derive_child(c).map_err(|e| e.to_string())?;
372    }
373
374    let client = crate::wallet::chronik::ChronikClient::new("https://chronik.be.cash");
375    let utxos = client.fetch_utxos_p2pkh(hash160_hex)?;
376    let selection = coin_select::select_utxos(&utxos, amount_sats)?;
377
378    let mut tx = Transaction::new();
379    for utxo in &selection.selected {
380        tx.inputs.push(TxIn {
381            prev_txid: utxo.outpoint.txid.clone(),
382            prev_out_idx: utxo.outpoint.out_idx,
383            signature_script: Vec::new(),
384            sequence: 0xFFFFFFFF,
385        });
386    }
387
388    // Recipient output
389    let dest_pubkey_hash = decode_ecash_address(destination_address)?;
390    let mut p2pkh_script = vec![0x76, 0xa9, 0x14];
391    p2pkh_script.extend_from_slice(&dest_pubkey_hash);
392    p2pkh_script.extend_from_slice(&[0x88, 0xac]);
393    tx.outputs.push(TxOut {
394        value: amount_sats as u64,
395        pk_script: p2pkh_script,
396    });
397
398    // Change output
399    if selection.change_sats > 0 {
400        let own_pubkey_hash = hash160(&child.public_key().to_bytes());
401        let mut change_script = vec![0x76, 0xa9, 0x14];
402        change_script.extend_from_slice(&own_pubkey_hash);
403        change_script.extend_from_slice(&[0x88, 0xac]);
404        tx.outputs.push(TxOut {
405            value: selection.change_sats as u64,
406            pk_script: change_script,
407        });
408    }
409
410    // Sign
411    for (i, utxo) in selection.selected.iter().enumerate() {
412        let script_sig = sign_p2pkh_input(&tx, i, utxo.value as u64, &child);
413        tx.inputs[i].signature_script = script_sig;
414    }
415
416    let raw_hex = hex::encode(tx.serialize());
417    Ok(SendPreview {
418        raw_hex,
419        fee_sats: selection.fee_sats,
420        total_input_sats: selection.total_input_sats,
421        change_sats: selection.change_sats,
422        target_sats: amount_sats,
423    })
424}
425
426/// Broadcast a previously built transaction.
427pub fn confirm_send_xec(raw_hex: &str) -> Result<String, String> {
428    let client = crate::wallet::chronik::ChronikClient::new("https://chronik.be.cash");
429    let txid = client.broadcast_tx(raw_hex)?;
430
431    // Record in ledger
432    let state = crate::state::APP_STATE.get().unwrap();
433    let storage = state.config.lock().unwrap().storage_path.clone();
434    let _ = crate::wallet::ledger::append_entry(
435        std::path::Path::new(&storage),
436        &crate::wallet::ledger::new_entry(crate::wallet::ledger::LedgerEntryKind::TxBroadcast {
437            chain: "XEC".into(),
438            txid: txid.clone(),
439            amount_sats: 0, // Amount is in the raw tx; we don't parse it back here
440            direction: "out".into(),
441        }),
442    );
443
444    Ok(txid)
445}
446
447/// Decode an eCash address to its 20-byte pubkey hash.
448/// Supports both `ecash:q...` (CashAddr) and legacy base58 formats.
449fn decode_ecash_address(addr: &str) -> Result<Vec<u8>, String> {
450    // Strip ecash: prefix if present
451    let stripped = if let Some(a) = addr.strip_prefix("ecash:") {
452        a
453    } else {
454        addr
455    };
456    // Try base58 decode (legacy format)
457    let decoded = bs58::decode(stripped)
458        .into_vec()
459        .map_err(|e| format!("Invalid address: {}", e))?;
460    if decoded.len() < 21 {
461        return Err("Address too short".into());
462    }
463    // Skip version byte, take 20-byte hash
464    Ok(decoded[1..21].to_vec())
465}
466
467/// Load the stored mnemonic from the vault (identity file stores derivation result,
468/// the mnemonic itself is stored separately for security).
469fn load_mnemonic_from_vault() -> Result<String, String> {
470    let mnemonic_path = app_meta_dir().join("mnemonic.enc");
471    std::fs::read_to_string(&mnemonic_path).map_err(|_| {
472        "No mnemonic stored — please save your seed phrase via the identity setup flow".to_string()
473    })
474}
475
476pub fn remove_token(id: String) -> Result<(), String> {
477    let state = crate::state::APP_STATE.get().unwrap();
478    let storage_path = state.config.lock().unwrap().storage_path.clone();
479    let mut tokens = load_tokens_from_disk(&storage_path);
480    tokens.retain(|t| t.id != id);
481    save_tokens_to_disk(&storage_path, &tokens)
482}
483
484// ─────────────────────────────────────────────────────────────────────────────
485
486pub fn read_identity() -> Option<serde_json::Value> {
487    std::fs::read_to_string(identity_file_path())
488        .ok()
489        .and_then(|s| serde_json::from_str(&s).ok())
490}
491
492#[derive(Serialize, Clone)]
493pub struct CoinBalance {
494    pub coin: String,
495    pub ticker: String,
496    pub address: String,
497    pub balance: f64,
498    pub balance_display: String,
499    pub fiat_usd: f64,
500    pub price_usd: f64,
501    pub change_24h: f64,
502    pub network: String,
503    pub status: String,
504}
505
506#[derive(Serialize, Clone)]
507pub struct TxRecord {
508    txid: String,
509    ticker: String,
510    direction: String, // "in" | "out"
511    amount: String,
512    label: String,
513    timestamp: String,
514    status: String, // "confirmed" | "pending"
515    confirmations: u32,
516    fee: String,
517    counterparty: String,
518}
519
520pub fn get_coin_balances() -> Vec<CoinBalance> {
521    let id = read_identity();
522    let has_identity = id.is_some();
523    let addr = |key: &str| -> String {
524        id.as_ref()
525            .and_then(|v| v.get(key))
526            .and_then(|v| v.as_str())
527            .unwrap_or("")
528            .to_string()
529    };
530    let no_adapter_status: String = if has_identity {
531        "no_adapter".into()
532    } else {
533        "awaiting_identity".into()
534    };
535    let zero_display = if has_identity { "0" } else { "\u{2014}" };
536
537    let mut xec_balance = 0.0;
538    let mut xec_status = if has_identity {
539        "no_adapter".to_string()
540    } else {
541        "awaiting_identity".to_string()
542    };
543    let mut xec_display = zero_display.to_string();
544
545    if has_identity {
546        if let Some(hash160) = id
547            .as_ref()
548            .and_then(|v| v.get("ecash_hash160"))
549            .and_then(|v| v.as_str())
550        {
551            let client = crate::wallet::chronik::ChronikClient::new("https://chronik.be.cash");
552            if let Ok(utxos) = client.fetch_utxos_p2pkh(hash160) {
553                let mut sats = 0;
554                for utxo in utxos {
555                    if utxo.slp_meta.is_none() {
556                        sats += utxo.value;
557                    }
558                }
559                xec_balance = sats as f64 / 100.0; // XEC is 2 decimals (100 sats)
560                xec_display = format!("{:.2}", xec_balance);
561                xec_status = "synced".into();
562            } else {
563                xec_status = "offline".into();
564            }
565        }
566    }
567
568    let mut balances = vec![
569        CoinBalance {
570            coin: "eCash".into(),
571            ticker: "XEC".into(),
572            address: addr("ecash_xec"),
573            balance: xec_balance,
574            balance_display: xec_display,
575            fiat_usd: 0.0,
576            price_usd: 0.0,
577            change_24h: 0.0,
578            network: "eCash".into(),
579            status: xec_status,
580        },
581        CoinBalance {
582            coin: "Bitcoin".into(),
583            ticker: "BTC".into(),
584            address: addr("bitcoin_btc"),
585            balance: 0.0,
586            balance_display: zero_display.into(),
587            fiat_usd: 0.0,
588            price_usd: 0.0,
589            change_24h: 0.0,
590            network: "Bitcoin".into(),
591            status: no_adapter_status.clone(),
592        },
593        CoinBalance {
594            coin: "Monero".into(),
595            ticker: "XMR".into(),
596            // XMR derivation is not implemented (see derive_wallets_from_seed).
597            // Show an explicit non-address so it can't be mistaken for a real
598            // receive address — never a fabricated "4..." string.
599            address: "(not yet supported)".into(),
600            balance: 0.0,
601            balance_display: zero_display.into(),
602            fiat_usd: 0.0,
603            price_usd: 0.0,
604            change_24h: 0.0,
605            network: "Monero".into(),
606            status: no_adapter_status.clone(),
607        },
608        CoinBalance {
609            coin: "Ethereum".into(),
610            ticker: "ETH".into(),
611            address: addr("ethereum"),
612            balance: 0.0,
613            balance_display: zero_display.into(),
614            fiat_usd: 0.0,
615            price_usd: 0.0,
616            change_24h: 0.0,
617            network: "Ethereum".into(),
618            status: no_adapter_status.clone(),
619        },
620    ];
621
622    if nym_mixnet_opted_in() {
623        balances.push(CoinBalance {
624            coin: "Nym".into(),
625            ticker: "NYM".into(),
626            address: addr("nym_mixnet"),
627            balance: 0.0,
628            balance_display: zero_display.into(),
629            fiat_usd: 0.0,
630            price_usd: 0.0,
631            change_24h: 0.0,
632            network: "Nyx Chain".into(),
633            status: no_adapter_status,
634        });
635    }
636
637    balances
638}
639
640pub fn get_transaction_history(ticker: String) -> Vec<TxRecord> {
641    // For XEC: query live transaction history from Chronik.
642    // For other chains: return empty (no adapter yet).
643    let id = read_identity();
644
645    let xec_history: Vec<TxRecord> = if ticker.is_empty() || ticker == "ALL" || ticker == "XEC" {
646        fetch_xec_tx_history(&id).unwrap_or_default()
647    } else {
648        Vec::new()
649    };
650
651    if ticker.is_empty() || ticker == "ALL" {
652        xec_history
653    } else {
654        xec_history
655            .into_iter()
656            .filter(|tx| tx.ticker == ticker)
657            .collect()
658    }
659}
660
661/// Fetch real XEC transaction history from Chronik.
662fn fetch_xec_tx_history(id: &Option<serde_json::Value>) -> Result<Vec<TxRecord>, String> {
663    let hash160 = id
664        .as_ref()
665        .and_then(|v| v.get("ecash_hash160"))
666        .and_then(|v| v.as_str())
667        .ok_or("No ecash_hash160 in identity")?;
668
669    let own_script_suffix = hash160.to_lowercase();
670
671    let client = crate::wallet::chronik::ChronikClient::new("https://chronik.be.cash");
672    let page = client.fetch_tx_history_p2pkh(hash160, 0, 25)?;
673
674    let mut records = Vec::new();
675    for tx in page.txs {
676        // Determine direction by checking if our address appears in inputs
677        let is_outgoing = tx.inputs.iter().any(|inp| {
678            inp.output_script
679                .to_lowercase()
680                .contains(&own_script_suffix)
681        });
682
683        // Calculate net amount
684        let own_output_sats: i64 = tx
685            .outputs
686            .iter()
687            .filter(|o| o.output_script.to_lowercase().contains(&own_script_suffix))
688            .map(|o| o.value)
689            .sum();
690
691        let own_input_sats: i64 = if is_outgoing {
692            tx.inputs
693                .iter()
694                .filter(|i| i.output_script.to_lowercase().contains(&own_script_suffix))
695                .map(|i| i.value)
696                .sum()
697        } else {
698            0
699        };
700
701        let (direction, amount_sats, label) = if is_outgoing {
702            let sent = own_input_sats - own_output_sats; // net outflow
703            ("out", sent, "Sent XEC")
704        } else {
705            ("in", own_output_sats, "Received XEC")
706        };
707
708        let xec_amount = amount_sats as f64 / 100.0;
709        let amount_str = format!("{:.2}", xec_amount);
710
711        // Determine confirmation status
712        let (status_str, confirmations) = match &tx.block {
713            Some(block) => {
714                // Rough confirmation count (we don't know current height, so show block height)
715                ("confirmed".to_string(), block.height as u32)
716            }
717            None => ("pending".to_string(), 0),
718        };
719
720        // Timestamp from block or first-seen
721        let timestamp = if let Some(ref block) = tx.block {
722            format_unix_timestamp(block.timestamp)
723        } else if tx.time_first_seen > 0 {
724            format_unix_timestamp(tx.time_first_seen)
725        } else {
726            "—".to_string()
727        };
728
729        // Counterparty: for outgoing, the first non-own output address; for incoming, first input
730        let counterparty = if is_outgoing {
731            tx.outputs
732                .iter()
733                .find(|o| {
734                    !o.output_script.to_lowercase().contains(&own_script_suffix) && o.value > 0
735                })
736                .map(|o| {
737                    format!(
738                        "script:{}",
739                        &o.output_script[..o.output_script.len().min(16)]
740                    )
741                })
742                .unwrap_or_else(|| "self".to_string())
743        } else {
744            tx.inputs
745                .first()
746                .map(|i| {
747                    format!(
748                        "script:{}",
749                        &i.output_script[..i.output_script.len().min(16)]
750                    )
751                })
752                .unwrap_or_else(|| "coinbase".to_string())
753        };
754
755        // Truncate txid for display
756        let txid_display = if tx.txid.len() > 16 {
757            format!("{}…{}", &tx.txid[..8], &tx.txid[tx.txid.len() - 4..])
758        } else {
759            tx.txid.clone()
760        };
761
762        records.push(TxRecord {
763            txid: txid_display,
764            ticker: "XEC".into(),
765            direction: direction.into(),
766            amount: amount_str,
767            label: label.into(),
768            timestamp,
769            status: status_str,
770            confirmations,
771            fee: "".into(), // Chronik doesn't return fee directly
772            counterparty,
773        });
774    }
775
776    Ok(records)
777}
778
779/// Format a Unix timestamp to a human-readable string.
780fn format_unix_timestamp(ts: i64) -> String {
781    let secs = ts as u64;
782    let days = secs / 86400;
783    let remaining = secs % 86400;
784    let hours = remaining / 3600;
785    let minutes = (remaining % 3600) / 60;
786    let (y, m, d) = crate::wallet::ledger::epoch_days_to_date_pub(days);
787    format!("{:04}-{:02}-{:02} {:02}:{:02}", y, m, d, hours, minutes)
788}
789
790pub fn is_first_run() -> bool {
791    !config_file_path().exists()
792}
793
794pub fn save_identity(wallets: serde_json::Value) -> Result<(), String> {
795    let meta = app_meta_dir();
796    std::fs::create_dir_all(&meta).map_err(|e| e.to_string())?;
797    let json = serde_json::to_string_pretty(&wallets).map_err(|e| e.to_string())?;
798    std::fs::write(identity_file_path(), json).map_err(|e| e.to_string())?;
799    Ok(())
800}
801
802pub fn load_identity() -> Result<Option<serde_json::Value>, String> {
803    let path = identity_file_path();
804    if !path.exists() {
805        return Ok(None);
806    }
807    let json = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
808    let val: serde_json::Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
809    Ok(Some(val))
810}
811
812use bip39::{Language, Mnemonic};
813
814pub fn to_hex(bytes: &[u8]) -> String {
815    bytes.iter().map(|b| format!("{:02x}", b)).collect()
816}
817
818pub async fn generate_bip39_seed() -> Result<String, String> {
819    // Generate a secure, randomized 12-word seed phrase natively
820    let mnemonic = Mnemonic::generate_in(Language::English, 12)
821        .map_err(|_| "Failed to generate".to_string())?;
822    let words: Vec<&str> = mnemonic.words().collect();
823    Ok(words.join(" "))
824}
825
826pub async fn derive_wallets_from_seed(seed: String) -> Result<serde_json::Value, String> {
827    let mnemonic = match Mnemonic::parse_in(Language::English, &seed) {
828        Ok(m) => m,
829        Err(_) => return Err("Invalid 12-word seed phrase.".to_string()),
830    };
831
832    let seed_bytes = mnemonic.to_seed("");
833    let wallet = crate::wallet::HdWallet::from_seed(&seed_bytes)?;
834
835    let btc_addr = wallet.derive_address("BTC", "m/44'/0'/0'/0/0")?.address;
836    let eth_addr = wallet.derive_address("ETH", "m/44'/60'/0'/0/0")?.address;
837    let nym_addr = wallet.derive_address("NYM", "m/44'/118'/0'/0/0")?.address;
838    let xec_payload = wallet.derive_address("XEC", "m/44'/899'/0'/0/0")?;
839    let xec_addr = xec_payload.address;
840    let xec_hash160 = xec_payload.pubkey_hash;
841
842    // Monero is deliberately NOT derived here. It uses ed25519 (not the
843    // secp256k1 BIP32 path above), Keccak-256 key derivation, and its own
844    // base58 address format. Emitting a plausible-looking "4..." string with
845    // no keys behind it is dangerous: any XMR sent to a keyless address is
846    // permanently lost. So we report it empty rather than fabricate an
847    // address. Real, test-vector-verified ed25519/Keccak/base58 derivation is
848    // tracked as follow-up work (needs an authoritative seed→address vector to
849    // verify against before it can be trusted).
850    let hex_seed = to_hex(&seed_bytes[0..16]);
851
852    Ok(serde_json::json!({
853        "qualia_root": format!("did:qualia:0x{}", hex_seed),
854        "nym_mixnet": nym_addr,
855        "ecash_xec": xec_addr,
856        "ecash_hash160": xec_hash160,
857        "ethereum": eth_addr,
858        "bitcoin_btc": btc_addr,
859        "monero_xmr": "" // not derived — never a fabricated address (see above)
860    }))
861}
862
863pub async fn generate_front_door_invite() -> Result<String, String> {
864    let invite = crate::social_connect::generate_connect_invite(None)?;
865    Ok(invite.invite_json)
866}
867
868pub async fn mint_semantic_token(asset_id: String) -> Result<String, String> {
869    use crate::wallet::coin_select;
870    use crate::wallet::signer::{hash160, sign_p2pkh_input};
871    use crate::wallet::transaction::{Transaction, TxIn, TxOut};
872    use bip32::XPrv;
873    use std::str::FromStr;
874
875    let id = read_identity().ok_or("No identity set — generate a seed first")?;
876    let hash160_hex = id
877        .get("ecash_hash160")
878        .and_then(|v| v.as_str())
879        .ok_or("No ecash_hash160 in identity")?;
880
881    let mnemonic_str = load_mnemonic_from_vault()?;
882    let mnemonic = bip39::Mnemonic::parse_in(bip39::Language::English, &mnemonic_str)
883        .map_err(|_| "Invalid stored mnemonic")?;
884    let seed_bytes = mnemonic.to_seed("");
885    let master = XPrv::new(&seed_bytes).map_err(|e| e.to_string())?;
886    let xec_path =
887        bip32::DerivationPath::from_str("m/44'/899'/0'/0/0").map_err(|e| e.to_string())?;
888    let mut child = master.clone();
889    for c in xec_path.iter() {
890        child = child.derive_child(c).map_err(|e| e.to_string())?;
891    }
892
893    // Parse asset_id as JSON metadata or use default
894    let metadata = crate::wallet::semantic_tokens::SemanticTokenMetadata {
895        token_ticker: asset_id.clone(),
896        token_name: format!("Qualia Semantic Token: {}", asset_id),
897        token_document_url: format!("https://qualia.io/tokens/{}", asset_id.to_lowercase()),
898        token_document_hash: format!("{:064x}", 0u128), // Placeholder hash
899        decimals: 0,
900    };
901
902    let client = crate::wallet::chronik::ChronikClient::new("https://chronik.be.cash");
903    let utxos = client.fetch_utxos_p2pkh(hash160_hex)?;
904
905    // Need enough XEC to cover: OP_RETURN (0) + mint output (546) + change + fee
906    let min_needed = coin_select::DUST_THRESHOLD_SATS + coin_select::BASE_FEE_SATS;
907    let selection = coin_select::select_utxos(&utxos, min_needed)?;
908
909    let mut tx = Transaction::new();
910    for utxo in &selection.selected {
911        tx.inputs.push(TxIn {
912            prev_txid: utxo.outpoint.txid.clone(),
913            prev_out_idx: utxo.outpoint.out_idx,
914            signature_script: Vec::new(),
915            sequence: 0xFFFFFFFF,
916        });
917    }
918
919    // Output 0: OP_RETURN GENESIS
920    let op_return = crate::wallet::semantic_tokens::generate_slp_op_return(&metadata);
921    tx.outputs.push(TxOut {
922        value: 0,
923        pk_script: op_return,
924    });
925
926    // Output 1: Mint receiver (self)
927    let own_pubkey_hash = hash160(&child.public_key().to_bytes());
928    let mut mint_script = vec![0x76, 0xa9, 0x14];
929    mint_script.extend_from_slice(&own_pubkey_hash);
930    mint_script.extend_from_slice(&[0x88, 0xac]);
931    tx.outputs.push(TxOut {
932        value: coin_select::DUST_THRESHOLD_SATS as u64,
933        pk_script: mint_script,
934    });
935
936    // Output 2: Change
937    if selection.change_sats > 0 {
938        let mut change_script = vec![0x76, 0xa9, 0x14];
939        change_script.extend_from_slice(&own_pubkey_hash);
940        change_script.extend_from_slice(&[0x88, 0xac]);
941        tx.outputs.push(TxOut {
942            value: selection.change_sats as u64,
943            pk_script: change_script,
944        });
945    }
946
947    // Sign
948    for (i, utxo) in selection.selected.iter().enumerate() {
949        let script_sig = sign_p2pkh_input(&tx, i, utxo.value as u64, &child);
950        tx.inputs[i].signature_script = script_sig;
951    }
952
953    // Broadcast
954    let raw_hex = hex::encode(tx.serialize());
955    let txid = client.broadcast_tx(&raw_hex)?;
956
957    // Record in ledger
958    let state = crate::state::APP_STATE.get().unwrap();
959    let storage = state.config.lock().unwrap().storage_path.clone();
960    let _ = crate::wallet::ledger::append_entry(
961        std::path::Path::new(&storage),
962        &crate::wallet::ledger::new_entry(crate::wallet::ledger::LedgerEntryKind::TokenMint {
963            chain: "XEC".into(),
964            txid: txid.clone(),
965            token_id: txid.clone(), // For SLP, the token_id IS the genesis txid
966            symbol: asset_id,
967        }),
968    );
969
970    Ok(txid)
971}
972
973pub async fn fetch_wallet_portfolio() -> Result<serde_json::Value, String> {
974    // Compose real portfolio from live coin balances + token registry
975    let balances = get_coin_balances();
976    let tokens = get_tokens();
977
978    let mut portfolio = Vec::new();
979
980    // Add native coin entries
981    for b in &balances {
982        portfolio.push(serde_json::json!({
983            "name": b.coin,
984            "tokenId": "",
985            "ticker": b.ticker,
986            "balance": b.balance_display,
987            "rdf": "",
988            "network": b.network,
989            "type": "native",
990            "status": b.status,
991            "address": b.address,
992            "fiat_usd": b.fiat_usd,
993        }));
994    }
995
996    // Add token entries
997    for t in &tokens {
998        portfolio.push(serde_json::json!({
999            "name": t.name,
1000            "tokenId": t.contract,
1001            "ticker": t.symbol,
1002            "balance": t.balance,
1003            "rdf": "",
1004            "network": t.chain,
1005            "type": t.token_type,
1006            "status": "loaded",
1007        }));
1008    }
1009
1010    Ok(serde_json::Value::Array(portfolio))
1011}
1012
1013pub async fn import_external_seed(
1014    network: String,
1015    seed: String,
1016    _label: String,
1017) -> Result<String, String> {
1018    // Validate seed format
1019    if seed.split_whitespace().count() < 12 {
1020        return Err("Invalid seed phrase — must be at least 12 words".to_string());
1021    }
1022
1023    // Derive real addresses using the existing HD wallet pipeline
1024    let mnemonic = bip39::Mnemonic::parse_in(bip39::Language::English, &seed)
1025        .map_err(|_| "Invalid BIP-39 mnemonic".to_string())?;
1026    let seed_bytes = mnemonic.to_seed("");
1027    let wallet = crate::wallet::HdWallet::from_seed(&seed_bytes)?;
1028
1029    let (net_code, path) = match network.as_str() {
1030        "eCash (XEC)" | "XEC" => ("XEC", "m/44'/899'/0'/0/0"),
1031        "Bitcoin (BTC)" | "BTC" => ("BTC", "m/44'/0'/0'/0/0"),
1032        "Nym (NYM) - Nyx Chain" | "NYM" => ("NYM", "m/44'/118'/0'/0/0"),
1033        "Ethereum (EVM)" | "ETH" => ("ETH", "m/44'/60'/0'/0/0"),
1034        "Monero (XMR)" | "XMR" => {
1035            // Monero uses ed25519, not secp256k1 — still mock for now
1036            let xmr_hex = to_hex(&seed_bytes[48..56]);
1037            return Ok(format!("4{}...", &xmr_hex[0..xmr_hex.len().min(16)]));
1038        }
1039        _ => return Err(format!("Unsupported network: {}", network)),
1040    };
1041
1042    let payload = wallet.derive_address(net_code, path)?;
1043    Ok(payload.address)
1044}
1045
1046pub async fn toggle_nym_relay() -> Result<bool, String> {
1047    let state = crate::state::APP_STATE.get().unwrap();
1048    let active = &state.nym_relay_active;
1049    let currently_active = active.load(Ordering::Relaxed);
1050    let new_state = !currently_active;
1051    active.store(new_state, Ordering::Relaxed);
1052
1053    if new_state {
1054        let active_clone = active.clone();
1055
1056        // Spawn asynchronous background daemon for packet routing
1057        tokio::spawn(async move {
1058            let mut packets_routed = 0;
1059            let mut _packets_dropped = 0;
1060
1061            while active_clone.load(Ordering::Relaxed) {
1062                // Simulate network fluctuations and calculate memory backpressure
1063                // Enforcing a strict 50MB telemetry boundary cap internally
1064                let packet_load_factor = 1.0 + (packets_routed % 5) as f64 * 0.2;
1065                let buffer_memory_mb = 12.4 * packet_load_factor;
1066                let is_congested = buffer_memory_mb > 45.0;
1067
1068                if is_congested {
1069                    _packets_dropped += 15;
1070                } else {
1071                    packets_routed += 42;
1072                }
1073
1074                // let _ = window_clone.emit("nym-telemetry", RelayTelemetry {
1075                //     packets_routed,
1076                //     packets_dropped,
1077                //     buffer_memory_mb,
1078                //     is_congested,
1079                // });
1080
1081                sleep(Duration::from_millis(500)).await;
1082            }
1083        });
1084    }
1085    Ok(new_state)
1086}
1087
1088pub async fn toggle_stark_prover() -> Result<bool, String> {
1089    let state = crate::state::APP_STATE.get().unwrap();
1090    let active = &state.stark_prover_active;
1091    let currently_active = active.load(Ordering::Relaxed);
1092    let new_state = !currently_active;
1093    active.store(new_state, Ordering::Relaxed);
1094
1095    if new_state {
1096        let active_clone = active.clone();
1097        let solar_clone = state.simulated_solar_watts.clone();
1098
1099        // Spawn asynchronous background daemon for out-of-core proof chunking
1100        tokio::spawn(async move {
1101            let mut _fragments_paged = 0;
1102
1103            while active_clone.load(Ordering::Relaxed) {
1104                let current_solar = solar_clone.load(Ordering::Relaxed);
1105
1106                // Environmental state evaluation trigger (threshold at 400W)
1107                if current_solar < 400 {
1108                    // let _ = window_clone.emit("stark-telemetry", StarkTelemetry {
1109                    //     status: "Suspended - Awaiting Solar Surplus".to_string(),
1110                    //     cpu_utilization: 0.0,
1111                    //     ram_usage_mb: 0.0,
1112                    //     fragments_paged,
1113                    // });
1114                } else {
1115                    _fragments_paged += 8; // Simulate 48-byte Super-Quin paging writes
1116
1117                    // let _ = window_clone.emit("stark-telemetry", StarkTelemetry {
1118                    //     status: "Proving Execution Active".to_string(),
1119                    //     cpu_utilization: 85.4,
1120                    //     ram_usage_mb: 320.0, // Constrained flat memory footprint
1121                    //     fragments_paged,
1122                    // });
1123                }
1124                sleep(Duration::from_millis(1000)).await;
1125            }
1126        });
1127    }
1128    Ok(new_state)
1129}
1130
1131pub fn update_solar_input(watts: u32) {
1132    let state = crate::state::APP_STATE.get().unwrap();
1133    state.simulated_solar_watts.store(watts, Ordering::Relaxed);
1134}
1135
1136pub async fn fetch_torrent_telemetry() -> Result<serde_json::Value, String> {
1137    let state = crate::state::APP_STATE.get().unwrap();
1138    let storage = state.config.lock().unwrap().storage_path.clone();
1139    Ok(crate::ontology_workbench::torrent_telemetry(Path::new(
1140        &storage,
1141    )))
1142}
1143
1144pub fn sync_workbench_torrent_seeds(storage_path: &str) -> Result<serde_json::Value, String> {
1145    crate::ontology_workbench::sync_workbench_seeds_to_daemon(Path::new(storage_path))
1146}
1147
1148pub async fn workbench_import_ontology_uri(
1149    uri: String,
1150    ontology_id: Option<String>,
1151    domain: Option<String>,
1152    title: Option<String>,
1153) -> Result<serde_json::Value, String> {
1154    let state = crate::state::APP_STATE.get().unwrap();
1155    let storage = state.config.lock().unwrap().storage_path.clone();
1156    let result = crate::ontology_workbench::import_from_uri(
1157        Path::new(&storage),
1158        uri,
1159        ontology_id,
1160        domain,
1161        title,
1162    )
1163    .await?;
1164    serde_json::to_value(result).map_err(|e| e.to_string())
1165}
1166
1167pub fn list_workbench_ontologies() -> Result<serde_json::Value, String> {
1168    let state = crate::state::APP_STATE.get().unwrap();
1169    let storage = state.config.lock().unwrap().storage_path.clone();
1170    let entries = crate::ontology_workbench::list_workbench_entries(Path::new(&storage))?;
1171    serde_json::to_value(entries).map_err(|e| e.to_string())
1172}
1173
1174pub fn set_workbench_torrent_policy(
1175    ontology_id: String,
1176    policy_json: serde_json::Value,
1177) -> Result<serde_json::Value, String> {
1178    let state = crate::state::APP_STATE.get().unwrap();
1179    let storage = state.config.lock().unwrap().storage_path.clone();
1180    let policy: crate::ontology_workbench::OntologyTorrentPolicy =
1181        serde_json::from_value(policy_json).map_err(|e| e.to_string())?;
1182    let updated =
1183        crate::ontology_workbench::set_torrent_policy(Path::new(&storage), &ontology_id, policy)?;
1184    serde_json::to_value(updated).map_err(|e| e.to_string())
1185}
1186
1187pub fn set_workbench_seed(ontology_id: String, active: bool) -> Result<serde_json::Value, String> {
1188    let state = crate::state::APP_STATE.get().unwrap();
1189    let storage = state.config.lock().unwrap().storage_path.clone();
1190    let updated =
1191        crate::ontology_workbench::set_seed_active(Path::new(&storage), &ontology_id, active)?;
1192    serde_json::to_value(updated).map_err(|e| e.to_string())
1193}
1194
1195pub fn get_torrent_bandwidth_policy() -> Result<serde_json::Value, String> {
1196    let policy = crate::ontology_workbench::load_bandwidth_policy();
1197    serde_json::to_value(policy).map_err(|e| e.to_string())
1198}
1199
1200pub fn set_torrent_bandwidth_policy(
1201    policy_json: serde_json::Value,
1202) -> Result<serde_json::Value, String> {
1203    let policy: crate::ontology_workbench::TorrentBandwidthGlobal =
1204        serde_json::from_value(policy_json).map_err(|e| e.to_string())?;
1205    crate::ontology_workbench::save_bandwidth_policy(&policy)?;
1206    serde_json::to_value(policy).map_err(|e| e.to_string())
1207}
1208
1209pub fn list_ontology_shares_for_contact(contact_did: String) -> Result<serde_json::Value, String> {
1210    let state = crate::state::APP_STATE.get().unwrap();
1211    let storage = state.config.lock().unwrap().storage_path.clone();
1212    let cards =
1213        crate::ontology_workbench::list_share_cards_for_contact(Path::new(&storage), &contact_did)?;
1214    serde_json::to_value(cards).map_err(|e| e.to_string())
1215}
1216
1217pub fn list_ontology_shares_for_session(session_did: String) -> Result<serde_json::Value, String> {
1218    let state = crate::state::APP_STATE.get().unwrap();
1219    let storage = state.config.lock().unwrap().storage_path.clone();
1220    let cards =
1221        crate::ontology_workbench::list_share_cards_for_session(Path::new(&storage), &session_did)?;
1222    serde_json::to_value(cards).map_err(|e| e.to_string())
1223}
1224
1225pub fn list_chat_session_share_targets() -> Result<serde_json::Value, String> {
1226    let state = crate::state::APP_STATE.get().unwrap();
1227    let storage = state.config.lock().unwrap().storage_path.clone();
1228    let targets = crate::chat_session::list_session_share_targets(Path::new(&storage))
1229        .map_err(|e| e.to_string())?;
1230    serde_json::to_value(targets).map_err(|e| e.to_string())
1231}
1232
1233pub fn get_chat_session_did(session_id: String) -> Result<String, String> {
1234    let state = crate::state::APP_STATE.get().unwrap();
1235    let storage = state.config.lock().unwrap().storage_path.clone();
1236    crate::chat_session::get_session_did(Path::new(&storage), &session_id)
1237        .map_err(|e| e.to_string())
1238}
1239
1240pub fn update_chat_contact_categories(
1241    contact_did: String,
1242    categories: Vec<String>,
1243) -> Result<serde_json::Value, String> {
1244    let contact = crate::social_connect::update_contact_categories(&contact_did, categories)?;
1245    serde_json::to_value(contact).map_err(|e| e.to_string())
1246}
1247
1248pub async fn discover_models() -> Result<Vec<llm_offload::ModelInfo>, String> {
1249    use std::collections::HashSet;
1250
1251    let state = crate::state::APP_STATE.get().unwrap();
1252    let storage_path = state.config.lock().unwrap().storage_path.clone();
1253    let models_dir = PathBuf::from(&storage_path).join("Models");
1254    let active_path = load_active_model_from_disk();
1255    let mut models = Vec::new();
1256    let mut seen: HashSet<String> = HashSet::new();
1257
1258    let mut push_model = |path: &Path| {
1259        if !path.is_file() {
1260            return;
1261        }
1262        let name = path
1263            .file_name()
1264            .map(|n| n.to_string_lossy().to_string())
1265            .unwrap_or_default();
1266        let ext = path
1267            .extension()
1268            .and_then(|e| e.to_str())
1269            .unwrap_or("")
1270            .to_ascii_lowercase();
1271        if (ext != "gguf" && ext != "p64") || name.to_ascii_lowercase().contains("mmproj") {
1272            return;
1273        }
1274        // Prefer converted p64 when listing a GGUF that has a sibling container.
1275        let effective: PathBuf = if ext == "gguf" {
1276            let p64 = path.with_extension("p64");
1277            if p64.is_file() {
1278                p64
1279            } else {
1280                path.to_path_buf()
1281            }
1282        } else {
1283            path.to_path_buf()
1284        };
1285        let name = effective
1286            .file_name()
1287            .map(|n| n.to_string_lossy().to_string())
1288            .unwrap_or(name);
1289        let key = effective.to_string_lossy().to_ascii_lowercase();
1290        if !seen.insert(key) {
1291            return;
1292        }
1293        let display_name = if effective.starts_with(&models_dir) {
1294            name
1295        } else {
1296            effective.to_string_lossy().into_owned()
1297        };
1298        let is_active = active_path
1299            .as_ref()
1300            .map(|active| paths_refer_to_same_file(active, &effective))
1301            .unwrap_or(false);
1302        models.push(llm_offload::ModelInfo {
1303            name: display_name,
1304            is_active,
1305            avatar_type: if effective.starts_with(&models_dir) {
1306                "installed".to_string()
1307            } else {
1308                "local".to_string()
1309            },
1310        });
1311    };
1312
1313    if let Ok(entries) = std::fs::read_dir(&models_dir) {
1314        for entry in entries.filter_map(Result::ok) {
1315            let path = entry.path();
1316            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1317            if ext == "gguf" || ext == "p64" {
1318                push_model(&path);
1319            } else if ext == "json"
1320                && path
1321                    .file_name()
1322                    .and_then(|n| n.to_str())
1323                    .map(|n| n.ends_with(".install.json"))
1324                    .unwrap_or(false)
1325            {
1326                if let Ok(text) = std::fs::read_to_string(&path) {
1327                    if let Ok(manifest) =
1328                        serde_json::from_str::<crate::model_lifecycle::InstallManifest>(&text)
1329                    {
1330                        push_model(Path::new(&manifest.gguf_path));
1331                    }
1332                }
1333            }
1334        }
1335    }
1336
1337    if let Some(active) = active_path.as_ref() {
1338        push_model(Path::new(active));
1339    }
1340
1341    models.sort_by(|a, b| {
1342        a.name
1343            .to_ascii_lowercase()
1344            .cmp(&b.name.to_ascii_lowercase())
1345    });
1346    Ok(models)
1347}
1348
1349fn paths_refer_to_same_file(left: &str, right: &Path) -> bool {
1350    let left_path = Path::new(left);
1351    if left_path == right {
1352        return true;
1353    }
1354    left_path
1355        .file_name()
1356        .is_some_and(|left_name| right.file_name() == Some(left_name))
1357        && left.replace('\\', "/").to_ascii_lowercase()
1358            == right
1359                .to_string_lossy()
1360                .replace('\\', "/")
1361                .to_ascii_lowercase()
1362}
1363
1364pub async fn run_agent_inference(
1365    prompt: String,
1366    model_name: String,
1367    intent_layout: Vec<f64>,
1368) -> Result<(), String> {
1369    tokio::spawn(async move {
1370        let _ = llm_offload::execute_agent_inference(prompt, model_name, intent_layout).await;
1371    });
1372    Ok(())
1373}