Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
41 changes: 41 additions & 0 deletions zallet/src/components/json_rpc/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -425,6 +427,29 @@ pub(crate) trait WalletRpc {
rescan: Option<bool>,
) -> 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`).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// - `vkey` (string, required) The viewing key (see `z_exportviewingkey`).
/// - `vkey` (string, required) The viewing key.

z_exportviewingkey hasn't been implemented yet, so this reference doesn't make sense. IMO just drop the reference for now

/// - `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<u64>,
) -> 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
Expand Down Expand Up @@ -839,6 +864,22 @@ impl WalletRpcServer for WalletRpcImpl {
.await
}

async fn import_viewing_key(
&self,
vkey: &str,
rescan: Option<&str>,
start_height: Option<u64>,
) -> 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<u32>,
Expand Down
326 changes: 326 additions & 0 deletions zallet/src/components/json_rpc/methods/import_viewing_key.rs
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
address_type: String,
type: String,

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> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please create an enum for the rescan value, so that we don't rely on stringly-typed data

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check should only apply if rescan="yes"

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!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return Err(LegacyCode::Wallet.with_message(format!(
"The wallet already contains the private key for this viewing key (address: {address})",

"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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This TODO is out of date. zcash_client_backend recently added WalletWrite::rewind_to_chain_state which does the rescan you need here. You'll need to update the crate patch in Cargo.toml for zcash_client_backend to point to latest main.

}
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]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
&format!("Imported Sapling viewing key {}", &address[..16]),
&format!("Imported Sapling viewing key {}", &address),

&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());
}
}
Loading