diff --git a/CHANGELOG.md b/CHANGELOG.md index aec8bc9e..2be4247d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ be considered breaking changes. - `decodescript` - `verifymessage` - `z_converttex` + - `z_importaddress` ### 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 348e1ee9..d9e1e960 100644 --- a/zallet/src/components/json_rpc/methods.rs +++ b/zallet/src/components/json_rpc/methods.rs @@ -59,6 +59,8 @@ mod view_transaction; #[cfg(zallet_build = "wallet")] mod z_get_total_balance; #[cfg(zallet_build = "wallet")] +mod z_import_address; +#[cfg(zallet_build = "wallet")] mod z_send_many; /// The general JSON-RPC interface, containing the methods provided in all Zallet builds. @@ -402,6 +404,27 @@ pub(crate) trait WalletRpc { #[method(name = "z_getbalances")] async fn get_balances(&self, minconf: Option) -> get_balances::Response; + /// Imports a transparent address into the wallet for a given account. + /// + /// The hex data can be either: + /// - A compressed or uncompressed public key (imports as P2PKH). + /// - A redeem script (imports as P2SH). + /// + /// Returns the type of address imported and the corresponding transparent address. + /// + /// # Arguments + /// - `account` (string, required) The account UUID. + /// - `hex_data` (string, required) Hex-encoded public key or redeem script. + /// - `rescan` (boolean, optional, default=true) If true, rescan the chain for UTXOs + /// belonging to all wallet transparent addresses after importing. + #[method(name = "z_importaddress")] + async fn import_address( + &self, + account: &str, + hex_data: &str, + rescan: Option, + ) -> z_import_address::Response; + /// Returns the total value of funds stored in the node's wallet. /// /// TODO: Currently watchonly addresses cannot be omitted; `include_watchonly` must be @@ -479,14 +502,14 @@ pub(crate) trait WalletRpc { /// /// Change generated from one or more transparent addresses flows to a new transparent /// address, while change generated from a legacy Sapling address returns to itself. - /// TODO: https://github.com/zcash/wallet/issues/138 + /// TODO: /// /// When sending from a unified address, change is returned to the internal-only /// address for the associated unified account. /// /// When spending coinbase UTXOs, only shielded recipients are permitted and change is /// not allowed; the entire value of the coinbase UTXO(s) must be consumed. - /// TODO: https://github.com/zcash/wallet/issues/137 + /// TODO: /// /// # Arguments /// @@ -800,6 +823,22 @@ impl WalletRpcServer for WalletRpcImpl { get_balances::call(self.wallet().await?.as_ref(), minconf) } + async fn import_address( + &self, + account: &str, + hex_data: &str, + rescan: Option, + ) -> z_import_address::Response { + z_import_address::call( + self.wallet().await?.as_mut(), + self.chain().await?, + account, + hex_data, + rescan, + ) + .await + } + async fn z_get_total_balance( &self, minconf: Option, diff --git a/zallet/src/components/json_rpc/methods/decode_script.rs b/zallet/src/components/json_rpc/methods/decode_script.rs index ee21b8df..a77365b4 100644 --- a/zallet/src/components/json_rpc/methods/decode_script.rs +++ b/zallet/src/components/json_rpc/methods/decode_script.rs @@ -1,10 +1,8 @@ use documented::Documented; use jsonrpsee::core::RpcResult; -use ripemd::Ripemd160; use schemars::JsonSchema; use secp256k1::PublicKey; use serde::Serialize; -use sha2::{Digest, Sha256}; use transparent::address::TransparentAddress; use zcash_keys::encoding::AddressCodec; use zcash_script::{ @@ -14,6 +12,8 @@ use zcash_script::{ use crate::{components::json_rpc::server::LegacyCode, network::Network}; +use super::super::utils::hash160; + pub(crate) type Response = RpcResult; /// The result of decoding a script. @@ -87,12 +87,6 @@ pub(super) fn script_to_json(params: &Network, script_code: &Code) -> Transparen } } -/// Computes the Hash160 of the given data. -fn hash160(data: &[u8]) -> [u8; 20] { - let sha_hash = Sha256::digest(data); - Ripemd160::digest(sha_hash).into() -} - /// Converts a raw public key to its P2PKH address. fn pubkey_to_p2pkh_address(pubkey_bytes: &[u8], params: &Network) -> Option { let pubkey = PublicKey::from_slice(pubkey_bytes).ok()?; diff --git a/zallet/src/components/json_rpc/methods/z_import_address.rs b/zallet/src/components/json_rpc/methods/z_import_address.rs new file mode 100644 index 00000000..758fa159 --- /dev/null +++ b/zallet/src/components/json_rpc/methods/z_import_address.rs @@ -0,0 +1,269 @@ +use documented::Documented; +use jsonrpsee::core::RpcResult; +use schemars::JsonSchema; +use serde::Serialize; +use zaino_state::FetchServiceSubscriber; + +use crate::components::{database::DbConnection, json_rpc::server::LegacyCode}; + +#[cfg(feature = "transparent-key-import")] +use { + crate::{components::json_rpc::utils::hash160, network::Network}, + jsonrpsee::types::ErrorCode as RpcErrorCode, + secp256k1::PublicKey, + transparent::address::TransparentAddress, + zcash_client_backend::data_api::WalletWrite, + zcash_client_sqlite::AccountUuid, + zcash_keys::encoding::AddressCodec, + zcash_script::script::{Code, Redeem}, +}; + +/// Response to a z_importaddress request +pub(crate) type Response = RpcResult; + +/// The result of importing an address. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct ResultType { + /// The type of address imported: "p2pkh" or "p2sh". + #[serde(rename = "type")] + kind: &'static str, + /// The transparent address corresponding to the imported data. + address: String, +} + +pub(super) const PARAM_ACCOUNT_DESC: &str = "The account UUID to import the address into."; +pub(super) const PARAM_HEX_DATA_DESC: &str = + "Hex-encoded public key (P2PKH) or redeem script (P2SH)."; +pub(super) const PARAM_RESCAN_DESC: &str = + "If true (default), rescan the chain for UTXOs belonging to all wallet transparent addresses."; + +#[cfg(feature = "transparent-key-import")] +pub(crate) async fn call( + wallet: &mut DbConnection, + chain: FetchServiceSubscriber, + account: &str, + hex_data: &str, + rescan: Option, +) -> Response { + let account_id = account + .parse() + .map(AccountUuid::from_uuid) + .map_err(|_| RpcErrorCode::InvalidParams)?; + + // Parse the address import data, and call the appropriate import handler + let result = match parse_import(wallet.params(), hex_data)? { + ParsedImport::P2pkh { pubkey, result } => { + wallet + .import_standalone_transparent_pubkey(account_id, pubkey) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))?; + result + } + ParsedImport::P2sh { script, result } => { + wallet + .import_standalone_transparent_script(account_id, script) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))?; + result + } + }; + + if rescan.unwrap_or(true) { + crate::components::sync::fetch_transparent_utxos(&chain, wallet) + .await + .map_err(|e| LegacyCode::Misc.with_message(format!("Rescan failed: {e}")))?; + } + + Ok(result) +} + +#[cfg(not(feature = "transparent-key-import"))] +pub(crate) async fn call( + _wallet: &mut DbConnection, + _chain: FetchServiceSubscriber, + _account: &str, + _hex_data: &str, + _rescan: Option, +) -> Response { + Err(LegacyCode::Misc.with_static("z_importaddress requires the transparent-key-import feature")) +} + +/// Intermediate result of parsing hex-encoded import data. +#[cfg(feature = "transparent-key-import")] +enum ParsedImport { + /// A compressed or uncompressed public key (P2PKH import). + P2pkh { + pubkey: PublicKey, + result: ResultType, + }, + /// A redeem script (P2SH import). + P2sh { script: Redeem, result: ResultType }, +} + +/// Parses hex-encoded data and classifies it as a public key (P2PKH) or redeem +/// script (P2SH), computing the corresponding transparent address. +#[cfg(feature = "transparent-key-import")] +fn parse_import(params: &Network, hex_data: &str) -> RpcResult { + let bytes = hex::decode(hex_data) + .map_err(|_| LegacyCode::InvalidParameter.with_static("Invalid hex encoding"))?; + + // Try to parse as a public key (P2PKH import). + if let Ok(pubkey) = PublicKey::from_slice(&bytes) { + let address = TransparentAddress::from_pubkey(&pubkey).encode(params); + Ok(ParsedImport::P2pkh { + pubkey, + result: ResultType { + kind: "p2pkh", + address, + }, + }) + } else { + // Otherwise treat as a redeem script (P2SH import). + let address = TransparentAddress::ScriptHash(hash160(&bytes)).encode(params); + let code = Code(bytes); + let script = Redeem::parse(&code).map_err(|_| { + LegacyCode::InvalidParameter.with_message(format!( + "Unrecognized input (not a valid pubkey or redeem script): {hex_data}" + )) + })?; + Ok(ParsedImport::P2sh { + script, + result: ResultType { + kind: "p2sh", + address, + }, + }) + } +} + +#[cfg(all(test, feature = "transparent-key-import"))] +mod tests { + use super::*; + use zcash_protocol::consensus; + + fn mainnet() -> Network { + Network::Consensus(consensus::Network::MainNetwork) + } + + fn testnet() -> Network { + Network::Consensus(consensus::Network::TestNetwork) + } + + // Compressed public key from zcashd qa/rpc-tests/decodescript.py:17 + const COMPRESSED_PUBKEY: &str = + "03b0da749730dc9b4b1f4a14d6902877a92541f5368778853d9c4a0cb7802dcfb2"; + + // P2PKH scriptPubKey (a valid script, but not a valid public key): + // OP_DUP OP_HASH160 <20-byte-hash> OP_EQUALVERIFY OP_CHECKSIG + const P2PKH_REDEEM_SCRIPT: &str = "76a91411695b6cd891484c2d49ec5aa738ec2b2f89777788ac"; + + #[test] + fn compressed_pubkey_classified_as_p2pkh() { + let parsed = parse_import(&mainnet(), COMPRESSED_PUBKEY).unwrap(); + match parsed { + ParsedImport::P2pkh { result, .. } => { + assert_eq!(result.kind, "p2pkh"); + assert!( + result.address.starts_with("t1"), + "P2PKH mainnet address should start with t1, got {}", + result.address, + ); + } + ParsedImport::P2sh { .. } => panic!("Expected P2PKH, got P2SH"), + } + } + + #[test] + fn compressed_pubkey_p2pkh_on_testnet() { + let parsed = parse_import(&testnet(), COMPRESSED_PUBKEY).unwrap(); + match parsed { + ParsedImport::P2pkh { result, .. } => { + assert_eq!(result.kind, "p2pkh"); + assert!( + result.address.starts_with("tm"), + "P2PKH testnet address should start with tm, got {}", + result.address, + ); + } + ParsedImport::P2sh { .. } => panic!("Expected P2PKH, got P2SH"), + } + } + + #[test] + fn redeem_script_classified_as_p2sh() { + let parsed = parse_import(&mainnet(), P2PKH_REDEEM_SCRIPT).unwrap(); + match parsed { + ParsedImport::P2sh { result, .. } => { + assert_eq!(result.kind, "p2sh"); + assert!( + result.address.starts_with("t3"), + "P2SH mainnet address should start with t3, got {}", + result.address, + ); + } + ParsedImport::P2pkh { .. } => panic!("Expected P2SH, got P2PKH"), + } + } + + #[test] + fn redeem_script_p2sh_on_testnet() { + let parsed = parse_import(&testnet(), P2PKH_REDEEM_SCRIPT).unwrap(); + match parsed { + ParsedImport::P2sh { result, .. } => { + assert_eq!(result.kind, "p2sh"); + assert!( + result.address.starts_with("t2"), + "P2SH testnet address should start with t2, got {}", + result.address, + ); + } + ParsedImport::P2pkh { .. } => panic!("Expected P2SH, got P2PKH"), + } + } + + #[test] + fn p2sh_address_is_hash160_of_script() { + let script_bytes = hex::decode(P2PKH_REDEEM_SCRIPT).unwrap(); + let expected_address = + TransparentAddress::ScriptHash(hash160(&script_bytes)).encode(&mainnet()); + + let parsed = parse_import(&mainnet(), P2PKH_REDEEM_SCRIPT).unwrap(); + match parsed { + ParsedImport::P2sh { result, .. } => { + assert_eq!(result.address, expected_address); + } + ParsedImport::P2pkh { .. } => panic!("Expected P2SH, got P2PKH"), + } + } + + #[test] + fn p2pkh_address_matches_pubkey() { + let pubkey_bytes = hex::decode(COMPRESSED_PUBKEY).unwrap(); + let pubkey = PublicKey::from_slice(&pubkey_bytes).unwrap(); + let expected_address = TransparentAddress::from_pubkey(&pubkey).encode(&mainnet()); + + let parsed = parse_import(&mainnet(), COMPRESSED_PUBKEY).unwrap(); + match parsed { + ParsedImport::P2pkh { result, .. } => { + assert_eq!(result.address, expected_address); + } + ParsedImport::P2sh { .. } => panic!("Expected P2PKH, got P2SH"), + } + } + + #[test] + fn invalid_hex_returns_error() { + let Err(err) = parse_import(&mainnet(), "not_valid_hex") else { + panic!("Expected error for invalid hex"); + }; + assert_eq!(err.code(), LegacyCode::InvalidParameter as i32); + assert_eq!(err.message(), "Invalid hex encoding"); + } + + #[test] + fn odd_length_hex_returns_error() { + let Err(err) = parse_import(&mainnet(), "abc") else { + panic!("Expected error for odd-length hex"); + }; + assert_eq!(err.code(), LegacyCode::InvalidParameter as i32); + assert_eq!(err.message(), "Invalid hex encoding"); + } +} diff --git a/zallet/src/components/json_rpc/utils.rs b/zallet/src/components/json_rpc/utils.rs index 4fc36a18..f950924b 100644 --- a/zallet/src/components/json_rpc/utils.rs +++ b/zallet/src/components/json_rpc/utils.rs @@ -16,8 +16,16 @@ use zcash_protocol::{ }; use zip32::DiversifierIndex; +use ripemd::Ripemd160; +use sha2::{Digest, Sha256}; + use super::server::LegacyCode; +/// Computes the Hash160 (RIPEMD-160 of SHA-256) of the given data. +pub(super) fn hash160(data: &[u8]) -> [u8; 20] { + Ripemd160::digest(Sha256::digest(data)).into() +} + #[cfg(zallet_build = "wallet")] use { crate::components::{database::DbConnection, keystore::KeyStore}, diff --git a/zallet/src/components/sync.rs b/zallet/src/components/sync.rs index bbf8f6fe..d816560e 100644 --- a/zallet/src/components/sync.rs +++ b/zallet/src/components/sync.rs @@ -154,7 +154,6 @@ impl WalletSync { let poll_transparent_task = crate::spawn!("Poll transparent", async move { poll_transparent( chain_subscriber, - ¶ms, db_data.as_mut(), poll_tip_change_signal_receiver, ) @@ -503,13 +502,63 @@ async fn recover_history( } } +/// Fetches all mined UTXOs for the wallet's non-ephemeral transparent addresses and +/// stores them in the wallet database. +pub(crate) async fn fetch_transparent_utxos( + chain: &FetchServiceSubscriber, + db_data: &mut DbConnection, +) -> Result<(), SyncError> { + let params = db_data.params(); + + // Collect all of the wallet's non-ephemeral transparent addresses. + // + // TODO: This is likely to be append-only unless we add support for removing an + // account from the wallet, so we could implement a more efficient strategy here + // with some changes to the `WalletRead` API. For now this is fine. + let addresses = db_data + .get_account_ids()? + .into_iter() + .map(|account| db_data.get_transparent_receivers(account, true, true)) + .collect::, _>>()? + .into_iter() + .flat_map(|m| m.into_keys().map(|addr| addr.encode(params))) + .collect(); + + // Fetch all mined UTXOs. + // TODO: I really want to use the chaininfo-aware version (which Zaino doesn't + // implement) or an equivalent Zaino index (once it exists). + info!("Fetching mined UTXOs"); + let utxos = chain + .z_get_address_utxos(AddressStrings::new(addresses)) + .await?; + + // Notify the wallet about all mined UTXOs. + for utxo in utxos { + let (address, txid, index, script, value_zat, mined_height) = utxo.into_parts(); + debug!("{address} has UTXO in tx {txid} at index {}", index.index()); + + let output = WalletTransparentOutput::from_parts( + OutPoint::new(txid.0, index.index()), + TxOut::new( + Zatoshis::const_from_u64(value_zat), + Script(script::Code(script.as_raw_bytes().to_vec())), + ), + Some(BlockHeight::from_u32(mined_height.0)), + ) + .expect("the UTXO was detected via a supported address kind"); + + db_data.put_received_transparent_utxo(&output)?; + } + + Ok(()) +} + /// Polls the non-ephemeral transparent addresses in the wallet for UTXOs. /// /// Ephemeral addresses are handled by [`data_requests`]. #[tracing::instrument(skip_all)] async fn poll_transparent( chain: FetchServiceSubscriber, - params: &Network, db_data: &mut DbConnection, tip_change_signal: Arc, ) -> Result<(), SyncError> { @@ -519,46 +568,7 @@ async fn poll_transparent( // Wait for the chain tip to advance tip_change_signal.notified().await; - // Collect all of the wallet's non-ephemeral transparent addresses. We do this - // fresh every loop to ensure we incorporate changes to the address set. - // - // TODO: This is likely to be append-only unless we add support for removing an - // account from the wallet, so we could implement a more efficient strategy here - // with some changes to the `WalletRead` API. For now this is fine. - let addresses = db_data - .get_account_ids()? - .into_iter() - .map(|account| db_data.get_transparent_receivers(account, true, true)) - .collect::, _>>()? - .into_iter() - .flat_map(|m| m.into_keys().map(|addr| addr.encode(params))) - .collect(); - - // Fetch all mined UTXOs. - // TODO: I really want to use the chaininfo-aware version (which Zaino doesn't - // implement) or an equivalent Zaino index (once it exists). - info!("Fetching mined UTXOs"); - let utxos = chain - .z_get_address_utxos(AddressStrings::new(addresses)) - .await?; - - // Notify the wallet about all mined UTXOs. - for utxo in utxos { - let (address, txid, index, script, value_zat, mined_height) = utxo.into_parts(); - debug!("{address} has UTXO in tx {txid} at index {}", index.index()); - - let output = WalletTransparentOutput::from_parts( - OutPoint::new(txid.0, index.index()), - TxOut::new( - Zatoshis::const_from_u64(value_zat), - Script(script::Code(script.as_raw_bytes().to_vec())), - ), - Some(BlockHeight::from_u32(mined_height.0)), - ) - .expect("the UTXO was detected via a supported address kind"); - - db_data.put_received_transparent_utxo(&output)?; - } + fetch_transparent_utxos(&chain, db_data).await?; // TODO: Once Zaino has an index over the mempool, monitor it for changes to the // unmined UTXO set (which we can't get directly from the stream without building // an index because existing mempool txs can be spent within the mempool).