use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MobileWalletStandard {
Bip44Legacy,
Bip49WrappedSegwit,
Bip84NativeSegwit,
Bip86Taproot,
MuunCustom,
LightningCompatible,
}
impl MobileWalletStandard {
pub fn derivation_purpose(&self) -> Option<u32> {
match self {
Self::Bip44Legacy => Some(44),
Self::Bip49WrappedSegwit => Some(49),
Self::Bip84NativeSegwit => Some(84),
Self::Bip86Taproot => Some(86),
Self::MuunCustom | Self::LightningCompatible => None,
}
}
pub fn wallet_name(&self) -> &'static str {
match self {
Self::Bip44Legacy => "BIP44 Legacy",
Self::Bip49WrappedSegwit => "BIP49 Wrapped SegWit",
Self::Bip84NativeSegwit => "BIP84 Native SegWit",
Self::Bip86Taproot => "BIP86 Taproot",
Self::MuunCustom => "Muun Custom",
Self::LightningCompatible => "Lightning Compatible",
}
}
pub fn is_standard_bip32(&self) -> bool {
!matches!(self, Self::MuunCustom | Self::LightningCompatible)
}
pub fn gap_limit(&self) -> u32 {
match self {
Self::LightningCompatible => 5,
_ => 20,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MobileWalletExport {
pub standard: MobileWalletStandard,
pub xpub: String,
pub derivation_path: String,
pub master_fingerprint: String,
pub network: String,
pub account: u32,
pub creation_timestamp: Option<u64>,
}
impl MobileWalletExport {
pub fn new(
standard: MobileWalletStandard,
xpub: String,
account: u32,
network: bitcoin::Network,
) -> Self {
let derivation_path = standard
.derivation_purpose()
.map(|p| {
let coin = if network == bitcoin::Network::Bitcoin {
0
} else {
1
};
format!("m/{}'/{}'/{}'", p, coin, account)
})
.unwrap_or_else(|| "m/custom".to_string());
#[allow(unreachable_patterns)]
let network_str = match network {
bitcoin::Network::Bitcoin => "mainnet",
bitcoin::Network::Testnet | bitcoin::Network::Testnet4 => "testnet",
bitcoin::Network::Regtest => "regtest",
bitcoin::Network::Signet => "signet",
_ => "unknown",
};
Self {
standard,
xpub,
derivation_path,
master_fingerprint: String::new(),
network: network_str.to_string(),
account,
creation_timestamp: None,
}
}
pub fn with_fingerprint(mut self, fingerprint: String) -> Self {
self.master_fingerprint = fingerprint;
self
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
pub fn is_testnet(&self) -> bool {
self.network != "mainnet"
}
}
#[derive(Debug, Clone)]
pub struct MobileWalletScanner {
pub standard: MobileWalletStandard,
pub gap_limit: u32,
pub account: u32,
pub max_accounts: u32,
}
impl Default for MobileWalletScanner {
fn default() -> Self {
Self {
standard: MobileWalletStandard::Bip84NativeSegwit,
gap_limit: 20,
account: 0,
max_accounts: 5,
}
}
}
impl MobileWalletScanner {
pub fn new(standard: MobileWalletStandard) -> Self {
let gap_limit = standard.gap_limit();
Self {
standard,
gap_limit,
account: 0,
max_accounts: 5,
}
}
pub fn receive_path(&self, index: u32) -> String {
let purpose = self.standard.derivation_purpose().unwrap_or(84);
format!("m/{}'/{}'/{}'/{}/{}", purpose, 0, self.account, 0, index)
}
pub fn change_path(&self, index: u32) -> String {
let purpose = self.standard.derivation_purpose().unwrap_or(84);
format!("m/{}'/{}'/{}'/{}/{}", purpose, 0, self.account, 1, index)
}
pub fn all_accounts_paths(&self) -> Vec<String> {
(0..self.max_accounts)
.map(|a| {
let purpose = self.standard.derivation_purpose().unwrap_or(84);
format!("m/{}'/{}'/{}'", purpose, 0, a)
})
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalletRestoreInfo {
pub detected_standard: Option<MobileWalletStandard>,
pub xpub: String,
pub account_paths: Vec<String>,
pub estimated_address_count: u32,
pub gap_limit: u32,
}
pub struct MobileWalletDetector;
impl MobileWalletDetector {
pub fn detect_from_xpub(xpub: &str) -> Option<MobileWalletStandard> {
if xpub.starts_with("xpub") || xpub.starts_with("tpub") {
Some(MobileWalletStandard::Bip44Legacy)
} else if xpub.starts_with("ypub") || xpub.starts_with("upub") {
Some(MobileWalletStandard::Bip49WrappedSegwit)
} else if xpub.starts_with("zpub") || xpub.starts_with("vpub") {
Some(MobileWalletStandard::Bip84NativeSegwit)
} else {
None
}
}
pub fn suggest_standard(path: &str) -> Option<MobileWalletStandard> {
if path.contains("44'") {
Some(MobileWalletStandard::Bip44Legacy)
} else if path.contains("49'") {
Some(MobileWalletStandard::Bip49WrappedSegwit)
} else if path.contains("84'") {
Some(MobileWalletStandard::Bip84NativeSegwit)
} else if path.contains("86'") {
Some(MobileWalletStandard::Bip86Taproot)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mobile_wallet_standard_name() {
assert_eq!(
MobileWalletStandard::Bip44Legacy.wallet_name(),
"BIP44 Legacy"
);
assert_eq!(
MobileWalletStandard::Bip49WrappedSegwit.wallet_name(),
"BIP49 Wrapped SegWit"
);
assert_eq!(
MobileWalletStandard::Bip84NativeSegwit.wallet_name(),
"BIP84 Native SegWit"
);
assert_eq!(
MobileWalletStandard::Bip86Taproot.wallet_name(),
"BIP86 Taproot"
);
assert_eq!(
MobileWalletStandard::MuunCustom.wallet_name(),
"Muun Custom"
);
assert_eq!(
MobileWalletStandard::LightningCompatible.wallet_name(),
"Lightning Compatible"
);
}
#[test]
fn test_mobile_wallet_standard_purpose() {
assert_eq!(
MobileWalletStandard::Bip44Legacy.derivation_purpose(),
Some(44)
);
assert_eq!(
MobileWalletStandard::Bip49WrappedSegwit.derivation_purpose(),
Some(49)
);
assert_eq!(
MobileWalletStandard::Bip84NativeSegwit.derivation_purpose(),
Some(84)
);
assert_eq!(
MobileWalletStandard::Bip86Taproot.derivation_purpose(),
Some(86)
);
assert_eq!(MobileWalletStandard::MuunCustom.derivation_purpose(), None);
assert_eq!(
MobileWalletStandard::LightningCompatible.derivation_purpose(),
None
);
}
#[test]
fn test_mobile_wallet_gap_limit() {
assert_eq!(MobileWalletStandard::Bip84NativeSegwit.gap_limit(), 20);
assert_eq!(MobileWalletStandard::Bip44Legacy.gap_limit(), 20);
assert_eq!(MobileWalletStandard::LightningCompatible.gap_limit(), 5);
assert_eq!(MobileWalletStandard::MuunCustom.gap_limit(), 20);
}
#[test]
fn test_mobile_wallet_export_new() {
let export = MobileWalletExport::new(
MobileWalletStandard::Bip84NativeSegwit,
"zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYe2Bgb44XGX78".to_string(),
0,
bitcoin::Network::Bitcoin,
);
assert_eq!(export.derivation_path, "m/84'/0'/0'");
assert_eq!(export.network, "mainnet");
assert_eq!(export.account, 0);
assert!(export.master_fingerprint.is_empty());
assert!(!export.is_testnet());
}
#[test]
fn test_mobile_wallet_export_json_roundtrip() {
let export = MobileWalletExport::new(
MobileWalletStandard::Bip44Legacy,
"xpubDEAD".to_string(),
2,
bitcoin::Network::Bitcoin,
)
.with_fingerprint("aabbccdd".to_string());
let json = export.to_json().expect("serialize");
let restored = MobileWalletExport::from_json(&json).expect("deserialize");
assert_eq!(restored.standard, MobileWalletStandard::Bip44Legacy);
assert_eq!(restored.xpub, "xpubDEAD");
assert_eq!(restored.account, 2);
assert_eq!(restored.master_fingerprint, "aabbccdd");
}
#[test]
fn test_mobile_wallet_export_testnet() {
let export = MobileWalletExport::new(
MobileWalletStandard::Bip84NativeSegwit,
"vpub...".to_string(),
0,
bitcoin::Network::Testnet,
);
assert!(export.is_testnet());
assert_eq!(export.derivation_path, "m/84'/1'/0'");
}
#[test]
fn test_mobile_wallet_scanner_paths() {
let scanner = MobileWalletScanner::new(MobileWalletStandard::Bip84NativeSegwit);
assert_eq!(scanner.receive_path(0), "m/84'/0'/0'/0/0");
assert_eq!(scanner.receive_path(5), "m/84'/0'/0'/0/5");
assert_eq!(scanner.change_path(0), "m/84'/0'/0'/1/0");
assert_eq!(scanner.change_path(3), "m/84'/0'/0'/1/3");
}
#[test]
fn test_mobile_wallet_scanner_all_accounts() {
let scanner = MobileWalletScanner {
standard: MobileWalletStandard::Bip84NativeSegwit,
gap_limit: 20,
account: 0,
max_accounts: 3,
};
let paths = scanner.all_accounts_paths();
assert_eq!(paths.len(), 3);
assert_eq!(paths[0], "m/84'/0'/0'");
assert_eq!(paths[1], "m/84'/0'/1'");
assert_eq!(paths[2], "m/84'/0'/2'");
}
#[test]
fn test_mobile_wallet_detector_from_xpub() {
assert_eq!(
MobileWalletDetector::detect_from_xpub("xpubABC"),
Some(MobileWalletStandard::Bip44Legacy)
);
assert_eq!(
MobileWalletDetector::detect_from_xpub("tpubXYZ"),
Some(MobileWalletStandard::Bip44Legacy)
);
assert_eq!(
MobileWalletDetector::detect_from_xpub("ypub123"),
Some(MobileWalletStandard::Bip49WrappedSegwit)
);
assert_eq!(
MobileWalletDetector::detect_from_xpub("upub456"),
Some(MobileWalletStandard::Bip49WrappedSegwit)
);
assert_eq!(
MobileWalletDetector::detect_from_xpub("zpubAAA"),
Some(MobileWalletStandard::Bip84NativeSegwit)
);
assert_eq!(
MobileWalletDetector::detect_from_xpub("vpubBBB"),
Some(MobileWalletStandard::Bip84NativeSegwit)
);
assert_eq!(
MobileWalletDetector::detect_from_xpub("unknown_prefix"),
None
);
}
#[test]
fn test_mobile_wallet_detector_suggest_standard() {
assert_eq!(
MobileWalletDetector::suggest_standard("m/44'/0'/0'"),
Some(MobileWalletStandard::Bip44Legacy)
);
assert_eq!(
MobileWalletDetector::suggest_standard("m/49'/0'/0'"),
Some(MobileWalletStandard::Bip49WrappedSegwit)
);
assert_eq!(
MobileWalletDetector::suggest_standard("m/84'/0'/0'"),
Some(MobileWalletStandard::Bip84NativeSegwit)
);
assert_eq!(
MobileWalletDetector::suggest_standard("m/86'/0'/0'"),
Some(MobileWalletStandard::Bip86Taproot)
);
assert_eq!(MobileWalletDetector::suggest_standard("m/custom"), None);
}
#[test]
fn test_mobile_wallet_standard_is_standard_bip32() {
assert!(MobileWalletStandard::Bip44Legacy.is_standard_bip32());
assert!(MobileWalletStandard::Bip49WrappedSegwit.is_standard_bip32());
assert!(MobileWalletStandard::Bip84NativeSegwit.is_standard_bip32());
assert!(MobileWalletStandard::Bip86Taproot.is_standard_bip32());
assert!(!MobileWalletStandard::MuunCustom.is_standard_bip32());
assert!(!MobileWalletStandard::LightningCompatible.is_standard_bip32());
}
#[test]
fn test_wallet_restore_info_serde() {
let info = WalletRestoreInfo {
detected_standard: Some(MobileWalletStandard::Bip84NativeSegwit),
xpub: "zpubABC".to_string(),
account_paths: vec!["m/84'/0'/0'".to_string()],
estimated_address_count: 42,
gap_limit: 20,
};
let json = serde_json::to_string(&info).expect("serialize");
let back: WalletRestoreInfo = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
back.detected_standard,
Some(MobileWalletStandard::Bip84NativeSegwit)
);
assert_eq!(back.estimated_address_count, 42);
assert_eq!(back.gap_limit, 20);
}
#[test]
fn test_mobile_wallet_export_muun_path() {
let export = MobileWalletExport::new(
MobileWalletStandard::MuunCustom,
"xpubMUUN".to_string(),
0,
bitcoin::Network::Bitcoin,
);
assert_eq!(export.derivation_path, "m/custom");
}
}