From 59627765dd996f667a0a407ffd1ae82955123edf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 11:07:21 +0700 Subject: [PATCH 1/4] fix(key-wallet): platform node id is the Tenderdash node ID, not hash160 (#883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit key-wallet computed the masternode platform node id as HASH160(ed25519_pubkey), but the canonical value — what dashmate derives from node_key.json, what Tenderdash announces on the platform P2P network, and what ProRegTx builders (DashSync) write on-chain — is the Tenderdash/CometBFT node ID: SHA256(pubkey)[0..20]. Wallet-derived node ids therefore never matched real on-chain platform_node_id values, so ProviderPlatformKeys matching could never hit an actual evonode registration. - Add derivation_slip10::tenderdash_node_id as the single audited primitive (SHA256(pubkey)[0..20]). - Key the ProviderPlatformKeys pool entries by node id: the DerivedKey::EdDSA branch of AddressPool::generate_address_at_index and EdDSAAccount::derive_address_at now wrap the node id (not hash160) in the P2PKH-style payload the ProRegTx matcher compares against reg.platform_node_id. - SLIP-0010 fingerprints/identifier() are intentionally unchanged: BIP32 key identifiers are spec-defined as hash160 and never compared against on-chain fields. Golden vectors (cross-checked with an independent Ed25519 implementation, pyca/cryptography): platform node key 0 for the shared test mnemonic has pubkey 3130c143...8cf789 and node id 302f2615e6955cce8ed3cff81e8011bfd3a2991f. Tests pin the primitive and the pool payload end-to-end; without the fix the pool payload is the hash160 value eaa83b51... and the new tests fail. Closes #883 Co-Authored-By: Claude Fable 5 --- key-wallet/src/account/eddsa_account.rs | 22 +++---- key-wallet/src/derivation_slip10.rs | 17 +++++ .../src/managed_account/address_pool.rs | 23 +++++-- .../tests/provider_key_derivation_tests.rs | 64 +++++++++++++++++++ .../transaction_checking/account_checker.rs | 4 +- 5 files changed, 113 insertions(+), 17 deletions(-) diff --git a/key-wallet/src/account/eddsa_account.rs b/key-wallet/src/account/eddsa_account.rs index bff1c5cc8..3436ccd16 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,13 @@ 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 pubkey_hash = dashcore::PubkeyHash::from_slice(&node_id) + .expect("Tenderdash node id is exactly 20 bytes"); + let payload = Payload::PubkeyHash(pubkey_hash); Ok(Address::new(self.network, payload)) } diff --git a/key-wallet/src/derivation_slip10.rs b/key-wallet/src/derivation_slip10.rs index 804154003..688116628 100644 --- a/key-wallet/src/derivation_slip10.rs +++ b/key-wallet/src/derivation_slip10.rs @@ -25,6 +25,23 @@ 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); + hash.as_byte_array()[..20].try_into().expect("SHA-256 yields 32 bytes") +} + /// 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..7495bdbd9 100644 --- a/key-wallet/src/managed_account/address_pool.rs +++ b/key-wallet/src/managed_account/address_pool.rs @@ -491,13 +491,26 @@ impl AddressPool { (address, PublicKeyType::BLS(public_key_bytes)) } 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 pubkey_hash = dashcore::PubkeyHash::from_slice(&node_id) + .expect("Tenderdash node id is exactly 20 bytes"); + let payload = Payload::PubkeyHash(pubkey_hash); 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 { From 4958114da60795e71bd02512995701cf0b81ae65 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 11:13:42 +0700 Subject: [PATCH 2/4] fix(key-wallet): gate DerivedKey::EdDSA on the eddsa feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit derivation_slip10 is compiled only with the eddsa feature, but the DerivedKey::EdDSA match arm referencing tenderdash_node_id was unconditional, breaking default-feature builds. Gate the variant and its arm like KeySource's EdDSA variants — the variant is only constructible from the (already gated) EdDSA key sources. Co-Authored-By: Claude Fable 5 --- key-wallet/src/managed_account/address_pool.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/key-wallet/src/managed_account/address_pool.rs b/key-wallet/src/managed_account/address_pool.rs index 7495bdbd9..ac82f13e3 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,6 +491,7 @@ impl AddressPool { (address, PublicKeyType::BLS(public_key_bytes)) } + #[cfg(feature = "eddsa")] DerivedKey::EdDSA(public_key_bytes) => { // EdDSA pool entries key on the Tenderdash node ID // (SHA256(pubkey)[0..20]) — the value ProRegTx carries as From 130d11c66aae2ff5458572a3b9cbe55e4484a833 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 11:22:13 +0700 Subject: [PATCH 3/4] test(key-wallet): cover derive_address_at node-id path with golden vector Codecov flagged the EdDSAAccount::derive_address_at change as uncovered. Pin it to the same independently computed Tenderdash node id as the pool-level tests so both implementations of the convention are locked to the vector. Co-Authored-By: Claude Fable 5 --- key-wallet/src/account/eddsa_account.rs | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/key-wallet/src/account/eddsa_account.rs b/key-wallet/src/account/eddsa_account.rs index 3436ccd16..814d1f5d2 100644 --- a/key-wallet/src/account/eddsa_account.rs +++ b/key-wallet/src/account/eddsa_account.rs @@ -534,6 +534,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]; From 90f02c5db56b29797ed09ca0d86bcf429f154c0b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 14 Jul 2026 11:24:00 +0700 Subject: [PATCH 4/4] refactor(key-wallet): use infallible conversions for node-id bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: PubkeyHash::from_byte_array instead of from_slice().expect(), and copy_from_slice instead of try_into().expect() in tenderdash_node_id — no panics in library code. Co-Authored-By: Claude Fable 5 --- key-wallet/src/account/eddsa_account.rs | 4 +--- key-wallet/src/derivation_slip10.rs | 4 +++- key-wallet/src/managed_account/address_pool.rs | 4 +--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/key-wallet/src/account/eddsa_account.rs b/key-wallet/src/account/eddsa_account.rs index 814d1f5d2..6ad5b2490 100644 --- a/key-wallet/src/account/eddsa_account.rs +++ b/key-wallet/src/account/eddsa_account.rs @@ -350,9 +350,7 @@ impl use dashcore::address::Payload; use dashcore::hashes::Hash; - let pubkey_hash = dashcore::PubkeyHash::from_slice(&node_id) - .expect("Tenderdash node id is exactly 20 bytes"); - let payload = Payload::PubkeyHash(pubkey_hash); + let payload = Payload::PubkeyHash(dashcore::PubkeyHash::from_byte_array(node_id)); Ok(Address::new(self.network, payload)) } diff --git a/key-wallet/src/derivation_slip10.rs b/key-wallet/src/derivation_slip10.rs index 688116628..40926ce1b 100644 --- a/key-wallet/src/derivation_slip10.rs +++ b/key-wallet/src/derivation_slip10.rs @@ -39,7 +39,9 @@ pub use crate::bip32::DerivationPath; 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); - hash.as_byte_array()[..20].try_into().expect("SHA-256 yields 32 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 diff --git a/key-wallet/src/managed_account/address_pool.rs b/key-wallet/src/managed_account/address_pool.rs index ac82f13e3..0bb38e061 100644 --- a/key-wallet/src/managed_account/address_pool.rs +++ b/key-wallet/src/managed_account/address_pool.rs @@ -510,9 +510,7 @@ impl AddressPool { use dashcore::address::Payload; use dashcore::hashes::Hash; - let pubkey_hash = dashcore::PubkeyHash::from_slice(&node_id) - .expect("Tenderdash node id is exactly 20 bytes"); - let payload = Payload::PubkeyHash(pubkey_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))