-
Notifications
You must be signed in to change notification settings - Fork 39
rpc: Implement z_importviewingkey for Sapling #431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<ResultType>; | ||||||
|
|
||||||
| /// 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, | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Match zcashd naming |
||||||
|
|
||||||
| /// 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> { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please create an |
||||||
| 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<u64>, | ||||||
| ) -> 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 { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This check should only apply if |
||||||
| 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!( | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| "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. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This TODO is out of date. |
||||||
| } | ||||||
| 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]), | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| &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()); | ||||||
| } | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
z_exportviewingkeyhasn't been implemented yet, so this reference doesn't make sense. IMO just drop the reference for now