diff --git a/key-wallet/src/account/eddsa_account.rs b/key-wallet/src/account/eddsa_account.rs index bff1c5cc8..6ad5b2490 100644 --- a/key-wallet/src/account/eddsa_account.rs +++ b/key-wallet/src/account/eddsa_account.rs @@ -329,9 +329,13 @@ impl )) } - /// Derive an Ed25519-based address at a specific chain and index. + /// Derive an Ed25519-based pseudo-address at a specific chain and index. /// - /// Creates a P2PKH-style address from the hash160 of the Ed25519 public key. + /// The payload is the Tenderdash node ID of the Ed25519 public key + /// (`SHA256(pubkey)[0..20]`, the CometBFT convention) wrapped in a + /// P2PKH-style address — the same value ProRegTx carries as + /// `platform_node_id`, so it can be matched against on-chain evonode + /// registrations. NOT hash160, which Dash only uses for ECDSA key hashes. fn derive_address_at( &self, address_pool_type: AddressPoolType, @@ -342,17 +346,11 @@ impl let ed25519_pubkey = self.derive_public_key_at(address_pool_type, index, use_hardened_with_priv_key)?; - // Get the Ed25519 public key bytes (32 bytes for Ed25519) - let pubkey_bytes = ed25519_pubkey.to_bytes(); + let node_id = crate::derivation_slip10::tenderdash_node_id(&ed25519_pubkey.to_bytes()); - // Create a P2PKH address from the hash160 of the Ed25519 public key - // This uses the same hash160 (SHA256 + RIPEMD160) as ECDSA addresses - use dashcore::hashes::{hash160, Hash}; - let pubkey_hash = hash160::Hash::hash(&pubkey_bytes); - - // Create the address from the public key hash use dashcore::address::Payload; - let payload = Payload::PubkeyHash(pubkey_hash.into()); + use dashcore::hashes::Hash; + let payload = Payload::PubkeyHash(dashcore::PubkeyHash::from_byte_array(node_id)); Ok(Address::new(self.network, payload)) } @@ -534,6 +532,46 @@ mod tests { assert!(result.is_err()); } + /// `derive_address_at` must produce Tenderdash-node-id payloads + /// (SHA256(pubkey)[0..20]) — pinned to the same golden vector as + /// `tests::provider_key_derivation_tests` (platform node key 0 at + /// m/9'/5'/3'/4'/0' for the BIP39 "abandon…about" seed, cross-checked + /// with an independent Ed25519 implementation). + #[test] + fn test_derive_address_at_uses_tenderdash_node_id() { + use dashcore::hashes::Hash; + + let seed = hex::decode( + "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1\ + 9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4", + ) + .unwrap(); + let account = EdDSAAccount::from_seed( + None, + AccountType::ProviderPlatformKeys, + &seed, + Network::Mainnet, + ) + .expect("create platform keys account from seed"); + + let master = + ExtendedEd25519PrivKey::new_master(Network::Mainnet, &seed).expect("master from seed"); + let account_xpriv = master + .derive_priv( + &AccountType::ProviderPlatformKeys.derivation_path(Network::Mainnet).unwrap(), + ) + .expect("derive account xpriv"); + + let address = account + .derive_address_at(AddressPoolType::AbsentHardened, 0, Some(account_xpriv)) + .expect("derive platform node pseudo-address"); + + let dashcore::address::Payload::PubkeyHash(hash) = address.payload() else { + panic!("platform pseudo-addresses use P2PKH-style payloads"); + }; + assert_eq!(hex::encode(hash.to_byte_array()), "302f2615e6955cce8ed3cff81e8011bfd3a2991f"); + } + #[test] fn test_derive_identity_key() { let seed = [5u8; 32]; diff --git a/key-wallet/src/derivation_slip10.rs b/key-wallet/src/derivation_slip10.rs index 804154003..40926ce1b 100644 --- a/key-wallet/src/derivation_slip10.rs +++ b/key-wallet/src/derivation_slip10.rs @@ -25,6 +25,25 @@ pub use dashcore::ed25519_dalek::VerifyingKey as Ed25519PublicKey; // Use DerivationPath from bip32 pub use crate::bip32::DerivationPath; +/// Compute the Tenderdash/CometBFT node ID for an Ed25519 public key: the +/// first 20 bytes of a single SHA-256 of the 32-byte public key. +/// +/// This is the canonical value of the `platform_node_id` field in ProRegTx / +/// ProUpServTx payloads for evonodes: it is what dashmate derives from +/// `node_key.json` and what Tenderdash announces on the platform P2P network, +/// so it is the only value that can match an on-chain registration. +/// +/// Note this is **not** `hash160` (SHA-256 + RIPEMD-160). Dash uses hash160 +/// for the ECDSA owner/voting key hashes in the same payload, but the +/// platform node id follows the CometBFT node-ID convention. +pub fn tenderdash_node_id(ed25519_public_key_bytes: &[u8; 32]) -> [u8; 20] { + use dashcore_hashes::sha256; + let hash = sha256::Hash::hash(ed25519_public_key_bytes); + let mut node_id = [0u8; 20]; + node_id.copy_from_slice(&hash.as_byte_array()[..20]); + node_id +} + /// Extended Ed25519 private key for SLIP-0010 #[derive(Clone)] pub struct ExtendedEd25519PrivKey { diff --git a/key-wallet/src/managed_account/address_pool.rs b/key-wallet/src/managed_account/address_pool.rs index 020c3f8f2..0bb38e061 100644 --- a/key-wallet/src/managed_account/address_pool.rs +++ b/key-wallet/src/managed_account/address_pool.rs @@ -122,6 +122,7 @@ pub enum DerivedKey { /// BLS public key (48 bytes) BLS(Vec), /// EdDSA public key (32 bytes) + #[cfg(feature = "eddsa")] EdDSA(Vec), } @@ -490,14 +491,26 @@ impl AddressPool { (address, PublicKeyType::BLS(public_key_bytes)) } + #[cfg(feature = "eddsa")] DerivedKey::EdDSA(public_key_bytes) => { - // EdDSA addresses use Hash160 of the public key bytes - use dashcore::hashes::{hash160, Hash}; - let pubkey_hash = hash160::Hash::hash(&public_key_bytes); + // EdDSA pool entries key on the Tenderdash node ID + // (SHA256(pubkey)[0..20]) — the value ProRegTx carries as + // `platform_node_id` — wrapped in a P2PKH-style payload so it + // can live in the pool's address index. NOT hash160: a + // hash160-keyed entry can never match an on-chain evonode + // registration. + let pubkey_arr: &[u8; 32] = + public_key_bytes.as_slice().try_into().map_err(|_| { + Error::InvalidParameter(format!( + "EdDSA public key must be 32 bytes, got {}", + public_key_bytes.len() + )) + })?; + let node_id = crate::derivation_slip10::tenderdash_node_id(pubkey_arr); - // Create P2PKH address from the hash use dashcore::address::Payload; - let payload = Payload::PubkeyHash(pubkey_hash.into()); + use dashcore::hashes::Hash; + let payload = Payload::PubkeyHash(dashcore::PubkeyHash::from_byte_array(node_id)); let address = Address::new(self.network, payload); (address, PublicKeyType::EdDSA(public_key_bytes)) diff --git a/key-wallet/src/tests/provider_key_derivation_tests.rs b/key-wallet/src/tests/provider_key_derivation_tests.rs index 4739095e3..4973e3180 100644 --- a/key-wallet/src/tests/provider_key_derivation_tests.rs +++ b/key-wallet/src/tests/provider_key_derivation_tests.rs @@ -125,6 +125,70 @@ fn ed25519_platform_node_keys_match_slip10_reference() { ); } +/// The platform node id must follow the Tenderdash/CometBFT convention — +/// `SHA256(pubkey)[0..20]` — not hash160, or wallet-side matching can never +/// hit on-chain evonode registrations. See +/// . +#[cfg(feature = "eddsa")] +#[test] +fn ed25519_platform_node_id_matches_tenderdash_convention() { + use crate::account::eddsa_account::EdDSAAccount; + use crate::derivation_slip10::tenderdash_node_id; + + // Platform node key 0 (m/9'/5'/3'/4'/0') for TEST_SEED — the same key the + // SLIP-0010 vector above pins. Public key and node id cross-checked with + // an independent Ed25519 implementation (pyca/cryptography). + let seed = hex::decode(TEST_SEED_HEX).unwrap(); + let sk0 = EdDSAAccount::platform_node_key_at(&seed, Network::Mainnet, 0).unwrap(); + let pubkey = sk0.verifying_key(); + assert_eq!( + hex::encode(pubkey.to_bytes()), + "3130c14339391cf26a68d86879e180ee9a16b660f5aa91f560f67c0abe8cf789" + ); + assert_eq!( + hex::encode(tenderdash_node_id(&pubkey.to_bytes())), + "302f2615e6955cce8ed3cff81e8011bfd3a2991f" + ); +} + +/// The managed ProviderPlatformKeys pool must key its entries by the +/// Tenderdash node id, since that payload is what ProRegTx matching compares +/// against the on-chain `platform_node_id` field. +#[cfg(feature = "eddsa")] +#[test] +fn platform_pool_entries_are_keyed_by_tenderdash_node_id() { + use crate::derivation_slip10::ExtendedEd25519PrivKey; + use crate::managed_account::managed_account_trait::ManagedAccountTrait; + use crate::wallet::managed_wallet_info::ManagedWalletInfo; + use dashcore::hashes::Hash; + + let wallet = test_wallet(Network::Mainnet); + let mut info = ManagedWalletInfo::from_wallet_with_name(&wallet, "node-id".to_string(), 0); + + let seed = hex::decode(TEST_SEED_HEX).unwrap(); + let master = ExtendedEd25519PrivKey::new_master(Network::Mainnet, &seed).unwrap(); + let account_xpriv = master + .derive_priv(&AccountType::ProviderPlatformKeys.derivation_path(Network::Mainnet).unwrap()) + .unwrap(); + + let (verifying_key, entry) = info + .provider_platform_keys_managed_account_mut() + .expect("platform managed account") + .next_eddsa_platform_key(account_xpriv, true) + .expect("derive platform key 0"); + + assert_eq!( + hex::encode(verifying_key.to_bytes()), + "3130c14339391cf26a68d86879e180ee9a16b660f5aa91f560f67c0abe8cf789" + ); + // The pool entry's payload is the Tenderdash node id — the value a real + // ProRegTx carries — so registration matching can hit it. + let dashcore::address::Payload::PubkeyHash(hash) = entry.address.payload() else { + panic!("platform pool entries use P2PKH-style payloads"); + }; + assert_eq!(hex::encode(hash.to_byte_array()), "302f2615e6955cce8ed3cff81e8011bfd3a2991f"); +} + /// The gate-free provider-key entry points must work identically on resident /// (non-watch-only) and watch-only accounts, and must match the dashbls /// reference vectors. See diff --git a/key-wallet/src/transaction_checking/account_checker.rs b/key-wallet/src/transaction_checking/account_checker.rs index bbff4ba8f..e5a4f7290 100644 --- a/key-wallet/src/transaction_checking/account_checker.rs +++ b/key-wallet/src/transaction_checking/account_checker.rs @@ -1042,7 +1042,9 @@ impl ManagedCoreFundsAccount { _ => return None, }; - // Check if platform_node_id matches any of our address hashes + // Check if platform_node_id matches any of our pool entries. + // Pool payloads hold Tenderdash node ids (SHA256(pubkey)[..20]), + // matching the on-chain field's convention. for (address, &addr_index) in &addresses.address_index { if let Payload::PubkeyHash(addr_hash) = address.payload() { if addr_hash == platform_node_id {