From b4779fcfb16ae7de5bd763ccba403dc070591b6c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 22 Jun 2026 13:56:58 +0700 Subject: [PATCH] feat(key-wallet): add Signer::extended_public_key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Signer trait exposes sign_ecdsa + public_key, the latter returning only the compressed leaf point. A caller that needs a BIP-32 extended public key at a hardened path — e.g. DashPay contact-xpub derivation under m/9'/coin'/15'/account'/sender/recipient — could not obtain the chain code without the wallet's resident root private key, forcing the seed to be made resident. Add a required extended_public_key(path) -> ExtendedPubKey method so a Keychain/hardware-backed signer can return the point + chain code + parent fingerprint at a (possibly hardened) path, letting the caller non-hardened-derive descendants offline with no resident seed. A signer that cannot export an xpub at a hardened path returns an error rather than panicking. The two in-repo InMemorySigner test signers derive it from their root xprv (ExtendedPubKey::from_priv); the NoDigestSigner test stubs are unreachable. Pinned by a test asserting the signer's xpub at a hardened path equals the wallet's own derive_extended_public_key. Required (not defaulted): the trait's associated Error type is opaque, so a default body cannot construct a Self::Error to return; there are no production Signer impls in this crate. Co-Authored-By: Claude Opus 4.8 (1M context) --- key-wallet/src/signer.rs | 17 +++++++- .../managed_wallet_info/asset_lock_builder.rs | 19 +++++++++ .../transaction_building.rs | 40 +++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/key-wallet/src/signer.rs b/key-wallet/src/signer.rs index a5a97f804..3435289aa 100644 --- a/key-wallet/src/signer.rs +++ b/key-wallet/src/signer.rs @@ -14,7 +14,7 @@ use async_trait::async_trait; use secp256k1::{ecdsa, PublicKey}; -use crate::bip32::DerivationPath; +use crate::bip32::{DerivationPath, ExtendedPubKey}; /// A signing method a [`Signer`] can perform. /// @@ -125,4 +125,19 @@ pub trait Signer: Send + Sync { /// keys) that the caller later references when signing Platform state /// transitions. async fn public_key(&self, path: &DerivationPath) -> Result; + + /// Return the BIP-32 extended public key at `path` — the public point + /// plus the chain code and parent fingerprint, so the caller can + /// non-hardened-derive a whole range of descendants from a single + /// (possibly hardened) request without further signer round-trips + /// (e.g. DashPay contact payment addresses under + /// `m/9'/coin'/15'/account'/sender/recipient`). + /// + /// Distinct from [`Self::public_key`], which returns only the leaf point + /// (no chain code). A signer that cannot export an extended public key at + /// a hardened path should return an error rather than panic. + async fn extended_public_key( + &self, + path: &DerivationPath, + ) -> Result; } diff --git a/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs b/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs index 8f8fa9bb5..56c9dad4c 100644 --- a/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs @@ -469,6 +469,19 @@ mod tests { .map_err(|e| e.to_string())?; Ok(secp256k1::PublicKey::from_secret_key(&secp, &xpriv.private_key)) } + + async fn extended_public_key( + &self, + path: &DerivationPath, + ) -> Result { + let secp = secp256k1::Secp256k1::new(); + let xpriv = self + .root + .to_extended_priv_key(self.network) + .derive_priv(&secp, path) + .map_err(|e| e.to_string())?; + Ok(crate::bip32::ExtendedPubKey::from_priv(&secp, &xpriv)) + } } #[tokio::test] @@ -537,6 +550,12 @@ mod tests { async fn public_key(&self, _: &DerivationPath) -> Result { unreachable!() } + async fn extended_public_key( + &self, + _: &DerivationPath, + ) -> Result { + unreachable!() + } } let (wallet, mut info) = test_wallet_and_info(); diff --git a/key-wallet/src/wallet/managed_wallet_info/transaction_building.rs b/key-wallet/src/wallet/managed_wallet_info/transaction_building.rs index 492b35dda..c553c59dc 100644 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_building.rs +++ b/key-wallet/src/wallet/managed_wallet_info/transaction_building.rs @@ -384,6 +384,19 @@ mod tests { .map_err(|e| e.to_string())?; Ok(secp256k1::PublicKey::from_secret_key(&secp, &xpriv.private_key)) } + + async fn extended_public_key( + &self, + path: &DerivationPath, + ) -> Result { + let secp = secp256k1::Secp256k1::new(); + let xpriv = self + .root + .to_extended_priv_key(self.network) + .derive_priv(&secp, path) + .map_err(|e| e.to_string())?; + Ok(crate::bip32::ExtendedPubKey::from_priv(&secp, &xpriv)) + } } fn root_from(wallet: &Wallet) -> crate::wallet::root_extended_keys::RootExtendedPrivKey { @@ -396,6 +409,27 @@ mod tests { } } + #[tokio::test] + async fn in_memory_signer_extended_public_key_matches_wallet_derivation() { + use crate::bip32::DerivationPath; + use std::str::FromStr; + let (wallet, _info) = test_wallet_and_info(); + let signer = InMemorySigner { + root: root_from(&wallet), + network: Network::Testnet, + }; + // A hardened path — only derivable with the private key, which is the + // whole point of exposing extended-pubkey export on the signer. + let path = DerivationPath::from_str("m/9'/1'/15'/0'").expect("valid path"); + let from_signer = + signer.extended_public_key(&path).await.expect("signer extended_public_key"); + let from_wallet = wallet.derive_extended_public_key(&path).expect("wallet extended pubkey"); + assert_eq!( + from_signer, from_wallet, + "signer xpub at a hardened path must equal the wallet's own derivation" + ); + } + fn dest_outputs(amount: u64) -> Vec<(Address, u64)> { let dest = Address::from_str("yTb47qEBpNmgXvYYsHEN4nh8yJwa5iC4Cs").unwrap(); vec![(dest, amount)] @@ -443,6 +477,12 @@ mod tests { async fn public_key(&self, _: &DerivationPath) -> Result { unreachable!() } + async fn extended_public_key( + &self, + _: &DerivationPath, + ) -> Result { + unreachable!() + } } let (wallet, mut info) = test_wallet_and_info();