diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b79b51..adfb6760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ be considered breaking changes. ## [0.1.0-alpha.4] - PLANNED +### Added +- RPC methods: + - `decoderawtransaction` + - `decodescript` + - `verifymessage` + - `z_converttex` + +### Changed +- `getrawtransaction` now correctly reports the fields `asm`, `reqSigs`, `kind`, + and `addresses` for transparent outputs. +- `z_viewtransaction`: The `outgoing` field is now omitted on outputs that + `zcashd` didn't include in its response. + ### Fixed - No longer crashes in regtest mode when an NU5 activation height is not defined. diff --git a/Cargo.lock b/Cargo.lock index 31244b63..f221b94f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4315,7 +4315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.11.0", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -4335,7 +4335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck", - "itertools 0.11.0", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -4355,7 +4355,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.108", @@ -4368,7 +4368,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.108", @@ -7490,7 +7490,7 @@ dependencies = [ "zcash_primitives 0.26.3", "zcash_proofs 0.26.1", "zcash_protocol 0.7.2", - "zcash_script 0.4.2", + "zcash_script 0.4.3", "zcash_transparent 0.6.2", "zebra-chain", "zebra-rpc", @@ -7589,7 +7589,7 @@ dependencies = [ "zcash_note_encryption", "zcash_primitives 0.26.3", "zcash_protocol 0.7.2", - "zcash_script 0.4.2", + "zcash_script 0.4.3", "zcash_transparent 0.6.2", "zip32 0.2.1", "zip321", @@ -7636,7 +7636,7 @@ dependencies = [ "zcash_keys 0.12.0", "zcash_primitives 0.26.3", "zcash_protocol 0.7.2", - "zcash_script 0.4.2", + "zcash_script 0.4.3", "zcash_transparent 0.6.2", "zip32 0.2.1", ] @@ -7891,7 +7891,7 @@ dependencies = [ "zcash_encoding 0.3.0", "zcash_note_encryption", "zcash_protocol 0.7.2", - "zcash_script 0.4.2", + "zcash_script 0.4.3", "zcash_spec 0.2.1", "zcash_transparent 0.6.2", "zip32 0.2.1", @@ -8000,9 +8000,9 @@ dependencies = [ [[package]] name = "zcash_script" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bed6cf5b2b4361105d4ea06b2752f0c8af4641756c7fbc9858a80af186c234f" +checksum = "c6ef9d04e0434a80b62ad06c5a610557be358ef60a98afa5dbc8ecaf19ad72e7" dependencies = [ "bip32 0.6.0-pre.1", "bitflags 2.10.0", @@ -8077,7 +8077,7 @@ dependencies = [ "zcash_address 0.10.1", "zcash_encoding 0.3.0", "zcash_protocol 0.7.2", - "zcash_script 0.4.2", + "zcash_script 0.4.3", "zcash_spec 0.2.1", "zip32 0.2.1", ] diff --git a/Cargo.toml b/Cargo.toml index 8d3fad3d..8f788522 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,7 +113,7 @@ zcash_encoding = "0.3.0" zcash_keys = { version = "0.12", features = ["transparent-inputs", "sapling", "orchard", "transparent-key-encoding"] } zcash_primitives = "0.26" zcash_proofs = "0.26" -zcash_script = "0.4" +zcash_script = "0.4.3" secp256k1 = { version = "0.29", features = ["recovery"] } # Zcash chain state diff --git a/supply-chain/audits.toml b/supply-chain/audits.toml index daa924a1..a4bb0dd8 100644 --- a/supply-chain/audits.toml +++ b/supply-chain/audits.toml @@ -897,6 +897,12 @@ user-id = 159631 # Conrado (conradoplg) start = "2022-08-31" end = "2026-04-08" +[[trusted.zcash_script]] +criteria = "safe-to-deploy" +user-id = 228785 # Alfredo Garcia (oxarbitrage) +start = "2023-10-18" +end = "2027-03-09" + [[trusted.zcash_spec]] criteria = "safe-to-deploy" user-id = 199950 # Daira-Emma Hopwood (daira) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 4486d8be..4912d14c 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -28,20 +28,6 @@ audit-as-crates-io = true [policy.age-core] audit-as-crates-io = true -[policy.equihash] - -[policy.f4jumble] - -[policy.orchard] - -[policy.redjubjub] - -[policy.sapling-crypto] - -[policy.tower-batch-control] - -[policy.tower-fallback] - [policy.zaino-fetch] audit-as-crates-io = true @@ -51,43 +37,10 @@ audit-as-crates-io = true [policy.zaino-state] audit-as-crates-io = true -[policy.zcash_address] - -[policy.zcash_client_backend] - -[policy.zcash_client_sqlite] - -[policy.zcash_encoding] - -[policy.zcash_history] - -[policy.zcash_keys] - -[policy.zcash_primitives] - -[policy.zcash_proofs] +[policy."zcash_encoding:0.2.2"] -[policy.zcash_protocol] - -[policy.zcash_transparent] - -[policy.zebra-chain] - -[policy.zebra-consensus] - -[policy.zebra-network] - -[policy.zebra-node-services] - -[policy.zebra-rpc] - -[policy.zebra-script] - -[policy.zebra-state] - -[policy.zip32] - -[policy.zip321] +[policy."zcash_encoding:0.3.0@git:cf39300ffa47aca9f2dec1f55727d17a5301f2fe"] +audit-as-crates-io = true [[exemptions.abscissa_core]] version = "0.9.0" @@ -1941,6 +1894,10 @@ criteria = "safe-to-deploy" version = "0.2.2" criteria = "safe-to-deploy" +[[exemptions.zcash_encoding]] +version = "0.3.0@git:cf39300ffa47aca9f2dec1f55727d17a5301f2fe" +criteria = "safe-to-deploy" + [[exemptions.zcash_spec]] version = "0.1.2" criteria = "safe-to-deploy" diff --git a/supply-chain/imports.lock b/supply-chain/imports.lock index 1fbf3e93..b4b8238e 100644 --- a/supply-chain/imports.lock +++ b/supply-chain/imports.lock @@ -371,13 +371,6 @@ user-id = 6289 user-login = "str4d" user-name = "Jack Grigg" -[[publisher.zcash_encoding]] -version = "0.3.0" -when = "2025-02-21" -user-id = 169181 -user-login = "nuttycom" -user-name = "Kris Nuttycombe" - [[publisher.zcash_history]] version = "0.4.0" when = "2024-03-01" @@ -470,11 +463,11 @@ user-login = "conradoplg" user-name = "Conrado" [[publisher.zcash_script]] -version = "0.4.2" -when = "2025-10-24" -user-id = 6289 -user-login = "str4d" -user-name = "Jack Grigg" +version = "0.4.3" +when = "2026-02-23" +user-id = 228785 +user-login = "oxarbitrage" +user-name = "Alfredo Garcia" [[publisher.zcash_spec]] version = "0.2.1" diff --git a/zallet/src/components/json_rpc/methods.rs b/zallet/src/components/json_rpc/methods.rs index 1b6f845c..4dff7384 100644 --- a/zallet/src/components/json_rpc/methods.rs +++ b/zallet/src/components/json_rpc/methods.rs @@ -666,7 +666,7 @@ impl RpcServer for RpcImpl { } async fn decode_raw_transaction(&self, hexstring: &str) -> decode_raw_transaction::Response { - decode_raw_transaction::call(hexstring) + decode_raw_transaction::call(self.wallet().await?.params(), hexstring) } async fn view_transaction(&self, txid: &str) -> view_transaction::Response { diff --git a/zallet/src/components/json_rpc/methods/decode_raw_transaction.rs b/zallet/src/components/json_rpc/methods/decode_raw_transaction.rs index 68e993e8..70e9e3b9 100644 --- a/zallet/src/components/json_rpc/methods/decode_raw_transaction.rs +++ b/zallet/src/components/json_rpc/methods/decode_raw_transaction.rs @@ -1,96 +1,21 @@ -use documented::Documented; use jsonrpsee::core::RpcResult; -use schemars::JsonSchema; -use serde::Serialize; use zcash_primitives::transaction::Transaction; -use zcash_protocol::{TxId, consensus}; +use zcash_protocol::consensus; -use super::get_raw_transaction::{ - Orchard, SaplingOutput, SaplingSpend, TransparentInput, TransparentOutput, -}; -use crate::components::json_rpc::{ - server::LegacyCode, - utils::{JsonZecBalance, value_from_zat_balance}, -}; +use super::get_raw_transaction::{TransactionDetails, tx_to_json}; +use crate::{components::json_rpc::server::LegacyCode, network::Network}; /// Response to a `decoderawtransaction` RPC request. pub(crate) type Response = RpcResult; /// The result type for OpenRPC schema generation. -pub(crate) type ResultType = DecodedTransaction; - -/// A decoded transaction. -/// -/// Based on zcashd `src/rpc/rawtransaction.cpp:212-338`. -#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] -pub(crate) struct DecodedTransaction { - /// The transaction id. - txid: String, - - /// The transaction's auth digest. For pre-v5 txs this is ffff..ffff. - authdigest: String, - - /// The transaction size. - size: u64, - - /// The Overwintered flag. - overwintered: bool, - - /// The version. - version: u32, - - /// The version group id (Overwintered txs). - #[serde(skip_serializing_if = "Option::is_none")] - versiongroupid: Option, - - /// The lock time. - locktime: u32, - - /// Last valid block height for mining transaction (Overwintered txs). - #[serde(skip_serializing_if = "Option::is_none")] - expiryheight: Option, - - /// The transparent inputs. - vin: Vec, - - /// The transparent outputs. - vout: Vec, - - /// The net value of Sapling Spends minus Outputs in ZEC. - #[serde(rename = "valueBalance")] - #[serde(skip_serializing_if = "Option::is_none")] - value_balance: Option, - - /// The net value of Sapling Spends minus Outputs in zatoshis. - #[serde(rename = "valueBalanceZat")] - #[serde(skip_serializing_if = "Option::is_none")] - value_balance_zat: Option, - - /// The Sapling spends. - #[serde(rename = "vShieldedSpend")] - #[serde(skip_serializing_if = "Option::is_none")] - v_shielded_spend: Option>, - - /// The Sapling outputs. - #[serde(rename = "vShieldedOutput")] - #[serde(skip_serializing_if = "Option::is_none")] - v_shielded_output: Option>, - - /// The Sapling binding sig. - #[serde(rename = "bindingSig")] - #[serde(skip_serializing_if = "Option::is_none")] - binding_sig: Option, - - /// The Orchard bundle. - #[serde(skip_serializing_if = "Option::is_none")] - orchard: Option, -} +pub(crate) type ResultType = TransactionDetails; /// Parameter description for OpenRPC schema generation. pub(super) const PARAM_HEXSTRING_DESC: &str = "The transaction hex string"; /// Decodes a hex-encoded transaction. -pub(crate) fn call(hexstring: &str) -> Response { +pub(crate) fn call(params: &Network, hexstring: &str) -> Response { let tx_bytes = hex::decode(hexstring) .map_err(|_| LegacyCode::Deserialization.with_static("TX decode failed"))?; @@ -100,78 +25,8 @@ pub(crate) fn call(hexstring: &str) -> Response { .map_err(|_| LegacyCode::Deserialization.with_static("TX decode failed"))?; let size = tx_bytes.len() as u64; - let overwintered = tx.version().has_overwinter(); - - let (vin, vout) = tx - .transparent_bundle() - .map(|bundle| { - ( - bundle - .vin - .iter() - .map(|tx_in| TransparentInput::encode(tx_in, bundle.is_coinbase())) - .collect(), - bundle - .vout - .iter() - .zip(0..) - .map(TransparentOutput::encode) - .collect(), - ) - }) - .unwrap_or_default(); - let (value_balance, value_balance_zat, v_shielded_spend, v_shielded_output, binding_sig) = - if let Some(bundle) = tx.sapling_bundle() { - ( - Some(value_from_zat_balance(*bundle.value_balance())), - Some(bundle.value_balance().into()), - Some( - bundle - .shielded_spends() - .iter() - .map(SaplingSpend::encode) - .collect(), - ), - Some( - bundle - .shielded_outputs() - .iter() - .map(SaplingOutput::encode) - .collect(), - ), - Some(hex::encode(<[u8; 64]>::from( - bundle.authorization().binding_sig, - ))), - ) - } else { - (None, None, None, None, None) - }; - - let orchard = tx - .version() - .has_orchard() - .then(|| Orchard::encode(tx.orchard_bundle())); - - Ok(DecodedTransaction { - txid: tx.txid().to_string(), - authdigest: TxId::from_bytes(tx.auth_commitment().as_bytes().try_into().unwrap()) - .to_string(), - size, - overwintered, - version: tx.version().header() & 0x7FFFFFFF, - versiongroupid: overwintered.then(|| format!("{:08x}", tx.version().version_group_id())), - locktime: tx.lock_time(), - expiryheight: overwintered.then(|| tx.expiry_height().into()), - vin, - vout, - value_balance, - value_balance_zat, - v_shielded_spend, - v_shielded_output, - binding_sig, - orchard, - }) + Ok(tx_to_json(params, tx, size)) } #[cfg(test)] @@ -179,29 +34,12 @@ mod tests { use super::*; // Test vectors from zcashd `src/test/rpc_tests.cpp:26-70` - - const V1_TX_HEX: &str = "0100000001a15d57094aa7a21a28cb20b59aab8fc7d1149a3bdbcddba9c622e4f5f6a99ece010000006c493046022100f93bb0e7d8db7bd46e40132d1f8242026e045f03a0efe71bbb8e3f475e970d790221009337cd7f1f929f00cc6ff01f03729b069a7c21b59b1736ddfee5db5946c5da8c0121033b9b137ee87d5a812d6f506efdd37f0affa7ffc310711c06c7f3e097c9447c52ffffffff0100e1f505000000001976a9140389035a9225b3839e2bbf32d826a1e222031fd888ac00000000"; - - /// Tests decoding a version 1 transaction. - #[test] - fn decode_v1_transaction() { - let result = call(V1_TX_HEX); - assert!(result.is_ok()); - let tx = result.unwrap(); - assert_eq!(tx.size, 193); - assert_eq!(tx.version, 1); - assert_eq!(tx.locktime, 0); - assert!(!tx.overwintered); - assert!(tx.versiongroupid.is_none()); - assert!(tx.expiryheight.is_none()); - assert_eq!(tx.vin.len(), 1); - assert_eq!(tx.vout.len(), 1); - } + const MAINNET: &Network = &Network::Consensus(consensus::Network::MainNetwork); /// Tests that "DEADBEEF" (valid hex but invalid transaction) returns an error. #[test] fn decode_deadbeef_returns_error() { - let result = call("DEADBEEF"); + let result = call(MAINNET, "DEADBEEF"); assert!(result.is_err()); let err = result.unwrap_err(); assert_eq!(err.message(), "TX decode failed"); @@ -210,7 +48,7 @@ mod tests { /// Tests that "null" (invalid hex) returns an error. #[test] fn decode_null_returns_error() { - let result = call("null"); + let result = call(MAINNET, "null"); assert!(result.is_err()); let err = result.unwrap_err(); assert_eq!(err.message(), "TX decode failed"); @@ -219,7 +57,7 @@ mod tests { /// Tests that an empty string returns an error. #[test] fn decode_empty_returns_error() { - let result = call(""); + let result = call(MAINNET, ""); assert!(result.is_err()); let err = result.unwrap_err(); assert_eq!(err.message(), "TX decode failed"); @@ -228,7 +66,7 @@ mod tests { /// Tests that the error code is RPC_DESERIALIZATION_ERROR (value -22) for decode failures. #[test] fn decode_error_has_correct_code() { - let result = call("DEADBEEF"); + let result = call(MAINNET, "DEADBEEF"); let err = result.unwrap_err(); assert_eq!(err.code(), LegacyCode::Deserialization as i32); } diff --git a/zallet/src/components/json_rpc/methods/decode_script.rs b/zallet/src/components/json_rpc/methods/decode_script.rs index 579e41ca..ee21b8df 100644 --- a/zallet/src/components/json_rpc/methods/decode_script.rs +++ b/zallet/src/components/json_rpc/methods/decode_script.rs @@ -19,27 +19,37 @@ pub(crate) type Response = RpcResult; /// The result of decoding a script. #[derive(Clone, Debug, Serialize, Documented, JsonSchema)] pub(crate) struct ResultType { - /// String representation of the script public key. + #[serde(flatten)] + inner: TransparentScript, + + /// The P2SH address for this script. + p2sh: String, +} + +#[derive(Clone, Debug, Serialize, JsonSchema)] +pub(super) struct TransparentScript { + /// The assembly string representation of the script. asm: String, - /// The type of script. - /// - /// One of: `pubkeyhash`, `scripthash`, `pubkey`, `multisig`, `nulldata`, `nonstandard`. - #[serde(rename = "type")] - kind: &'static str, - /// The required number of signatures. + + /// The required number of signatures to spend this output. /// /// Omitted for scripts that don't contain identifiable addresses (such as /// non-standard or null-data scripts). #[serde(rename = "reqSigs", skip_serializing_if = "Option::is_none")] req_sigs: Option, - /// The addresses associated with this script. + + /// The type of script. + /// + /// One of `["pubkey", "pubkeyhash", "scripthash", "multisig", "nulldata", "nonstandard"]`. + #[serde(rename = "type")] + kind: &'static str, + + /// Array of the transparent addresses involved in the script. /// /// Omitted for scripts that don't contain identifiable addresses (such as /// non-standard or null-data scripts). #[serde(skip_serializing_if = "Vec::is_empty")] addresses: Vec, - /// The P2SH address for this script. - p2sh: String, } pub(super) const PARAM_HEXSTRING_DESC: &str = "The hex-encoded script."; @@ -54,47 +64,27 @@ pub(crate) fn call(params: &Network, hexstring: &str) -> Response { .map_err(|_| LegacyCode::Deserialization.with_static("Hex decoding failed"))?; let script_code = Code(script_bytes); - let asm = to_zcashd_asm(&script_code.to_asm(false)); - let (kind, req_sigs, addresses) = detect_script_info(&script_code, params); let p2sh = calculate_p2sh_address(&script_code.0, params); Ok(ResultType { - asm, - kind, - req_sigs, - addresses, + inner: script_to_json(params, &script_code), p2sh, }) } -/// Converts zcash_script ASM format to zcashd format. -/// -/// zcashd uses numbers for small values (OP_1 -> "1", OP_1NEGATE -> "-1") -fn to_zcashd_asm(asm: &str) -> String { - asm.split(' ') - .map(|token| match token { - "OP_1NEGATE" => "-1", - "OP_1" => "1", - "OP_2" => "2", - "OP_3" => "3", - "OP_4" => "4", - "OP_5" => "5", - "OP_6" => "6", - "OP_7" => "7", - "OP_8" => "8", - "OP_9" => "9", - "OP_10" => "10", - "OP_11" => "11", - "OP_12" => "12", - "OP_13" => "13", - "OP_14" => "14", - "OP_15" => "15", - "OP_16" => "16", - other => other, - }) - .collect::>() - .join(" ") +/// Equivalent of `ScriptPubKeyToJSON` in `zcashd` with `fIncludeHex = false`. +pub(super) fn script_to_json(params: &Network, script_code: &Code) -> TransparentScript { + let asm = script_code.to_asm(false); + + let (kind, req_sigs, addresses) = detect_script_info(script_code, params); + + TransparentScript { + asm, + kind, + req_sigs, + addresses, + } } /// Computes the Hash160 of the given data. @@ -119,6 +109,13 @@ fn calculate_p2sh_address(script_bytes: &[u8], params: &Network) -> String { /// Detects the script type and extracts associated information. /// /// Returns a tuple of (type_name, required_sigs, addresses). +// +// TODO: Replace match arms with `ScriptKind::as_str()` and `ScriptKind::req_sigs()` +// once zcash_script is upgraded past 0.4.x. +// See https://github.com/ZcashFoundation/zcash_script/pull/291 +// TODO: zcashd relied on initialization behaviour for the default value +// for null-data or non-standard outputs. Figure out what it is. +// https://github.com/zcash/wallet/issues/236 fn detect_script_info( script_code: &Code, params: &Network, @@ -186,7 +183,7 @@ mod tests { // From zcashd qa/rpc-tests/decodescript.py:65 // P2PKH: OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG let script_hex = format!("76a914{ZCASHD_PUBLIC_KEY_HASH}88ac"); - let result = call(&mainnet(), &script_hex).unwrap(); + let result = call(&mainnet(), &script_hex).unwrap().inner; assert_eq!(result.kind, "pubkeyhash"); assert_eq!(result.req_sigs, Some(1)); @@ -203,7 +200,7 @@ mod tests { // From zcashd qa/rpc-tests/decodescript.py:73 // P2SH: OP_HASH160 OP_EQUAL let script_hex = format!("a914{ZCASHD_PUBLIC_KEY_HASH}87"); - let result = call(&mainnet(), &script_hex).unwrap(); + let result = call(&mainnet(), &script_hex).unwrap().inner; assert_eq!(result.kind, "scripthash"); assert_eq!(result.req_sigs, Some(1)); @@ -221,7 +218,7 @@ mod tests { // P2PK: OP_CHECKSIG // 0x21 = 33 bytes push opcode let script_hex = format!("21{ZCASHD_PUBLIC_KEY}ac"); - let result = call(&mainnet(), &script_hex).unwrap(); + let result = call(&mainnet(), &script_hex).unwrap().inner; assert_eq!(result.kind, "pubkey"); assert_eq!(result.req_sigs, Some(1)); @@ -243,7 +240,7 @@ mod tests { 53ae", pk = ZCASHD_PUBLIC_KEY ); - let result = call(&mainnet(), &script_hex).unwrap(); + let result = call(&mainnet(), &script_hex).unwrap().inner; assert_eq!(result.kind, "multisig"); assert_eq!(result.req_sigs, Some(2)); @@ -262,7 +259,7 @@ mod tests { let script_hex = "6a48304502207fa7a6d1e0ee81132a269ad84e68d695483745cde8b541e\ 3bf630749894e342a022100c1f7ab20e13e22fb95281a870f3dcf38d782e53023ee31\ 3d741ad0cfbc0c509001"; - let result = call(&mainnet(), script_hex).unwrap(); + let result = call(&mainnet(), script_hex).unwrap().inner; assert_eq!(result.kind, "nulldata"); assert_eq!(result.req_sigs, None); @@ -274,7 +271,7 @@ mod tests { fn decode_nonstandard_script() { // OP_TRUE (0x51) let script_hex = "51"; - let result = call(&mainnet(), script_hex).unwrap(); + let result = call(&mainnet(), script_hex).unwrap().inner; assert_eq!(result.kind, "nonstandard"); assert_eq!(result.req_sigs, None); @@ -287,10 +284,10 @@ mod tests { fn decode_empty_script() { let result = call(&mainnet(), "").unwrap(); - assert_eq!(result.kind, "nonstandard"); - assert_eq!(result.req_sigs, None); - assert!(result.addresses.is_empty()); - assert!(result.asm.is_empty()); + assert_eq!(result.inner.kind, "nonstandard"); + assert_eq!(result.inner.req_sigs, None); + assert!(result.inner.addresses.is_empty()); + assert!(result.inner.asm.is_empty()); // P2SH should still be computed (hash of empty script) assert!(result.p2sh.starts_with("t3")); } @@ -310,9 +307,9 @@ mod tests { let script_hex = format!("76a914{ZCASHD_PUBLIC_KEY_HASH}88ac"); let result = call(&testnet(), &script_hex).unwrap(); - assert_eq!(result.kind, "pubkeyhash"); + assert_eq!(result.inner.kind, "pubkeyhash"); // Testnet addresses start with "tm" for P2PKH - assert!(result.addresses[0].starts_with("tm")); + assert!(result.inner.addresses[0].starts_with("tm")); // P2SH testnet addresses start with "t2" assert!(result.p2sh.starts_with("t2")); } @@ -328,4 +325,89 @@ mod tests { let expected_p2sh = calculate_p2sh_address(&script_bytes, &mainnet()); assert_eq!(result.p2sh, expected_p2sh); } + + /// P2PKH scriptPubKey asm output. + /// + /// Test vector from zcashd `qa/rpc-tests/decodescript.py:134`. + #[test] + fn scriptpubkey_asm_p2pkh() { + let script = + Code(hex::decode("76a914dc863734a218bfe83ef770ee9d41a27f824a6e5688ac").unwrap()); + let asm = script.to_asm(false); + assert_eq!( + asm, + "OP_DUP OP_HASH160 dc863734a218bfe83ef770ee9d41a27f824a6e56 OP_EQUALVERIFY OP_CHECKSIG" + ); + + // Verify script type detection + let (kind, req_sigs, _) = detect_script_info(&script, &mainnet()); + assert_eq!(kind, "pubkeyhash"); + assert_eq!(req_sigs, Some(1)); + } + + /// P2SH scriptPubKey asm output. + /// + /// Test vector from zcashd `qa/rpc-tests/decodescript.py:135`. + #[test] + fn scriptpubkey_asm_p2sh() { + let script = Code(hex::decode("a9142a5edea39971049a540474c6a99edf0aa4074c5887").unwrap()); + let asm = script.to_asm(false); + assert_eq!( + asm, + "OP_HASH160 2a5edea39971049a540474c6a99edf0aa4074c58 OP_EQUAL" + ); + + let (kind, req_sigs, _) = detect_script_info(&script, &mainnet()); + assert_eq!(kind, "scripthash"); + assert_eq!(req_sigs, Some(1)); + } + + /// OP_RETURN nulldata scriptPubKey. + /// + /// Test vector from zcashd `qa/rpc-tests/decodescript.py:142`. + #[test] + fn scriptpubkey_asm_nulldata() { + let script = Code(hex::decode("6a09300602010002010001").unwrap()); + let asm = script.to_asm(false); + assert_eq!(asm, "OP_RETURN 300602010002010001"); + + let (kind, req_sigs, addresses) = detect_script_info(&script, &mainnet()); + assert_eq!(kind, "nulldata"); + assert_eq!(req_sigs, None); + assert!(addresses.is_empty()); + } + + /// P2PK scriptPubKey (uncompressed pubkey). + /// + /// Pubkey extracted from zcashd `qa/rpc-tests/decodescript.py:122-125` scriptSig, + /// wrapped in P2PK format (OP_PUSHBYTES_65 OP_CHECKSIG). + #[test] + fn scriptpubkey_asm_p2pk() { + let script = Code(hex::decode( + "4104d3f898e6487787910a690410b7a917ef198905c27fb9d3b0a42da12aceae0544fc7088d239d9a48f2828a15a09e84043001f27cc80d162cb95404e1210161536ac" + ).unwrap()); + let asm = script.to_asm(false); + assert_eq!( + asm, + "04d3f898e6487787910a690410b7a917ef198905c27fb9d3b0a42da12aceae0544fc7088d239d9a48f2828a15a09e84043001f27cc80d162cb95404e1210161536 OP_CHECKSIG" + ); + + let (kind, req_sigs, _) = detect_script_info(&script, &mainnet()); + assert_eq!(kind, "pubkey"); + assert_eq!(req_sigs, Some(1)); + } + + /// Nonstandard script detection. + /// + /// Tests fallback behavior for scripts that don't match standard patterns. + #[test] + fn scriptpubkey_nonstandard() { + // Just OP_TRUE (0x51) - a valid but nonstandard script + let script = Code(hex::decode("51").unwrap()); + + let (kind, req_sigs, addresses) = detect_script_info(&script, &mainnet()); + assert_eq!(kind, "nonstandard"); + assert_eq!(req_sigs, None); + assert!(addresses.is_empty()); + } } diff --git a/zallet/src/components/json_rpc/methods/get_raw_transaction.rs b/zallet/src/components/json_rpc/methods/get_raw_transaction.rs index ccf4eee8..5d3668da 100644 --- a/zallet/src/components/json_rpc/methods/get_raw_transaction.rs +++ b/zallet/src/components/json_rpc/methods/get_raw_transaction.rs @@ -8,6 +8,7 @@ use serde::Serialize; use transparent::bundle::{TxIn, TxOut}; use zaino_state::{FetchServiceError, FetchServiceSubscriber, LightWalletIndexer, ZcashIndexer}; use zcash_encoding::ReverseHex; +use zcash_primitives::transaction::TxVersion; use zcash_protocol::{ TxId, consensus::{self, BlockHeight}, @@ -15,12 +16,18 @@ use zcash_protocol::{ }; use zcash_script::script::{Asm, Code}; -use crate::components::{ - database::DbConnection, - json_rpc::{ - server::LegacyCode, - utils::{JsonZec, JsonZecBalance, parse_txid, value_from_zat_balance, value_from_zatoshis}, +use super::decode_script::{TransparentScript, script_to_json}; +use crate::{ + components::{ + database::DbConnection, + json_rpc::{ + server::LegacyCode, + utils::{ + JsonZec, JsonZecBalance, parse_txid, value_from_zat_balance, value_from_zatoshis, + }, + }, }, + network::Network, }; /// Response to a `getrawtransaction` RPC request. @@ -49,6 +56,48 @@ pub(crate) struct Transaction { /// The serialized, hex-encoded data for the transaction identified by `txid`. hex: String, + #[serde(flatten)] + inner: TransactionDetails, + + /// The hash of the block that the transaction is mined in, if any. + /// + /// Omitted if the transaction is not known to be mined in any block. + #[serde(skip_serializing_if = "Option::is_none")] + blockhash: Option, + + /// The height of the block that the transaction is mined in, or -1 if that block is + /// not in the current best chain. + /// + /// Omitted if `blockhash` is either omitted or unknown. + #[serde(skip_serializing_if = "Option::is_none")] + height: Option, + + /// The number of confirmations the transaction has, or 0 if the block it is mined in + /// is not in the current best chain. + /// + /// Omitted if `blockhash` is either omitted or unknown. + #[serde(skip_serializing_if = "Option::is_none")] + confirmations: Option, + + /// The transaction time in seconds since epoch (Jan 1 1970 GMT). + /// + /// This is always identical to `blocktime`. + /// + /// Omitted if `blockhash` is either omitted or not in the current best chain. + #[serde(skip_serializing_if = "Option::is_none")] + time: Option, + + /// The block time in seconds since epoch (Jan 1 1970 GMT) for the block that the + /// transaction is mined in. + /// + /// Omitted if `blockhash` is either omitted or not in the current best chain. + #[serde(skip_serializing_if = "Option::is_none")] + blocktime: Option, +} + +/// Verbose information about a transaction. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct TransactionDetails { /// The transaction ID (same as provided). txid: String, @@ -147,41 +196,6 @@ pub(crate) struct Transaction { #[serde(rename = "joinSplitSig")] #[serde(skip_serializing_if = "Option::is_none")] join_split_sig: Option, - - /// The hash of the block that the transaction is mined in, if any. - /// - /// Omitted if the transaction is not known to be mined in any block. - #[serde(skip_serializing_if = "Option::is_none")] - blockhash: Option, - - /// The height of the block that the transaction is mined in, or -1 if that block is - /// not in the current best chain. - /// - /// Omitted if `blockhash` is either omitted or unknown. - #[serde(skip_serializing_if = "Option::is_none")] - height: Option, - - /// The number of confirmations the transaction has, or 0 if the block it is mined in - /// is not in the current best chain. - /// - /// Omitted if `blockhash` is either omitted or unknown. - #[serde(skip_serializing_if = "Option::is_none")] - confirmations: Option, - - /// The transaction time in seconds since epoch (Jan 1 1970 GMT). - /// - /// This is always identical to `blocktime`. - /// - /// Omitted if `blockhash` is either omitted or not in the current best chain. - #[serde(skip_serializing_if = "Option::is_none")] - time: Option, - - /// The block time in seconds since epoch (Jan 1 1970 GMT) for the block that the - /// transaction is mined in. - /// - /// Omitted if `blockhash` is either omitted or not in the current best chain. - #[serde(skip_serializing_if = "Option::is_none")] - blocktime: Option, } #[derive(Clone, Debug, Serialize, JsonSchema)] @@ -244,24 +258,11 @@ pub(super) struct TransparentOutput { #[derive(Clone, Debug, Serialize, JsonSchema)] pub(super) struct TransparentScriptPubKey { - /// The assembly string representation of the script. - asm: String, - /// The serialized script, encoded as a hex string. hex: String, - /// The required number of signatures to spend this output. - #[serde(rename = "reqSigs")] - req_sigs: u8, - - /// The type of script. - /// - /// One of `["pubkey", "pubkeyhash", "scripthash", "multisig", "nulldata", "nonstandard"]`. - #[serde(rename = "type")] - kind: &'static str, - - /// Array of the transparent P2PKH addresses involved in the script. - addresses: Vec, + #[serde(flatten)] + inner: TransparentScript, } #[cfg(zallet_unimplemented)] @@ -541,6 +542,8 @@ pub(crate) async fn call( .expect("not our problem"), ) + 1; + let size = tx.hex().as_ref().len() as u64; + let consensus_branch_id = consensus::BranchId::for_height( wallet.params(), tx.height() @@ -551,8 +554,24 @@ pub(crate) async fn call( zcash_primitives::transaction::Transaction::read(tx.hex().as_ref(), consensus_branch_id) .expect("guaranteed to be parseable by Zaino"); - let size = (tx_hex.len() / 2) as u64; + Ok(ResultType::Verbose(Box::new(Transaction { + in_active_chain: None, + hex: tx_hex, + inner: tx_to_json(wallet.params(), tx, size), + blockhash, + height, + confirmations, + time, + blocktime, + }))) +} +/// Equivalent of `TxToJSON` in `zcashd` with null `hashBlock`. +pub(super) fn tx_to_json( + params: &Network, + tx: zcash_primitives::transaction::Transaction, + size: u64, +) -> TransactionDetails { let overwintered = tx.version().has_overwinter(); let (vin, vout) = match tx.transparent_bundle() { @@ -566,7 +585,7 @@ pub(crate) async fn call( .vout .iter() .zip(0..) - .map(TransparentOutput::encode) + .map(|txout| TransparentOutput::encode(params, txout)) .collect(), ), _ => (vec![], vec![]), @@ -605,8 +624,18 @@ pub(crate) async fn call( bundle.authorization().binding_sig, ))), ) - } else { + } else if matches!(tx.version(), TxVersion::Sprout(_) | TxVersion::V3) { + // Omitted if `version < 4`. (None, None, None, None, None) + } else { + // Present but empty, except for `bindingSig`. + ( + Some(value_from_zat_balance(ZatBalance::zero())), + Some(0), + Some(vec![]), + Some(vec![]), + None, + ) }; let orchard = tx @@ -614,10 +643,8 @@ pub(crate) async fn call( .has_orchard() .then(|| Orchard::encode(tx.orchard_bundle())); - Ok(ResultType::Verbose(Box::new(Transaction { - in_active_chain: None, - hex: tx_hex, - txid: txid_str.to_ascii_lowercase(), + TransactionDetails { + txid: tx.txid().to_string(), authdigest: ReverseHex::encode(&tx.auth_commitment().as_bytes().try_into().unwrap()), size, overwintered, @@ -639,12 +666,7 @@ pub(crate) async fn call( join_split_pub_key, #[cfg(zallet_unimplemented)] join_split_sig, - blockhash, - height, - confirmations, - time, - blocktime, - }))) + } } impl TransparentInput { @@ -662,7 +684,7 @@ impl TransparentInput { } } else { // For scriptSig, we pass `true` since there may be signatures - let asm = to_zcashd_asm(&Code(script_bytes.to_vec()).to_asm(true)); + let asm = Code(script_bytes.to_vec()).to_asm(true); Self { coinbase: None, @@ -679,21 +701,13 @@ impl TransparentInput { } impl TransparentOutput { - pub(super) fn encode((tx_out, n): (&TxOut, u16)) -> Self { + pub(super) fn encode(params: &Network, (tx_out, n): (&TxOut, u16)) -> Self { let script_bytes = &tx_out.script_pubkey().0.0; - - // For scriptPubKey, we pass `false` since there are no signatures - let asm = to_zcashd_asm(&Code(script_bytes.to_vec()).to_asm(false)); - - // Detect the script type using zcash_script's solver. - let (kind, req_sigs) = detect_script_type_and_sigs(script_bytes); + let script_code = Code(script_bytes.to_vec()); let script_pub_key = TransparentScriptPubKey { - asm, hex: hex::encode(script_bytes), - req_sigs, - kind, - addresses: vec![], + inner: script_to_json(params, &script_code), }; Self { @@ -791,72 +805,26 @@ impl OrchardAction { } } -/// Converts zcash_script asm output to zcashd-compatible format. -/// -/// The zcash_script crate outputs "OP_0" through "OP_16" and "OP_1NEGATE", -/// but zcashd outputs "0" through "16" and "-1" respectively. -/// -/// Reference: https://github.com/zcash/zcash/blob/v6.11.0/src/script/script.cpp#L19-L40 -/// -/// TODO: Remove this function once zcash_script is upgraded past 0.4.x, -/// as `to_asm()` will natively output zcashd-compatible format. -/// See https://github.com/ZcashFoundation/zcash_script/pull/289 -fn to_zcashd_asm(asm: &str) -> String { - asm.split(' ') - .map(|token| match token { - "OP_1NEGATE" => "-1", - "OP_1" => "1", - "OP_2" => "2", - "OP_3" => "3", - "OP_4" => "4", - "OP_5" => "5", - "OP_6" => "6", - "OP_7" => "7", - "OP_8" => "8", - "OP_9" => "9", - "OP_10" => "10", - "OP_11" => "11", - "OP_12" => "12", - "OP_13" => "13", - "OP_14" => "14", - "OP_15" => "15", - "OP_16" => "16", - other => other, - }) - .collect::>() - .join(" ") -} - -/// Detects the script type and required signatures from a scriptPubKey. -/// -/// Returns a tuple of (type_name, required_signatures). -/// -/// TODO: Replace match arms with `ScriptKind::as_str()` and `ScriptKind::req_sigs()` -/// once zcash_script is upgraded past 0.4.x. -/// See https://github.com/ZcashFoundation/zcash_script/pull/291 -// TODO: zcashd relied on initialization behaviour for the default value -// for null-data or non-standard outputs. Figure out what it is. -// https://github.com/zcash/wallet/issues/236 -fn detect_script_type_and_sigs(script_bytes: &[u8]) -> (&'static str, u8) { - Code(script_bytes.to_vec()) - .to_component() - .ok() - .and_then(|c| c.refine().ok()) - .and_then(|component| zcash_script::solver::standard(&component)) - .map(|script_kind| match script_kind { - zcash_script::solver::ScriptKind::PubKeyHash { .. } => ("pubkeyhash", 1), - zcash_script::solver::ScriptKind::ScriptHash { .. } => ("scripthash", 1), - zcash_script::solver::ScriptKind::MultiSig { required, .. } => ("multisig", required), - zcash_script::solver::ScriptKind::NullData { .. } => ("nulldata", 0), - zcash_script::solver::ScriptKind::PubKey { .. } => ("pubkey", 1), - }) - .unwrap_or(("nonstandard", 0)) -} - #[cfg(test)] mod tests { use super::*; + const MAINNET: &Network = &Network::Consensus(consensus::Network::MainNetwork); + const V1_TX_HEX: &str = "0100000001a15d57094aa7a21a28cb20b59aab8fc7d1149a3bdbcddba9c622e4f5f6a99ece010000006c493046022100f93bb0e7d8db7bd46e40132d1f8242026e045f03a0efe71bbb8e3f475e970d790221009337cd7f1f929f00cc6ff01f03729b069a7c21b59b1736ddfee5db5946c5da8c0121033b9b137ee87d5a812d6f506efdd37f0affa7ffc310711c06c7f3e097c9447c52ffffffff0100e1f505000000001976a9140389035a9225b3839e2bbf32d826a1e222031fd888ac00000000"; + + #[test] + fn decode_v1_transaction() { + let tx = super::super::decode_raw_transaction::call(MAINNET, V1_TX_HEX).unwrap(); + assert_eq!(tx.size, 193); + assert_eq!(tx.version, 1); + assert_eq!(tx.locktime, 0); + assert!(!tx.overwintered); + assert!(tx.versiongroupid.is_none()); + assert!(tx.expiryheight.is_none()); + assert_eq!(tx.vin.len(), 1); + assert_eq!(tx.vout.len(), 1); + } + /// P2PKH scriptSig with sighash decode. /// /// Test vector from zcashd `qa/rpc-tests/decodescript.py:122-125`. @@ -873,88 +841,6 @@ mod tests { ); } - /// P2PKH scriptPubKey asm output. - /// - /// Test vector from zcashd `qa/rpc-tests/decodescript.py:134`. - #[test] - fn scriptpubkey_asm_p2pkh() { - let script = hex::decode("76a914dc863734a218bfe83ef770ee9d41a27f824a6e5688ac").unwrap(); - let asm = Code(script.clone()).to_asm(false); - assert_eq!( - asm, - "OP_DUP OP_HASH160 dc863734a218bfe83ef770ee9d41a27f824a6e56 OP_EQUALVERIFY OP_CHECKSIG" - ); - - // Verify script type detection - let (kind, req_sigs) = detect_script_type_and_sigs(&script); - assert_eq!(kind, "pubkeyhash"); - assert_eq!(req_sigs, 1); - } - - /// P2SH scriptPubKey asm output. - /// - /// Test vector from zcashd `qa/rpc-tests/decodescript.py:135`. - #[test] - fn scriptpubkey_asm_p2sh() { - let script = hex::decode("a9142a5edea39971049a540474c6a99edf0aa4074c5887").unwrap(); - let asm = Code(script.clone()).to_asm(false); - assert_eq!( - asm, - "OP_HASH160 2a5edea39971049a540474c6a99edf0aa4074c58 OP_EQUAL" - ); - - let (kind, req_sigs) = detect_script_type_and_sigs(&script); - assert_eq!(kind, "scripthash"); - assert_eq!(req_sigs, 1); - } - - /// OP_RETURN nulldata scriptPubKey. - /// - /// Test vector from zcashd `qa/rpc-tests/decodescript.py:142`. - #[test] - fn scriptpubkey_asm_nulldata() { - let script = hex::decode("6a09300602010002010001").unwrap(); - let asm = Code(script.clone()).to_asm(false); - assert_eq!(asm, "OP_RETURN 300602010002010001"); - - let (kind, req_sigs) = detect_script_type_and_sigs(&script); - assert_eq!(kind, "nulldata"); - assert_eq!(req_sigs, 0); - } - - /// P2PK scriptPubKey (uncompressed pubkey). - /// - /// Pubkey extracted from zcashd `qa/rpc-tests/decodescript.py:122-125` scriptSig, - /// wrapped in P2PK format (OP_PUSHBYTES_65 OP_CHECKSIG). - #[test] - fn scriptpubkey_asm_p2pk() { - let script = hex::decode( - "4104d3f898e6487787910a690410b7a917ef198905c27fb9d3b0a42da12aceae0544fc7088d239d9a48f2828a15a09e84043001f27cc80d162cb95404e1210161536ac" - ).unwrap(); - let asm = Code(script.clone()).to_asm(false); - assert_eq!( - asm, - "04d3f898e6487787910a690410b7a917ef198905c27fb9d3b0a42da12aceae0544fc7088d239d9a48f2828a15a09e84043001f27cc80d162cb95404e1210161536 OP_CHECKSIG" - ); - - let (kind, req_sigs) = detect_script_type_and_sigs(&script); - assert_eq!(kind, "pubkey"); - assert_eq!(req_sigs, 1); - } - - /// Nonstandard script detection. - /// - /// Tests fallback behavior for scripts that don't match standard patterns. - #[test] - fn scriptpubkey_nonstandard() { - // Just OP_TRUE (0x51) - a valid but nonstandard script - let script = hex::decode("51").unwrap(); - - let (kind, req_sigs) = detect_script_type_and_sigs(&script); - assert_eq!(kind, "nonstandard"); - assert_eq!(req_sigs, 0); - } - /// Test that scriptSig uses sighash decoding (true) and scriptPubKey does not (false). /// /// Verifies correct wiring: `TransparentInput::encode` passes `true` to `to_asm()` @@ -1020,12 +906,12 @@ mod tests { fn asm_numeric_opcodes_match_zcashd() { // From decodescript.py:54 - script '5100' (OP_1 OP_0) should produce '1 0' let script = hex::decode("5100").unwrap(); - let asm = to_zcashd_asm(&Code(script).to_asm(false)); + let asm = Code(script).to_asm(false); assert_eq!(asm, "1 0"); // OP_1NEGATE (0x4f) should produce '-1' let script = hex::decode("4f").unwrap(); - let asm = to_zcashd_asm(&Code(script).to_asm(false)); + let asm = Code(script).to_asm(false); assert_eq!(asm, "-1"); // From decodescript.py:82 - 2-of-3 multisig pattern should use '2' and '3' @@ -1037,7 +923,7 @@ mod tests { push_public_key, push_public_key, push_public_key ); let script = hex::decode(&script_hex).unwrap(); - let asm = to_zcashd_asm(&Code(script).to_asm(false)); + let asm = Code(script).to_asm(false); let expected = format!( "2 {} {} {} 3 OP_CHECKMULTISIG", public_key, public_key, public_key