use std::str::FromStr;
use super::Wallet;
use crate::amount::Amount;
use crate::error::{Error, Result};
use crate::hd::HdWallet as HDWallet;
use crate::webcash::{PublicWebcash, SecretWebcash, SecureString};
use crate::server::{Legalese, ReplaceRequest};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WalletStats {
pub total_webcash: u64,
pub unspent_webcash: u64,
pub spent_webcash: u64,
pub total_balance: Amount,
}
#[derive(Debug, Clone)]
pub struct CheckResult {
pub valid_count: usize,
pub spent_count: usize,
pub unknown_count: usize,
}
#[derive(Debug, Clone)]
pub struct RecoveryResult {
pub recovered_count: usize,
pub total_amount: Amount,
}
impl std::fmt::Display for RecoveryResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Recovery completed! Webcash recovered: {}, Total amount: {}",
self.recovered_count, self.total_amount
)
}
}
impl Wallet {
pub(crate) fn get_or_generate_master_secret(&self) -> Result<String> {
match self.store.get_meta("master_secret")? {
Some(secret) => Ok(secret),
None => {
let master_secret = crate::crypto::CryptoSecret::generate().map_err(|e| {
Error::crypto(format!("Failed to generate master secret: {}", e))
})?;
let hex = master_secret.to_hex();
self.store.set_meta("master_secret", &hex)?;
log::info!("Generated new master secret using hardware RNG");
Ok(hex)
}
}
}
fn get_master_secret(&self) -> Result<String> {
self.get_or_generate_master_secret()
}
pub fn master_secret_hex(&self) -> Result<String> {
self.get_master_secret()
}
fn validate_master_secret(&self, hex: &str) -> Result<[u8; 32]> {
let bytes = hex::decode(hex).map_err(|_| Error::wallet("Invalid master secret format"))?;
if bytes.len() != 32 {
return Err(Error::wallet(format!(
"Master secret must be 32 bytes, got {}",
bytes.len()
)));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(arr)
}
pub async fn store_master_secret(&self, master_secret_hex: &str) -> Result<()> {
self.store.set_meta("master_secret", master_secret_hex)?;
log::info!("Master secret stored in wallet for recovery purposes");
Ok(())
}
fn read_chain_depth(&self, chain_name: &str) -> Result<u64> {
self.store.get_depth(chain_name)
}
fn hd_wallet(&self) -> Result<HDWallet> {
let hex = self.get_master_secret()?;
let arr = self.validate_master_secret(&hex)?;
Ok(HDWallet::from_master_secret(arr))
}
}
impl Wallet {
pub async fn balance(&self) -> Result<String> {
Ok(self.balance_amount().await?.to_string())
}
pub async fn balance_amount(&self) -> Result<Amount> {
Ok(Amount::from_wats(self.store.sum_unspent()?))
}
pub async fn list_webcash(&self) -> Result<Vec<SecretWebcash>> {
let rows = self.store.get_unspent()?;
Ok(rows
.into_iter()
.map(|(secret, wats)| {
SecretWebcash::new(SecureString::new(secret), Amount::from_wats(wats))
})
.collect())
}
pub async fn list_public_webcash(&self) -> Result<Vec<PublicWebcash>> {
Ok(self
.list_webcash()
.await?
.iter()
.map(|wc| wc.to_public())
.collect())
}
pub async fn stats(&self) -> Result<WalletStats> {
Ok(WalletStats {
total_webcash: self.store.count_outputs()?,
unspent_webcash: self.store.count_unspent()?,
spent_webcash: self.store.count_spent_hashes()?,
total_balance: Amount::from_wats(self.store.sum_unspent()?),
})
}
}
impl Wallet {
pub async fn store_directly(&self, webcash: SecretWebcash) -> Result<()> {
let secret_str = webcash
.secret
.as_str()
.map_err(|_| Error::wallet("Invalid secret encoding"))?;
let secret_hash = crate::crypto::sha256(secret_str.as_bytes());
self.store
.insert_output(&secret_hash, secret_str, webcash.amount.wats)?;
log::debug!("Webcash stored directly: {}", webcash.amount);
Ok(())
}
pub async fn insert(&self, webcash: SecretWebcash) -> Result<()> {
self.insert_with_validation(webcash, false).await
}
pub async fn insert_with_validation(
&self,
webcash: SecretWebcash,
validate_with_server: bool,
) -> Result<()> {
log::debug!("Starting webcash insertion with ownership transfer");
let hd_wallet = self.hd_wallet()?;
let depth = self.read_chain_depth("RECEIVE")?;
let new_secret_hex = hd_wallet.derive_secret(crate::hd::ChainCode::Receive, depth);
let new_webcash = SecretWebcash::new(SecureString::new(new_secret_hex), webcash.amount);
if validate_with_server {
self.validate_input_webcash(&webcash).await?;
}
let replace_request = ReplaceRequest {
webcashes: vec![webcash.to_string()],
new_webcashes: vec![new_webcash.to_string()],
legalese: Legalese { terms: true },
};
match self.server_replace(&replace_request).await {
Ok(resp) if resp.status == "success" => {
log::info!("Server replacement successful — ownership transferred");
let new_secret_str = new_webcash
.secret
.as_str()
.map_err(|_| Error::wallet("Invalid new secret encoding"))?;
let new_secret_hash = crate::crypto::sha256(new_secret_str.as_bytes());
self.store.insert_output(
&new_secret_hash,
new_secret_str,
new_webcash.amount.wats,
)?;
self.store.set_depth("RECEIVE", depth + 1)?;
log::info!("Inserted webcash at RECEIVE/{}", depth);
Ok(())
}
Err(Error::Server { ref message })
if message.contains("can only be replaced by itself") =>
{
log::info!(
"Same-lineage secret webcash detected, storing directly without replace"
);
let public_webcash = webcash.to_public();
let health_response = self
.server_health_check(std::slice::from_ref(&public_webcash))
.await?;
if health_response.status != "success" {
return Err(Error::server(
"Health check failed for same-lineage fallback",
));
}
if let Some(hr) = health_response.results.get(&public_webcash.to_string()) {
if hr.spent == Some(true) {
return Err(Error::wallet("Input webcash has been spent"));
}
}
self.store_directly(webcash).await
}
Ok(_) => Err(Error::server("Server replacement failed")),
Err(e) => Err(e),
}
}
async fn validate_input_webcash(&self, webcash: &SecretWebcash) -> Result<()> {
let public_webcash = webcash.to_public();
let health = self
.server_health_check(std::slice::from_ref(&public_webcash))
.await?;
if health.status != "success" {
return Err(Error::server("Server validation failed"));
}
if let Some(result) = health.results.get(&public_webcash.to_string()) {
if let Some(true) = result.spent {
return Err(Error::wallet("Input webcash has been spent"));
}
if let Some(ref server_amount) = result.amount {
let expected = Amount::from_str(server_amount).map_err(|_| {
Error::wallet(format!("Invalid amount from server: {}", server_amount))
})?;
if webcash.amount != expected {
return Err(Error::wallet(format!(
"Amount mismatch: provided {}, server says {}",
webcash.amount, expected
)));
}
}
} else {
return Err(Error::server("Input webcash not found in server response"));
}
Ok(())
}
}
impl Wallet {
pub async fn pay(&self, amount: Amount, memo: &str) -> Result<String> {
log::info!("Starting payment: amount={}, memo={}", amount, memo);
let hd_wallet = self.hd_wallet()?;
let inputs = self.select_inputs(amount).await?;
if inputs.is_empty() {
return Err(Error::wallet("Insufficient funds"));
}
let input_total: Amount = inputs.iter().fold(Amount::ZERO, |acc, wc| acc + wc.amount);
let change_amount = input_total - amount;
let pay_depth = self.read_chain_depth("PAY")?;
let change_depth = self.read_chain_depth("CHANGE")?;
let pay_secret = hd_wallet.derive_secret(crate::hd::ChainCode::Pay, pay_depth);
let payment_webcash = SecretWebcash::new(SecureString::new(pay_secret), amount);
let mut new_webcashes = vec![payment_webcash.to_string()];
let change_webcash = if change_amount > Amount::ZERO {
let change_secret = hd_wallet.derive_secret(crate::hd::ChainCode::Change, change_depth);
let cw = SecretWebcash::new(SecureString::new(change_secret), change_amount);
new_webcashes.push(cw.to_string());
Some(cw)
} else {
None
};
let replace_request = ReplaceRequest {
webcashes: inputs.iter().map(|wc| wc.to_string()).collect(),
new_webcashes,
legalese: Legalese { terms: true },
};
let response = self.server_replace(&replace_request).await?;
if response.status != "success" {
return Err(Error::server("Payment transaction failed"));
}
let change_data = change_webcash.as_ref().map(|cw| {
let s = cw.secret.as_str().unwrap_or("");
let h = crate::crypto::sha256(s.as_bytes());
(h, s.to_string(), cw.amount.wats)
});
let has_change = change_webcash.is_some();
self.store.atomic(&mut |store| {
for input in &inputs {
let secret_str = input.secret.as_str().unwrap_or("");
let secret_hash = crate::crypto::sha256(secret_str.as_bytes());
store.mark_spent(&secret_hash)?;
store.insert_spent_hash(&secret_hash)?;
}
if let Some((ref h, ref s, wats)) = change_data {
store.insert_output(h, s, wats)?;
}
store.set_depth("PAY", pay_depth + 1)?;
if has_change {
store.set_depth("CHANGE", change_depth + 1)?;
}
Ok(())
})?;
Ok(format!(
"Payment completed! Send this webcash to recipient: {}",
payment_webcash
))
}
async fn select_inputs(&self, amount: Amount) -> Result<Vec<SecretWebcash>> {
let rows = self.store.get_unspent()?;
let mut selected = Vec::new();
let mut total = Amount::ZERO;
for (secret_str, wats) in rows {
let wc_amount = Amount::from_wats(wats);
selected.push(SecretWebcash::new(SecureString::new(secret_str), wc_amount));
total += wc_amount;
if total >= amount {
break;
}
}
if total < amount {
return Err(Error::wallet("Insufficient funds"));
}
Ok(selected)
}
pub async fn mark_inputs_spent(&self, inputs: &[SecretWebcash]) -> Result<()> {
for input in inputs {
let secret_str = input.secret.as_str().unwrap_or("");
let secret_hash = crate::crypto::sha256(secret_str.as_bytes());
self.store.mark_spent(&secret_hash)?;
self.store.insert_spent_hash(&secret_hash)?;
}
Ok(())
}
pub async fn update_unspent_amount(
&self,
secret_webcash: &SecretWebcash,
correct_amount: Amount,
) -> Result<()> {
let secret_str = secret_webcash.secret.as_str().unwrap_or("");
let secret_hash = crate::crypto::sha256(secret_str.as_bytes());
self.store
.update_output_amount(&secret_hash, correct_amount.wats)?;
Ok(())
}
}
impl Wallet {
pub async fn check(&self) -> Result<CheckResult> {
let public_webcash_list = self.list_public_webcash().await?;
if public_webcash_list.is_empty() {
return Ok(CheckResult {
valid_count: 0,
spent_count: 0,
unknown_count: 0,
});
}
let health_response = self.server_health_check(&public_webcash_list).await?;
if health_response.status != "success" {
return Err(Error::server("Server returned non-success status"));
}
let mut valid_count = 0;
let mut spent_count = 0;
for health_result in health_response.results.values() {
if let Some(true) = health_result.spent {
spent_count += 1;
} else {
valid_count += 1;
}
}
Ok(CheckResult {
valid_count,
spent_count,
unknown_count: 0,
})
}
}
impl Wallet {
pub async fn merge(&self, max_outputs: usize) -> Result<String> {
log::info!("Starting output consolidation");
let all_webcash = self.list_webcash().await?;
if all_webcash.len() <= 1 {
return Ok("No consolidation needed".to_string());
}
let webcash_to_merge = if all_webcash.len() > max_outputs {
&all_webcash[..max_outputs]
} else {
&all_webcash
};
if webcash_to_merge.len() <= 1 {
return Ok("Insufficient outputs to merge".to_string());
}
let total_amount: Amount = webcash_to_merge
.iter()
.fold(Amount::ZERO, |acc, wc| acc + wc.amount);
let hd_wallet = self.hd_wallet()?;
let change_depth = self.read_chain_depth("CHANGE")?;
let change_secret_hex = hd_wallet.derive_secret(crate::hd::ChainCode::Change, change_depth);
let consolidated_webcash =
SecretWebcash::new(SecureString::new(change_secret_hex), total_amount);
let replace_request = ReplaceRequest {
webcashes: webcash_to_merge.iter().map(|wc| wc.to_string()).collect(),
new_webcashes: vec![consolidated_webcash.to_string()],
legalese: Legalese { terms: true },
};
let response = self.server_replace(&replace_request).await?;
if response.status != "success" {
return Err(Error::server("Consolidation transaction failed"));
}
let cs = consolidated_webcash
.secret
.as_str()
.map_err(|_| Error::wallet("Invalid consolidated secret"))?
.to_string();
let ch = crate::crypto::sha256(cs.as_bytes());
let cons_wats = consolidated_webcash.amount.wats;
let merge_count = webcash_to_merge.len();
self.store.atomic(&mut |store| {
for input in webcash_to_merge {
let s = input.secret.as_str().unwrap_or("");
let h = crate::crypto::sha256(s.as_bytes());
store.mark_spent(&h)?;
store.insert_spent_hash(&h)?;
}
store.insert_output(&ch, &cs, cons_wats)?;
store.set_depth("CHANGE", change_depth + 1)?;
Ok(())
})?;
Ok(format!(
"Consolidation completed: {} outputs merged, total {} preserved",
merge_count, total_amount
))
}
}
impl Wallet {
pub async fn recover_from_wallet(&self, gap_limit: usize) -> Result<RecoveryResult> {
match self.store.get_meta("master_secret")? {
Some(secret) => {
log::info!("Found stored master secret, proceeding with recovery");
self.recover(&secret, gap_limit).await
}
None => Err(Error::wallet("No master secret found in wallet")),
}
}
pub async fn recover(
&self,
master_secret_hex: &str,
gap_limit: usize,
) -> Result<RecoveryResult> {
use crate::core::ChainCode as CoreChain;
use crate::hd::HdWallet as CoreHd;
use crate::server_client::Client as CoreClient;
use crate::wallet_webcash::Webcash;
use std::collections::HashMap;
log::info!("Starting wallet recovery with gap_limit={}", gap_limit);
if gap_limit == 0 {
return Err(Error::wallet("gap_limit must be > 0"));
}
let hd = CoreHd::from_hex(master_secret_hex)
.map_err(|e| Error::wallet(format!("Invalid master secret: {e}")))?;
let chain_codes: [(&str, CoreChain); 4] = [
("RECEIVE", CoreChain::Receive),
("PAY", CoreChain::Pay),
("CHANGE", CoreChain::Change),
("MINING", CoreChain::Mining),
];
let mut reported_depths: HashMap<CoreChain, u64> = HashMap::new();
for (name, code) in &chain_codes {
reported_depths.insert(*code, self.store.get_depth(name)?);
}
let base_url = self.network.base_url().to_string();
let report = tokio::task::spawn_blocking(move || {
let client = CoreClient::new(base_url);
crate::core::recover::<Webcash>(&client, &hd, &(), gap_limit as u64, &reported_depths)
})
.await
.map_err(|e| Error::wallet(format!("recover task: {e}")))?
.map_err(|e| Error::server(format!("recovery: {e}")))?;
let mut recovered_count: usize = 0;
let mut total_recovered_amount = Amount::ZERO;
for output in &report.recovered {
let amount_wats = output.amount_wats.expect(
"Webcash recovery must produce Some(amount_wats); \
SERVER_REPORTS_AMOUNT=true",
);
let amount = Amount::from_wats(amount_wats);
let webcash = SecretWebcash::new(SecureString::new(output.secret_hex.clone()), amount);
match self.store_directly(webcash).await {
Ok(()) => {
recovered_count += 1;
total_recovered_amount += amount;
log::info!(
"Recovered: {} at {:?}/{}",
amount,
output.chain,
output.depth
);
}
Err(e)
if e.to_string().contains("UNIQUE constraint")
|| e.to_string().contains("already exists") =>
{
}
Err(e) => return Err(e),
}
}
for (name, code) in &chain_codes {
let reported = self.store.get_depth(name)?;
if let Some(seen) = report.last_used_depth.get(code).copied() {
if seen + 1 > reported {
self.store.set_depth(name, seen + 1)?;
}
}
}
Ok(RecoveryResult {
recovered_count,
total_amount: total_recovered_amount,
})
}
}
impl Wallet {
pub fn derive_next_secret(&self, chain_code: crate::hd::ChainCode) -> Result<(String, u64)> {
let master_secret_hex = self.get_master_secret()?;
let master_secret_array = self.validate_master_secret(&master_secret_hex)?;
let hd_wallet = HDWallet::from_master_secret(master_secret_array);
let chain_name = match chain_code {
crate::hd::ChainCode::Receive => "RECEIVE",
crate::hd::ChainCode::Pay => "PAY",
crate::hd::ChainCode::Change => "CHANGE",
crate::hd::ChainCode::Mining => "MINING",
};
let depth = self.store.get_depth(chain_name)?;
let secret_hex = hd_wallet.derive_secret(chain_code, depth);
self.store.set_depth(chain_name, depth + 1)?;
Ok((secret_hex, depth))
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn mine(&self) -> Result<crate::miner::MineResult> {
crate::miner::mine(self).await
}
}
use crate::server::{HealthResponse, ReplaceResponse};
impl Wallet {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn server_replace(&self, req: &ReplaceRequest) -> Result<ReplaceResponse> {
self.server_client.lock().await.replace(req).await
}
#[cfg(target_arch = "wasm32")]
pub(crate) async fn server_replace(&self, req: &ReplaceRequest) -> Result<ReplaceResponse> {
self.server_client.replace(req).await
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn server_health_check(
&self,
webcash: &[PublicWebcash],
) -> Result<HealthResponse> {
self.server_client.lock().await.health_check(webcash).await
}
#[cfg(target_arch = "wasm32")]
pub(crate) async fn server_health_check(
&self,
webcash: &[PublicWebcash],
) -> Result<HealthResponse> {
self.server_client.health_check(webcash).await
}
pub async fn server_get_target(&self) -> Result<crate::server::TargetResponse> {
#[cfg(not(target_arch = "wasm32"))]
{
self.server_client.lock().await.get_target().await
}
#[cfg(target_arch = "wasm32")]
{
self.server_client.get_target().await
}
}
pub async fn server_submit_mining_report(
&self,
report: &crate::server::MiningReportRequest,
) -> Result<crate::server::MiningReportResponse> {
#[cfg(not(target_arch = "wasm32"))]
{
self.server_client
.lock()
.await
.submit_mining_report(report)
.await
}
#[cfg(target_arch = "wasm32")]
{
self.server_client.submit_mining_report(report).await
}
}
}