use super::error::{Error, ErrorKind, Result};
#[cfg(feature = "cli")]
use crate::protocol::base58;
use base64::{Engine as _, engine::general_purpose};
use ed25519_dalek::SigningKey;
use serde::Deserialize;
#[cfg(feature = "cli")]
use sha2::{Digest, Sha256};
use std::env;
#[cfg(feature = "cli")]
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use zeroize::Zeroize;
#[derive(Deserialize, Default)]
pub(super) struct WalletFile {
pub(super) addr: Option<String>,
pub(super) address: Option<String>,
pub(super) priv_: Option<String>,
#[serde(rename = "priv")]
pub(super) priv_field: Option<String>,
pub(super) private_key: Option<String>,
pub(super) private_key_b64: Option<String>,
pub(super) pub_: Option<String>,
#[serde(rename = "pub")]
pub(super) pub_field: Option<String>,
pub(super) public_key: Option<String>,
pub(super) public_key_b64: Option<String>,
pub(super) rpc: Option<String>,
#[cfg(feature = "cli")]
#[serde(rename = "keyPair")]
pub(super) key_pair: Option<WalletKeyPair>,
}
#[cfg(feature = "cli")]
#[derive(Deserialize, Default)]
pub(super) struct WalletKeyPair {
#[serde(rename = "secretKey")]
pub(super) secret_key: Option<String>,
#[serde(rename = "publicKey")]
pub(super) public_key: Option<String>,
}
#[derive(PartialEq, Eq)]
#[cfg(feature = "cli")]
pub(crate) struct WalletMaterial {
pub(crate) address: String,
pub(crate) private_key_b64: String,
pub(crate) public_key_b64: String,
}
#[cfg(feature = "cli")]
impl fmt::Debug for WalletMaterial {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WalletMaterial")
.field("address", &self.address)
.field("private_key_b64", &"<redacted>")
.field("public_key_b64", &self.public_key_b64)
.finish()
}
}
#[cfg(feature = "cli")]
impl Drop for WalletMaterial {
fn drop(&mut self) {
self.private_key_b64.zeroize();
}
}
pub(super) fn load_wallet(path: Option<&Path>) -> Result<WalletFile> {
match path {
Some(path) => {
let mut text = fs::read_to_string(path).map_err(|error| {
Error::with_kind(
ErrorKind::Io,
format!("reading wallet {}: {error}", path.display()),
)
})?;
let parsed = serde_json::from_str(&text).map_err(|error| {
Error::with_kind(
ErrorKind::Wallet,
format!("parsing wallet {}: {error}", path.display()),
)
});
text.zeroize();
Ok(parsed?)
}
None => Ok(WalletFile::default()),
}
}
pub fn discover_wallet_path() -> Option<PathBuf> {
wallet_candidates().into_iter().find(|path| path.is_file())
}
pub fn wallet_caller(path: Option<&Path>, caller: Option<&str>) -> Result<Option<String>> {
let wallet = load_wallet(path)?;
Ok(first_string(&[
caller.map(str::to_string),
wallet.addr,
wallet.address,
env::var("OCTRA_CALLER").ok(),
]))
}
#[cfg(feature = "cli")]
pub(crate) fn wallet_file_material(path: &Path) -> Result<WalletMaterial> {
let wallet = load_wallet(Some(path))?;
let key_pair = wallet.key_pair.unwrap_or_default();
let supplied_address = first_string(&[wallet.addr, wallet.address]).ok_or_else(|| {
Error::with_kind(
ErrorKind::Wallet,
format!("wallet {} is missing address/addr", path.display()),
)
})?;
let mut private_key = first_secret_string([
wallet.priv_field,
wallet.priv_,
wallet.private_key,
wallet.private_key_b64,
key_pair.secret_key,
])
.ok_or_else(|| {
Error::with_kind(
ErrorKind::Wallet,
format!(
"wallet {} is missing private_key_b64/priv or keyPair.secretKey",
path.display()
),
)
})?;
let supplied_public_key = first_string(&[
wallet.pub_field,
wallet.pub_,
wallet.public_key,
wallet.public_key_b64,
key_pair.public_key,
]);
let material = wallet_material_from_private_key(&private_key, supplied_public_key)?;
private_key.zeroize();
if material.address != supplied_address {
return Err(Error::with_kind(
ErrorKind::Wallet,
format!(
"wallet address {} does not match private key-derived address {}",
supplied_address, material.address
),
));
}
Ok(material)
}
#[cfg(feature = "cli")]
pub(crate) fn wallet_material_from_private_key(
private_key: &str,
public_key: Option<String>,
) -> Result<WalletMaterial> {
let key = signing_key_from_text(private_key)?;
let public_key_bytes = key.verifying_key().to_bytes();
let public_key_b64 = match public_key {
Some(text) => normalized_public_key_b64(&text, &public_key_bytes)?,
None => general_purpose::STANDARD.encode(public_key_bytes),
};
Ok(WalletMaterial {
address: address_from_public_key(&public_key_bytes),
private_key_b64: general_purpose::STANDARD.encode(key.to_bytes()),
public_key_b64,
})
}
pub(super) fn signing_key_from_text(text: &str) -> Result<SigningKey> {
let cleaned = clean_key_text(text);
let mut raw = decode_key_text(&cleaned)
.ok_or_else(|| Error::with_kind(ErrorKind::Wallet, "private key must be base64 or hex"))?;
if raw.len() != 32 && raw.len() != 64 {
raw.zeroize();
return Err(Error::with_kind(
ErrorKind::Wallet,
"private key must decode to a 32-byte seed or 64-byte keypair",
));
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&raw[..32]);
let signing_key = SigningKey::from_bytes(&seed);
if raw.len() == 64 && raw[32..] != signing_key.verifying_key().to_bytes() {
seed.zeroize();
raw.zeroize();
return Err(Error::with_kind(
ErrorKind::Wallet,
"64-byte private key public half does not match seed",
));
}
seed.zeroize();
raw.zeroize();
Ok(signing_key)
}
pub(super) fn normalized_public_key_b64(text: &str, expected: &[u8; 32]) -> Result<String> {
let cleaned = clean_key_text(text);
let mut raw = decode_key_text(&cleaned)
.ok_or_else(|| Error::with_kind(ErrorKind::Wallet, "public key must be base64 or hex"))?;
if raw.len() != 32 {
raw.zeroize();
return Err(Error::with_kind(
ErrorKind::Wallet,
"public key must decode to 32 bytes",
));
}
if raw.as_slice() != expected {
raw.zeroize();
return Err(Error::with_kind(
ErrorKind::Wallet,
"wallet public key does not match private key",
));
}
let public_key = general_purpose::STANDARD.encode(&raw);
raw.zeroize();
Ok(public_key)
}
fn wallet_candidates() -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Ok(cwd) = env::current_dir() {
candidates.push(cwd.join("wallet.json"));
candidates.push(cwd.join(".octra").join("wallet.json"));
}
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".octra").join("wallet.json"));
candidates.push(home.join(".octra").join("devnet-wallet.json"));
}
candidates
}
fn first_string(values: &[Option<String>]) -> Option<String> {
values
.iter()
.find_map(|value| value.as_ref().filter(|v| !v.is_empty()).cloned())
}
#[cfg(feature = "cli")]
fn first_secret_string(values: impl IntoIterator<Item = Option<String>>) -> Option<String> {
let mut selected = None;
for mut value in values.into_iter().flatten() {
if value.is_empty() {
value.zeroize();
continue;
}
if selected.is_none() {
selected = Some(value);
} else {
value.zeroize();
}
}
selected
}
fn clean_key_text(text: &str) -> String {
text.chars().filter(|ch| !ch.is_whitespace()).collect()
}
fn decode_key_text(cleaned: &str) -> Option<Vec<u8>> {
let looks_hex =
cleaned.len().is_multiple_of(2) && cleaned.as_bytes().iter().all(|b| b.is_ascii_hexdigit());
if looks_hex {
return hex::decode(cleaned).ok();
}
general_purpose::STANDARD.decode(cleaned).ok()
}
#[cfg(feature = "cli")]
fn address_from_public_key(public_key: &[u8; 32]) -> String {
let digest = Sha256::digest(public_key);
let mut encoded = base58::encode(&digest);
while encoded.len() < 44 {
encoded.insert(0, '1');
}
format!("oct{encoded}")
}
#[cfg(all(test, feature = "cli"))]
mod tests {
use super::*;
#[test]
fn wallet_material_derives_octra_address() {
let material =
wallet_material_from_private_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", None)
.unwrap();
assert!(material.address.starts_with("oct"));
assert_eq!(material.address.len(), 47);
assert_eq!(
material.private_key_b64,
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
);
assert!(!material.public_key_b64.is_empty());
}
#[test]
fn wallet_material_strips_private_key_whitespace() {
let material = wallet_material_from_private_key(
"AAAA AAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
None,
)
.unwrap();
assert_eq!(material.address.len(), 47);
}
#[test]
fn wallet_file_material_accepts_wallet_generator_shape() {
let material =
wallet_material_from_private_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", None)
.unwrap();
let seed = hex::encode(
general_purpose::STANDARD
.decode(&material.private_key_b64)
.unwrap(),
);
let public = hex::encode(
general_purpose::STANDARD
.decode(&material.public_key_b64)
.unwrap(),
);
let wallet = serde_json::json!({
"address": material.address.clone(),
"mnemonic": ["abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "about"],
"seed": "00",
"entropy": "00",
"masterKeyPair": {
"publicKey": public.clone(),
"secretKey": seed.clone(),
},
"keyPair": {
"publicKey": public.clone(),
"secretKey": format!("{seed}{public}"),
}
});
let path = std::env::temp_dir().join(format!(
"octra-sqlite-wallet-generator-test-{}.json",
std::process::id()
));
std::fs::write(&path, serde_json::to_vec(&wallet).unwrap()).unwrap();
let loaded = wallet_file_material(&path).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(loaded.address, material.address);
assert_eq!(loaded.private_key_b64, material.private_key_b64);
assert_eq!(loaded.public_key_b64, material.public_key_b64);
}
}