1#![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, token_type: String, contract: String, 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 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 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 let client = crate::wallet::chronik::ChronikClient::new("https://chronik.be.cash");
247 let utxos = client.fetch_utxos_p2pkh(hash160_hex)?;
248
249 let (token_utxos, xec_selection) = coin_select::select_token_utxos(&utxos, token_id, amount)?;
251
252 let mut tx = Transaction::new();
254
255 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 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 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 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 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 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 let raw_hex = hex::encode(tx.serialize());
316 let txid = client.broadcast_tx(&raw_hex)?;
317
318 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#[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 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 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 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
426pub 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 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, direction: "out".into(),
441 }),
442 );
443
444 Ok(txid)
445}
446
447fn decode_ecash_address(addr: &str) -> Result<Vec<u8>, String> {
450 let stripped = if let Some(a) = addr.strip_prefix("ecash:") {
452 a
453 } else {
454 addr
455 };
456 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 Ok(decoded[1..21].to_vec())
465}
466
467fn 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
484pub 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, amount: String,
512 label: String,
513 timestamp: String,
514 status: String, 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_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 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 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
661fn 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 let is_outgoing = tx.inputs.iter().any(|inp| {
678 inp.output_script
679 .to_lowercase()
680 .contains(&own_script_suffix)
681 });
682
683 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; ("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 let (status_str, confirmations) = match &tx.block {
713 Some(block) => {
714 ("confirmed".to_string(), block.height as u32)
716 }
717 None => ("pending".to_string(), 0),
718 };
719
720 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 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 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(), counterparty,
773 });
774 }
775
776 Ok(records)
777}
778
779fn 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 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 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": "" }))
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 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), 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 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 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 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 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 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 let raw_hex = hex::encode(tx.serialize());
955 let txid = client.broadcast_tx(&raw_hex)?;
956
957 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(), symbol: asset_id,
967 }),
968 );
969
970 Ok(txid)
971}
972
973pub async fn fetch_wallet_portfolio() -> Result<serde_json::Value, String> {
974 let balances = get_coin_balances();
976 let tokens = get_tokens();
977
978 let mut portfolio = Vec::new();
979
980 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 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 if seed.split_whitespace().count() < 12 {
1020 return Err("Invalid seed phrase — must be at least 12 words".to_string());
1021 }
1022
1023 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 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 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 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 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 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 if current_solar < 400 {
1108 } else {
1115 _fragments_paged += 8; }
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 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}