Skip to content
Merged
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 @@ -14,6 +14,7 @@ be considered breaking changes.
- `decodescript`
- `verifymessage`
- `z_converttex`
- `z_importaddress`

### Changed
- `getrawtransaction` now correctly reports the fields `asm`, `reqSigs`, `kind`,
Expand Down
43 changes: 41 additions & 2 deletions zallet/src/components/json_rpc/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -402,6 +404,27 @@ pub(crate) trait WalletRpc {
#[method(name = "z_getbalances")]
async fn get_balances(&self, minconf: Option<u32>) -> 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<bool>,
) -> 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
Expand Down Expand Up @@ -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: <https://github.com/zcash/wallet/issues/138>
///
/// 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: <https://github.com/zcash/wallet/issues/137>
///
/// # Arguments
///
Expand Down Expand Up @@ -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<bool>,
) -> 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<u32>,
Expand Down
10 changes: 2 additions & 8 deletions zallet/src/components/json_rpc/methods/decode_script.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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<ResultType>;

/// The result of decoding a script.
Expand Down Expand Up @@ -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<String> {
let pubkey = PublicKey::from_slice(pubkey_bytes).ok()?;
Expand Down
269 changes: 269 additions & 0 deletions zallet/src/components/json_rpc/methods/z_import_address.rs
Original file line number Diff line number Diff line change
@@ -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<ResultType>;

/// 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<bool>,
) -> 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<bool>,
) -> 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<ParsedImport> {
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");
}
}
Loading
Loading