From dca4cbe6b240b41917c6f3dcf0e1906e7f1f991c Mon Sep 17 00:00:00 2001 From: Mohak Date: Sat, 25 Apr 2026 01:24:49 +1000 Subject: [PATCH] rpc: Implement z_importviewingkey for Sapling Add support for importing Sapling extended full viewing keys via the z_importviewingkey JSON-RPC method. The wallet will track incoming and outgoing transactions for addresses derived from imported keys but will not have spending authority. Supports the "whenkeyisnew", "yes", and "no" rescan options with a configurable start height, matching zcashd's interface. Closes #80 --- CHANGELOG.md | 1 + zallet/src/components/json_rpc/methods.rs | 41 +++ .../json_rpc/methods/import_viewing_key.rs | 326 ++++++++++++++++++ zallet/src/components/json_rpc/utils.rs | 69 +++- 4 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 zallet/src/components/json_rpc/methods/import_viewing_key.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc4ec71..941c1107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ be considered breaking changes. - `verifymessage` - `z_converttex` - `z_importaddress` + - `z_importviewingkey` (Sapling extended full viewing keys only) ### Changed - `getrawtransaction` now correctly reports the fields `asm`, `reqSigs`, `kind`, diff --git a/zallet/src/components/json_rpc/methods.rs b/zallet/src/components/json_rpc/methods.rs index d9e1e960..d4df10c0 100644 --- a/zallet/src/components/json_rpc/methods.rs +++ b/zallet/src/components/json_rpc/methods.rs @@ -36,6 +36,8 @@ mod get_raw_transaction; mod get_wallet_info; #[cfg(zallet_build = "wallet")] mod help; +#[cfg(zallet_build = "wallet")] +mod import_viewing_key; mod list_accounts; mod list_addresses; #[cfg(zallet_build = "wallet")] @@ -425,6 +427,29 @@ pub(crate) trait WalletRpc { rescan: Option, ) -> z_import_address::Response; + /// Imports a Sapling viewing key into the wallet. + /// + /// Only Sapling extended full viewing keys are supported. The wallet will track + /// incoming and outgoing transactions for addresses derived from this key, but + /// will not have spending authority. + /// + /// # Arguments + /// + /// - `vkey` (string, required) The viewing key (see `z_exportviewingkey`). + /// - `rescan` (string, optional, default="whenkeyisnew") Whether to rescan the + /// blockchain for transactions ("yes", "no", or "whenkeyisnew"). When rescan is + /// enabled, the wallet's background sync engine will scan for historical + /// transactions from the given start height. + /// - `startHeight` (numeric, optional, default=0) Block height from which to begin + /// the rescan. Only used when rescan is "yes" or "whenkeyisnew" (for a new key). + #[method(name = "z_importviewingkey")] + async fn import_viewing_key( + &self, + vkey: &str, + rescan: Option<&str>, + start_height: Option, + ) -> import_viewing_key::Response; + /// Returns the total value of funds stored in the node's wallet. /// /// TODO: Currently watchonly addresses cannot be omitted; `include_watchonly` must be @@ -839,6 +864,22 @@ impl WalletRpcServer for WalletRpcImpl { .await } + async fn import_viewing_key( + &self, + vkey: &str, + rescan: Option<&str>, + start_height: Option, + ) -> import_viewing_key::Response { + import_viewing_key::call( + self.wallet().await?.as_mut(), + self.chain().await?, + vkey, + rescan, + start_height, + ) + .await + } + async fn z_get_total_balance( &self, minconf: Option, diff --git a/zallet/src/components/json_rpc/methods/import_viewing_key.rs b/zallet/src/components/json_rpc/methods/import_viewing_key.rs new file mode 100644 index 00000000..f4d0323f --- /dev/null +++ b/zallet/src/components/json_rpc/methods/import_viewing_key.rs @@ -0,0 +1,326 @@ +use documented::Documented; +use jsonrpsee::core::RpcResult; +use schemars::JsonSchema; +use serde::Serialize; +use zaino_state::FetchServiceSubscriber; +use zcash_client_backend::data_api::{Account, AccountPurpose, WalletRead, WalletWrite}; +use zcash_keys::{ + encoding::{decode_extended_full_viewing_key, encode_payment_address}, + keys::UnifiedFullViewingKey, +}; +use zcash_protocol::consensus::{BlockHeight, NetworkConstants}; + +use crate::components::{ + database::DbConnection, + json_rpc::{server::LegacyCode, utils::fetch_account_birthday}, +}; + +/// Response to a `z_importviewingkey` RPC request. +pub(crate) type Response = RpcResult; + +/// Result of importing a viewing key. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct ResultType { + /// The type of the imported address (always "sapling"). + address_type: String, + + /// The Sapling payment address corresponding to the imported viewing key + /// (the default address). + address: String, +} + +pub(super) const PARAM_VKEY_DESC: &str = + "The viewing key (only Sapling extended full viewing keys are supported)."; +pub(super) const PARAM_RESCAN_DESC: &str = "Whether to rescan the blockchain for transactions (\"yes\", \"no\", or \"whenkeyisnew\"; default is \"whenkeyisnew\"). When rescan is enabled, the wallet's background sync engine will scan for historical transactions from the given start height."; +pub(super) const PARAM_START_HEIGHT_DESC: &str = "Block height from which to begin the rescan (default is 0). Only used when rescan is \"yes\" or \"whenkeyisnew\" (for a new key)."; + +/// Validates the `rescan` parameter. +/// +/// Returns the validated rescan value, or an RPC error if the value is invalid. +fn validate_rescan(rescan: Option<&str>) -> RpcResult<&str> { + match rescan { + None | Some("whenkeyisnew") => Ok("whenkeyisnew"), + Some("yes") => Ok("yes"), + Some("no") => Ok("no"), + Some(_) => Err(LegacyCode::InvalidParameter + .with_static("Invalid rescan value. Must be \"yes\", \"no\", or \"whenkeyisnew\".")), + } +} + +/// Decodes a Sapling extended full viewing key and derives the default payment address. +/// +/// Returns the decoded viewing key and the encoded payment address string. +fn decode_vkey_and_address( + hrp_fvk: &str, + hrp_payment_address: &str, + vkey: &str, +) -> RpcResult<(sapling::zip32::ExtendedFullViewingKey, String)> { + let extfvk = decode_extended_full_viewing_key(hrp_fvk, vkey).map_err(|e| { + LegacyCode::InvalidAddressOrKey.with_message(format!("Invalid viewing key: {e}")) + })?; + + let (_, payment_address) = extfvk.default_address(); + + let address = encode_payment_address(hrp_payment_address, &payment_address); + + Ok((extfvk, address)) +} + +pub(crate) async fn call( + wallet: &mut DbConnection, + chain: FetchServiceSubscriber, + vkey: &str, + rescan: Option<&str>, + start_height: Option, +) -> Response { + let rescan = validate_rescan(rescan)?; + + // Resolve and validate start_height, defaulting to 0 (genesis). + let start_height = BlockHeight::from_u32( + u32::try_from(start_height.unwrap_or(0)) + .map_err(|_| LegacyCode::InvalidParameter.with_static("Block height out of range."))?, + ); + + let chain_tip = wallet + .chain_height() + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))?; + + if let Some(tip) = chain_tip { + if start_height > tip { + return Err(LegacyCode::InvalidParameter.with_static("Block height out of range.")); + } + } + + let hrp_fvk = wallet.params().hrp_sapling_extended_full_viewing_key(); + let hrp_addr = wallet.params().hrp_sapling_payment_address(); + let (extfvk, address) = decode_vkey_and_address(hrp_fvk, hrp_addr, vkey)?; + + // Construct a UFVK from the Sapling extended full viewing key so the wallet can + // track transactions to/from this key's addresses. + let ufvk = UnifiedFullViewingKey::from_sapling_extended_full_viewing_key(extfvk) + .map_err(|e| LegacyCode::Wallet.with_message(e.to_string()))?; + + // Check if the key is already known to the wallet. + let existing_account = wallet + .get_account_for_ufvk(&ufvk) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))?; + match existing_account { + Some(account) => { + if matches!(account.purpose(), AccountPurpose::Spending { .. }) { + return Err(LegacyCode::Wallet.with_message(format!( + "The wallet already contains the private key for this viewing key (address: {})", + address + ))); + } + // ViewOnly — key already exists, return result. + // + // TODO: When rescan is "yes" and the key already exists, zcashd would force a + // rescan from start_height. We currently skip this because zcash_client_sqlite + // does not expose a way to reset scan ranges for an existing account. + } + None => { + // new key + let effective_height = match rescan { + "yes" | "whenkeyisnew" => start_height, + "no" => chain_tip.unwrap_or(BlockHeight::from_u32(0)), + _ => unreachable!(), + }; + + let birthday = fetch_account_birthday(wallet, &chain, effective_height).await?; + + wallet + .import_account_ufvk( + &format!("Imported Sapling viewing key {}", &address[..16]), + &ufvk, + &birthday, + AccountPurpose::ViewOnly, + None, + ) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))?; + } + } + + Ok(ResultType { + address_type: "sapling".to_string(), + address, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use zcash_keys::encoding::encode_extended_full_viewing_key; + use zcash_protocol::constants; + + /// Derives a test extended full viewing key from seed [0; 32] and encodes it. + fn encoded_mainnet_extfvk() -> String { + let extsk = sapling::zip32::ExtendedSpendingKey::master(&[0; 32]); + #[allow(deprecated)] + let extfvk = extsk.to_extended_full_viewing_key(); + encode_extended_full_viewing_key( + constants::mainnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + &extfvk, + ) + } + + /// Derives a test extended full viewing key from seed [0; 32] and encodes it for testnet. + fn encoded_testnet_extfvk() -> String { + let extsk = sapling::zip32::ExtendedSpendingKey::master(&[0; 32]); + #[allow(deprecated)] + let extfvk = extsk.to_extended_full_viewing_key(); + encode_extended_full_viewing_key( + constants::testnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + &extfvk, + ) + } + + // -- validate_rescan tests -- + + #[test] + fn rescan_none_defaults_to_whenkeyisnew() { + assert_eq!(validate_rescan(None).unwrap(), "whenkeyisnew"); + } + + #[test] + fn rescan_whenkeyisnew() { + assert_eq!( + validate_rescan(Some("whenkeyisnew")).unwrap(), + "whenkeyisnew" + ); + } + + #[test] + fn rescan_yes() { + assert_eq!(validate_rescan(Some("yes")).unwrap(), "yes"); + } + + #[test] + fn rescan_no() { + assert_eq!(validate_rescan(Some("no")).unwrap(), "no"); + } + + #[test] + fn rescan_invalid_value() { + assert!(validate_rescan(Some("always")).is_err()); + assert!(validate_rescan(Some("")).is_err()); + assert!(validate_rescan(Some("true")).is_err()); + } + + // -- decode_vkey_and_address tests -- + + #[test] + fn decode_valid_mainnet_vkey() { + let encoded = encoded_mainnet_extfvk(); + let (_, address) = decode_vkey_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + &encoded, + ) + .unwrap(); + + // Mainnet Sapling addresses start with "zs1". + assert!(address.starts_with("zs1")); + } + + #[test] + fn decode_valid_testnet_vkey() { + let encoded = encoded_testnet_extfvk(); + let (_, address) = decode_vkey_and_address( + constants::testnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + constants::testnet::HRP_SAPLING_PAYMENT_ADDRESS, + &encoded, + ) + .unwrap(); + + // Testnet Sapling addresses start with "ztestsapling1". + assert!(address.starts_with("ztestsapling1")); + } + + #[test] + fn decode_same_key_produces_same_address_across_calls() { + let encoded = encoded_mainnet_extfvk(); + + let (_, addr1) = decode_vkey_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + &encoded, + ) + .unwrap(); + + let (_, addr2) = decode_vkey_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + &encoded, + ) + .unwrap(); + + assert_eq!(addr1, addr2); + } + + #[test] + fn decode_roundtrip() { + let encoded = encoded_mainnet_extfvk(); + let (extfvk, _) = decode_vkey_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + &encoded, + ) + .unwrap(); + + let re_encoded = encode_extended_full_viewing_key( + constants::mainnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + &extfvk, + ); + assert_eq!(re_encoded, encoded); + } + + #[test] + fn decode_invalid_vkey() { + let result = decode_vkey_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + "not-a-valid-key", + ); + assert!(result.is_err()); + } + + #[test] + fn decode_wrong_network_vkey() { + // Testnet viewing key decoded with mainnet HRP should fail. + let testnet_encoded = encoded_testnet_extfvk(); + let result = decode_vkey_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + &testnet_encoded, + ); + assert!(result.is_err()); + } + + #[test] + fn decode_empty_vkey() { + let result = decode_vkey_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + "", + ); + assert!(result.is_err()); + } + + #[test] + fn decode_spending_key_rejected_as_viewing_key() { + // A spending key string should be rejected when decoded as a viewing key, + // since the HRP will not match. + let extsk = sapling::zip32::ExtendedSpendingKey::master(&[0; 32]); + let spending_key_encoded = zcash_keys::encoding::encode_extended_spending_key( + constants::mainnet::HRP_SAPLING_EXTENDED_SPENDING_KEY, + &extsk, + ); + + let result = decode_vkey_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_FULL_VIEWING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + &spending_key_encoded, + ); + assert!(result.is_err()); + } +} diff --git a/zallet/src/components/json_rpc/utils.rs b/zallet/src/components/json_rpc/utils.rs index 4fc36a18..eb393b57 100644 --- a/zallet/src/components/json_rpc/utils.rs +++ b/zallet/src/components/json_rpc/utils.rs @@ -22,8 +22,16 @@ use super::server::LegacyCode; use { crate::components::{database::DbConnection, keystore::KeyStore}, std::collections::HashSet, - zcash_client_backend::data_api::{Account, WalletRead}, - zcash_protocol::value::BalanceError, + zaino_state::FetchServiceSubscriber, + zcash_client_backend::{ + data_api::{Account, AccountBirthday, WalletRead, chain::ChainState}, + proto::service::TreeState, + }, + zcash_primitives::block::BlockHash, + zcash_protocol::{ + consensus::{NetworkType, Parameters}, + value::BalanceError, + }, zip32::fingerprint::SeedFingerprint, }; @@ -44,6 +52,63 @@ pub(super) async fn ensure_wallet_is_unlocked(keystore: &KeyStore) -> RpcResult< } } +/// Builds an [`AccountBirthday`] by fetching the treestate at the given height. +/// +/// For height 0 (genesis), returns a birthday with an empty chain state since the +/// commitment trees are empty. For non-zero heights, fetches the real treestate from +/// the chain indexer so the sync engine can validate note commitment tree continuity. +#[cfg(zallet_build = "wallet")] +pub(super) async fn fetch_account_birthday( + wallet: &DbConnection, + chain: &FetchServiceSubscriber, + height: BlockHeight, +) -> RpcResult { + if height == BlockHeight::from_u32(0) { + return Ok(AccountBirthday::from_parts( + ChainState::empty(BlockHeight::from_u32(0), BlockHash([0; 32])), + None, + )); + } + + let treestate_height = height.saturating_sub(1); + let treestate = chain + .fetcher + .get_treestate(treestate_height.to_string()) + .await + .map_err(|e| { + LegacyCode::InvalidParameter.with_message(format!( + "Failed to get treestate at height {treestate_height}: {e}" + )) + })?; + + let treestate = TreeState { + network: match wallet.params().network_type() { + NetworkType::Main => "main".into(), + NetworkType::Test => "test".into(), + NetworkType::Regtest => "regtest".into(), + }, + height: u64::try_from(treestate.height).map_err(|_| RpcErrorCode::InternalError)?, + hash: treestate.hash, + time: treestate.time, + sapling_tree: treestate + .sapling + .commitments() + .final_state() + .as_ref() + .map(hex::encode) + .unwrap_or_default(), + orchard_tree: treestate + .orchard + .commitments() + .final_state() + .as_ref() + .map(hex::encode) + .unwrap_or_default(), + }; + + AccountBirthday::from_treestate(treestate, None).map_err(|_| RpcErrorCode::InternalError.into()) +} + pub(crate) fn parse_txid(txid_str: &str) -> RpcResult { ReverseHex::decode(txid_str) .map(TxId::from_bytes)