mx-core 0.1.0

Core utilities for MultiversX Rust services.
Documentation
//! Wallet derivation utilities for `MultiversX`.
//!
//! This module provides shared functionality for deriving Ed25519 signing keys
//! and bech32 addresses from BIP39 mnemonics using the `MultiversX` wallet derivation path.

use crate::error::CoreError;
use crate::normalize_mnemonic;
use bip39::Mnemonic;
use ed25519_dalek::SigningKey;
use multiversx_sdk::wallet;
use std::convert::TryInto;

/// Parses and normalizes a mnemonic string into a validated `Mnemonic`.
///
/// Handles commas, extra whitespace, and validates the BIP39 mnemonic.
fn parse_mnemonic(mnemonic_str: &str) -> Result<Mnemonic, CoreError> {
    let mnemonic_phrase = normalize_mnemonic(mnemonic_str);
    Mnemonic::parse(&mnemonic_phrase).map_err(|e| CoreError::InvalidMnemonic(e.to_string()))
}

/// Derives an Ed25519 signing key from a BIP39 mnemonic at the given account and index.
///
/// Uses the `MultiversX` wallet derivation path: m/44'/508'/{account}'/0'/{index}
///
/// # Arguments
/// * `mnemonic_str` - BIP39 mnemonic phrase (12 or 24 words)
/// * `account` - Account number in the derivation path (typically 0 for relayer, 1+ for stress testing)
/// * `index` - Address index within the account
///
/// # Returns
/// The Ed25519 signing key, or an error if derivation fails.
///
/// # Examples
/// ```
/// use mx_core::derive_signing_key;
///
/// let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
/// let key = derive_signing_key(mnemonic, 0, 0).unwrap();
/// ```
pub fn derive_signing_key(
    mnemonic_str: &str,
    account: u32,
    index: u32,
) -> Result<SigningKey, CoreError> {
    let mnemonic = parse_mnemonic(mnemonic_str)?;

    // Use MultiversX SDK to derive the private key
    let private_key = wallet::Wallet::get_private_key_from_mnemonic(mnemonic, account, index);

    // Convert to hex string and then to ed25519 signing key
    let private_key_hex = private_key.to_string();
    let seed_bytes = hex::decode(&private_key_hex)
        .map_err(|e| CoreError::InvalidPrivateKey(format!("invalid private key hex: {e}")))?;

    // Ensure we have exactly 32 bytes
    let seed: [u8; 32] = seed_bytes.as_slice().try_into().map_err(|_| {
        CoreError::InvalidPrivateKey(format!("expected 32-byte seed, got {}", seed_bytes.len()))
    })?;

    Ok(SigningKey::from_bytes(&seed))
}

/// Derives a bech32-encoded address from a BIP39 mnemonic at the given account and index.
///
/// Uses the `MultiversX` wallet derivation path: m/44'/508'/{account}'/0'/{index}
///
/// # Arguments
/// * `mnemonic_str` - BIP39 mnemonic phrase (12 or 24 words)
/// * `account` - Account number in the derivation path (typically 0 for relayer, 1+ for stress testing)
/// * `index` - Address index within the account
///
/// # Returns
/// The bech32-encoded address (e.g., "erd1..."), or an error if derivation fails.
///
/// # Examples
/// ```
/// use mx_core::derive_address;
///
/// let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
/// let address = derive_address(mnemonic, 0, 0).unwrap();
/// assert!(address.starts_with("erd1"));
/// ```
pub fn derive_address(mnemonic_str: &str, account: u32, index: u32) -> Result<String, CoreError> {
    let mnemonic = parse_mnemonic(mnemonic_str)?;

    // Use MultiversX SDK to derive the private key
    let private_key = wallet::Wallet::get_private_key_from_mnemonic(mnemonic, account, index);

    // Convert to hex and create wallet to get the address
    let private_key_hex = private_key.to_string();
    let wallet_obj = wallet::Wallet::from_private_key(&private_key_hex)
        .map_err(|e| CoreError::WalletCreation(e.to_string()))?;

    Ok(wallet_obj.to_address().to_bech32_default().to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    // Test mnemonic for deterministic testing (DO NOT use in production)
    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";

    #[test]
    fn test_derive_signing_key_success() {
        let result = derive_signing_key(TEST_MNEMONIC, 0, 0);
        assert!(result.is_ok());

        // Derive twice to ensure determinism
        let key1 = derive_signing_key(TEST_MNEMONIC, 0, 0).unwrap();
        let key2 = derive_signing_key(TEST_MNEMONIC, 0, 0).unwrap();
        assert_eq!(key1.to_bytes(), key2.to_bytes());
    }

    #[test]
    fn test_derive_signing_key_invalid_mnemonic() {
        let result = derive_signing_key("invalid mnemonic words that dont exist", 0, 0);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("invalid mnemonic"));
    }

    #[test]
    fn test_derive_signing_key_different_indices() {
        let key0 = derive_signing_key(TEST_MNEMONIC, 0, 0).unwrap();
        let key1 = derive_signing_key(TEST_MNEMONIC, 0, 1).unwrap();
        let key2 = derive_signing_key(TEST_MNEMONIC, 1, 0).unwrap();

        // Different indices should produce different keys
        assert_ne!(key0.to_bytes(), key1.to_bytes());
        assert_ne!(key0.to_bytes(), key2.to_bytes());
        assert_ne!(key1.to_bytes(), key2.to_bytes());
    }

    #[test]
    fn test_derive_address_success() {
        let result = derive_address(TEST_MNEMONIC, 0, 0);
        assert!(result.is_ok());

        let address = result.unwrap();
        assert!(address.starts_with("erd1"));
        assert_eq!(address.len(), 62); // MultiversX addresses are 62 chars
    }

    #[test]
    fn test_derive_address_invalid_mnemonic() {
        let result = derive_address("invalid mnemonic words that dont exist", 0, 0);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("invalid mnemonic"));
    }

    #[test]
    fn test_derive_address_different_indices() {
        let addr0 = derive_address(TEST_MNEMONIC, 0, 0).unwrap();
        let addr1 = derive_address(TEST_MNEMONIC, 0, 1).unwrap();
        let addr2 = derive_address(TEST_MNEMONIC, 1, 0).unwrap();

        // Different indices should produce different addresses
        assert_ne!(addr0, addr1);
        assert_ne!(addr0, addr2);
        assert_ne!(addr1, addr2);
    }

    #[test]
    fn test_derive_address_deterministic() {
        let addr1 = derive_address(TEST_MNEMONIC, 0, 0).unwrap();
        let addr2 = derive_address(TEST_MNEMONIC, 0, 0).unwrap();
        assert_eq!(addr1, addr2);
    }

    #[test]
    fn test_normalize_mnemonic_in_derivation() {
        // Test that normalization works with commas and extra spaces
        let mnemonic_commas = "abandon,abandon,abandon,abandon,abandon,abandon,abandon,abandon,abandon,abandon,abandon,about";
        let mnemonic_spaces = "abandon  abandon  abandon  abandon  abandon  abandon  abandon  abandon  abandon  abandon  abandon  about";
        let mnemonic_normal = TEST_MNEMONIC;

        let addr_commas = derive_address(mnemonic_commas, 0, 0).unwrap();
        let addr_spaces = derive_address(mnemonic_spaces, 0, 0).unwrap();
        let addr_normal = derive_address(mnemonic_normal, 0, 0).unwrap();

        // All should produce the same address
        assert_eq!(addr_commas, addr_normal);
        assert_eq!(addr_spaces, addr_normal);
    }

    #[test]
    fn test_signing_key_and_address_match() {
        // Ensure that the signing key and address derived for the same index correspond
        let signing_key = derive_signing_key(TEST_MNEMONIC, 0, 0).unwrap();
        let address = derive_address(TEST_MNEMONIC, 0, 0).unwrap();

        // Derive the address from the signing key's public key
        let verifying_key = signing_key.verifying_key();
        let pubkey_bytes = verifying_key.to_bytes();

        // Decode the bech32 address to get the raw bytes
        let (_hrp, addr_bytes) = bech32::decode(&address).unwrap();
        let addr_bytes_array: [u8; 32] = addr_bytes.as_slice().try_into().unwrap();

        // The address bytes should match the public key
        assert_eq!(pubkey_bytes, addr_bytes_array);
    }
}