diff --git a/Cargo.lock b/Cargo.lock index 6b7b6179ace..fad498d7885 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1654,7 +1654,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=40268cc0402a8933ec539f16b2d634c4e25876ad#40268cc0402a8933ec539f16b2d634c4e25876ad" dependencies = [ "cbindgen 0.29.4", "grovedb-bincode", @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=40268cc0402a8933ec539f16b2d634c4e25876ad#40268cc0402a8933ec539f16b2d634c4e25876ad" dependencies = [ "dash-network", ] @@ -1760,7 +1760,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=40268cc0402a8933ec539f16b2d634c4e25876ad#40268cc0402a8933ec539f16b2d634c4e25876ad" dependencies = [ "async-trait", "chrono", @@ -1789,7 +1789,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=40268cc0402a8933ec539f16b2d634c4e25876ad#40268cc0402a8933ec539f16b2d634c4e25876ad" dependencies = [ "anyhow", "base64-compat", @@ -1815,12 +1815,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=40268cc0402a8933ec539f16b2d634c4e25876ad#40268cc0402a8933ec539f16b2d634c4e25876ad" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=40268cc0402a8933ec539f16b2d634c4e25876ad#40268cc0402a8933ec539f16b2d634c4e25876ad" dependencies = [ "dashcore-rpc-json", "hex", @@ -1833,7 +1833,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=40268cc0402a8933ec539f16b2d634c4e25876ad#40268cc0402a8933ec539f16b2d634c4e25876ad" dependencies = [ "dashcore", "grovedb-bincode", @@ -1848,7 +1848,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=40268cc0402a8933ec539f16b2d634c4e25876ad#40268cc0402a8933ec539f16b2d634c4e25876ad" dependencies = [ "dashcore-private", "grovedb-bincode", @@ -2922,7 +2922,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=40268cc0402a8933ec539f16b2d634c4e25876ad#40268cc0402a8933ec539f16b2d634c4e25876ad" [[package]] name = "glob" @@ -4154,7 +4154,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=40268cc0402a8933ec539f16b2d634c4e25876ad#40268cc0402a8933ec539f16b2d634c4e25876ad" dependencies = [ "aes", "async-trait", @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=40268cc0402a8933ec539f16b2d634c4e25876ad#40268cc0402a8933ec539f16b2d634c4e25876ad" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=40268cc0402a8933ec539f16b2d634c4e25876ad#40268cc0402a8933ec539f16b2d634c4e25876ad" dependencies = [ "async-trait", "dashcore", diff --git a/Cargo.toml b/Cargo.toml index 305df7df4b2..bbff32a1a38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,14 +65,14 @@ grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8 grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "40268cc0402a8933ec539f16b2d634c4e25876ad" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "40268cc0402a8933ec539f16b2d634c4e25876ad" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "40268cc0402a8933ec539f16b2d634c4e25876ad" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "40268cc0402a8933ec539f16b2d634c4e25876ad" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "40268cc0402a8933ec539f16b2d634c4e25876ad" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "40268cc0402a8933ec539f16b2d634c4e25876ad" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "40268cc0402a8933ec539f16b2d634c4e25876ad" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "40268cc0402a8933ec539f16b2d634c4e25876ad" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which diff --git a/packages/dashmate/templates/core/dash.conf.dot b/packages/dashmate/templates/core/dash.conf.dot index 7e001947960..957a589bd93 100644 --- a/packages/dashmate/templates/core/dash.conf.dot +++ b/packages/dashmate/templates/core/dash.conf.dot @@ -31,6 +31,9 @@ debuglogfile=/var/log/dash/debug.log # JSON RPC server=1 rpcwallet=main +# Core leaves a masternode's `service`, `platformP2PPort` and `platformHTTPPort` +# out of RPC output unless they are requested here. Platform reads them. +deprecatedrpc=service rpcworkqueue=64 rpcthreads=16 rpcwhitelistdefault=0 diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_end/update_state_cache/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/block_end/update_state_cache/v0/mod.rs index dd5415f44bf..14f613d8302 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/block_end/update_state_cache/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/block_end/update_state_cache/v0/mod.rs @@ -282,7 +282,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array([0u8; 32]), collateral_index: 0, - collateral_address: [0u8; 20], + collateral_address: Some([0u8; 20]), operator_reward: 0.0, state: DMNState { service: "1.2.3.4:1234".parse().expect("socket address"), @@ -290,14 +290,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: [0u8; 20], + owner_address: Some([0u8; 20]), voting_address: [0u8; 20], - payout_address: [0u8; 20], + payout_address: Some([0u8; 20]), + payouts: None, pub_key_operator: vec![0u8; 48], operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; block_platform_state diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_operator_identity/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_operator_identity/v0/mod.rs index 8e9be14377c..bab5291b1d4 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_operator_identity/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_operator_identity/v0/mod.rs @@ -51,7 +51,7 @@ mod tests { pro_tx_hash: ProTxHash::from_byte_array(pro_tx), collateral_hash: Txid::from_byte_array([0u8; 32]), collateral_index: 0, - collateral_address: [0u8; 20], + collateral_address: Some([0u8; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.2.3.4:1234").unwrap(), @@ -59,14 +59,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: [0u8; 20], + owner_address: Some([0u8; 20]), voting_address: [0u8; 20], - payout_address: [0u8; 20], + payout_address: Some([0u8; 20]), + payouts: None, pub_key_operator: vec![1u8; 48], operator_payout_address, platform_node_id, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/mod.rs index f341cee2f1b..df59d420863 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/mod.rs @@ -1,5 +1,6 @@ mod v0; mod v1; +mod v2; use crate::error::execution::ExecutionError; use crate::error::Error; @@ -18,6 +19,11 @@ where /// This function constructs an identity for an owner using details from the masternode. /// It delegates to a version-specific method depending on the platform version. /// + /// Versions 0 and 1 always create an identity and fail on a masternode without the payout + /// address (and, in version 1, the owner address) they read. Version 2 (from protocol + /// version 14) creates none for a masternode without an owner address, such as a shared + /// masternode, and only the OWNER key for one paid to several payouts. + /// /// # Arguments /// /// * masternode - A reference to the masternode list item. @@ -25,12 +31,12 @@ where /// /// # Returns /// - /// * Result - Returns the constructed identity for the owner if successful. - /// Otherwise, returns an error. + /// * Result, Error> - The owner identity, or `None` when the masternode + /// has none (only version 2 returns `None`). Otherwise, returns an error. pub(crate) fn create_owner_identity( masternode: &MasternodeListItem, platform_version: &PlatformVersion, - ) -> Result { + ) -> Result, Error> { match platform_version .drive_abci .methods @@ -38,13 +44,156 @@ where .masternode_updates .create_owner_identity { - 0 => Self::create_owner_identity_v0(masternode, platform_version), - 1 => Self::create_owner_identity_v1(masternode, platform_version), + 0 => Self::create_owner_identity_v0(masternode, platform_version).map(Some), + 1 => Self::create_owner_identity_v1(masternode, platform_version).map(Some), + 2 => Self::create_owner_identity_v2(masternode, platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "create_owner_identity".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } } } + +#[cfg(test)] +mod tests { + use crate::error::execution::ExecutionError; + use crate::error::Error; + use crate::platform_types::platform::Platform; + use crate::rpc::core::MockCoreRPCLike; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::{ProTxHash, PubkeyHash, ScriptBuf, Txid}; + use dpp::dashcore_rpc::dashcore_rpc_json::{ + DMNPayout, DMNState, MasternodeListItem, MasternodeType, + }; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::{KeyID, Purpose}; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + const OWNER_ADDRESS: [u8; 20] = [0x22; 20]; + const PAYOUT_ADDRESS: [u8; 20] = [0x24; 20]; + const SECOND_PAYOUT_ADDRESS: [u8; 20] = [0x25; 20]; + + /// A Regular masternode as Core lists it: a shared one has neither owner, payout nor + /// collateral address; an extended-address one has `payouts` instead of `payout_address`. + fn masternode( + owner_address: Option<[u8; 20]>, + payout_address: Option<[u8; 20]>, + payouts: Option>, + ) -> MasternodeListItem { + MasternodeListItem { + node_type: MasternodeType::Regular, + pro_tx_hash: ProTxHash::from_byte_array([0x21; 32]), + collateral_hash: Txid::from_byte_array([0x27; 32]), + collateral_index: 0, + collateral_address: owner_address.map(|_| [0x28; 20]), + operator_reward: 0.0, + state: DMNState { + service: "1.2.3.4:9999".parse().expect("socket address"), + registered_height: 0, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: 0, + owner_address, + voting_address: [0x23; 20], + payout_address, + payouts, + pub_key_operator: vec![0x26; 48], + operator_payout_address: None, + platform_node_id: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, + }, + } + } + + fn p2pkh_payout(key_hash: [u8; 20], reward: u16) -> DMNPayout { + DMNPayout { + address: key_hash, + script: ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(key_hash)), + reward, + } + } + + /// Generation 0 (protocol versions 1 to 3) builds the owner identity from the payout + /// address (TRANSFER key 0), and generation 1 (4 to 13) from the payout and the owner + /// address (TRANSFER key 0, OWNER key 1). A masternode Core lists without one of them + /// fails with `DashCoreBadResponseError`, so its block fails instead of the masternode + /// getting an owner identity these generations do not define: a shared masternode has + /// neither address, and an extended-address masternode has a payout list instead of a + /// payout address. + #[test] + fn should_fail_for_a_masternode_without_a_payout_or_owner_address_up_to_protocol_version_13() { + let legacy = masternode(Some(OWNER_ADDRESS), Some(PAYOUT_ADDRESS), None); + let shared = masternode(None, None, None); + let single_payout = masternode( + Some(OWNER_ADDRESS), + None, + Some(vec![p2pkh_payout(PAYOUT_ADDRESS, 10000)]), + ); + let several_payouts = masternode( + Some(OWNER_ADDRESS), + None, + Some(vec![ + p2pkh_payout(PAYOUT_ADDRESS, 7000), + p2pkh_payout(SECOND_PAYOUT_ADDRESS, 3000), + ]), + ); + let without_owner_address = masternode(None, Some(PAYOUT_ADDRESS), None); + + for protocol_version in [3, 13] { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected a known protocol version"); + + let legacy_owner_identity_keys: BTreeMap)> = + Platform::::create_owner_identity(&legacy, platform_version) + .expect("expected to create the owner identity") + .expect("a masternode with an owner and a payout address has an owner identity") + .public_keys() + .iter() + .map(|(key_id, key)| (*key_id, (key.purpose(), key.data().to_vec()))) + .collect(); + let mut expected_keys = + BTreeMap::from([(0, (Purpose::TRANSFER, PAYOUT_ADDRESS.to_vec()))]); + if protocol_version == 13 { + expected_keys.insert(1, (Purpose::OWNER, OWNER_ADDRESS.to_vec())); + } + assert_eq!( + legacy_owner_identity_keys, expected_keys, + "protocol version {protocol_version}" + ); + + let mut cases = vec![ + (&shared, "has no payout address"), + (&single_payout, "has no payout address"), + (&several_payouts, "has no payout address"), + ]; + if protocol_version == 13 { + cases.push((&without_owner_address, "has no owner address")); + } + for (masternode, expected_message) in cases { + match Platform::::create_owner_identity( + masternode, + platform_version, + ) { + Err(Error::Execution(ExecutionError::DashCoreBadResponseError(message))) => { + assert!( + message.ends_with(expected_message), + "protocol version {protocol_version}: unexpected message {message}" + ) + } + other => panic!( + "protocol version {protocol_version}: expected DashCoreBadResponseError \ + for {masternode:?}, got {other:?}" + ), + } + } + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v0/mod.rs index 503fe5ac07c..7bdd4753d5c 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v0/mod.rs @@ -1,3 +1,4 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; @@ -15,10 +16,16 @@ where masternode: &MasternodeListItem, platform_version: &PlatformVersion, ) -> Result { + let payout_address = masternode.state.payout_address.ok_or_else(|| { + Error::Execution(ExecutionError::DashCoreBadResponseError(format!( + "masternode {} has no payout address", + masternode.pro_tx_hash + ))) + })?; let owner_identifier = Self::get_owner_identifier(masternode)?; let mut identity = Identity::create_basic_identity(owner_identifier, platform_version)?; identity.add_public_keys([Self::get_owner_identity_withdrawal_key( - masternode.state.payout_address, + payout_address, 0, platform_version, )?]); @@ -52,7 +59,7 @@ mod tests { pro_tx_hash: ProTxHash::from_byte_array(pro_tx), collateral_hash: Txid::from_byte_array([0u8; 32]), collateral_index: 0, - collateral_address: [0u8; 20], + collateral_address: Some([0u8; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.2.3.4:1234").unwrap(), @@ -60,14 +67,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: [0u8; 20], + owner_address: Some([0u8; 20]), voting_address: [0u8; 20], - payout_address, + payout_address: Some(payout_address), + payouts: None, pub_key_operator: vec![0u8; 48], operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v1/mod.rs index ac593abf23d..597f4a287e3 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v1/mod.rs @@ -1,3 +1,4 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; @@ -14,19 +15,23 @@ where masternode: &MasternodeListItem, platform_version: &PlatformVersion, ) -> Result { + let payout_address = masternode.state.payout_address.ok_or_else(|| { + Error::Execution(ExecutionError::DashCoreBadResponseError(format!( + "masternode {} has no payout address", + masternode.pro_tx_hash + ))) + })?; + let owner_address = masternode.state.owner_address.ok_or_else(|| { + Error::Execution(ExecutionError::DashCoreBadResponseError(format!( + "masternode {} has no owner address", + masternode.pro_tx_hash + ))) + })?; let owner_identifier = Self::get_owner_identifier(masternode)?; let mut identity = Identity::create_basic_identity(owner_identifier, platform_version)?; identity.add_public_keys([ - Self::get_owner_identity_withdrawal_key( - masternode.state.payout_address, - 0, - platform_version, - )?, - Self::get_owner_identity_owner_key( - masternode.state.owner_address, - 1, - platform_version, - )?, + Self::get_owner_identity_withdrawal_key(payout_address, 0, platform_version)?, + Self::get_owner_identity_owner_key(owner_address, 1, platform_version)?, ]); Ok(identity) } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v2/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v2/mod.rs new file mode 100644 index 00000000000..aea2c9ec61f --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v2/mod.rs @@ -0,0 +1,278 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::dashcore::hashes::Hash; +use dpp::dashcore::{PubkeyHash, ScriptBuf}; +use dpp::dashcore_rpc::dashcore_rpc_json::{DMNState, MasternodeListItem}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::Identity; +use dpp::version::PlatformVersion; + +impl Platform +where + C: CoreRPCLike, +{ + /// Creates the owner identity of a masternode like version 1, and also decides it for the + /// masternodes version 1 fails on. + /// + /// Version 1 reads a payout address and an owner address and fails with + /// `DashCoreBadResponseError` when either is absent. Dash Core lists such masternodes from + /// v24 on: a shared masternode has no owner, payout or collateral address (its owners are + /// its share holders), and an extended-address masternode has a payout list instead of a + /// payout address. This version gives: + /// + /// - no owner address: no owner identity (`None`), as there is no owner key to hold; the + /// voter and operator identities of the masternode do not depend on it; + /// - an owner address and a single payout address (see `effective_payout_address`): the + /// identity of version 1, TRANSFER key id 0 and OWNER key id 1, byte for byte; + /// - an owner address and no sole supported P2PKH payout: only the OWNER key id 1, as no + /// single address can hold the TRANSFER key; key id 0 stays free. + pub(super) fn create_owner_identity_v2( + masternode: &MasternodeListItem, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(owner_address) = masternode.state.owner_address else { + tracing::debug!( + pro_tx_hash = %masternode.pro_tx_hash, + method = "create_owner_identity_v2", + "no owner identity: the masternode has no owner address" + ); + return Ok(None); + }; + let owner_identifier = Self::get_owner_identifier(masternode)?; + let mut identity = Identity::create_basic_identity(owner_identifier, platform_version)?; + match effective_payout_address(&masternode.state) { + Some(payout_address) => identity.add_public_keys([ + Self::get_owner_identity_withdrawal_key(payout_address, 0, platform_version)?, + Self::get_owner_identity_owner_key(owner_address, 1, platform_version)?, + ]), + None => { + tracing::debug!( + pro_tx_hash = %masternode.pro_tx_hash, + method = "create_owner_identity_v2", + "owner identity with only the OWNER key: no single payout address" + ); + identity.add_public_keys([Self::get_owner_identity_owner_key( + owner_address, + 1, + platform_version, + )?]); + } + } + Ok(Some(identity)) + } +} + +/// The one address the owner reward of a masternode is paid to, which its owner identity's +/// TRANSFER key holds: the payout address, or the only entry of a payout list with one entry +/// with a matching P2PKH script (Core turns a payout address into such a list when a +/// masternode moves to extended addresses). Other scripts and reward splits have no single +/// supported TRANSFER authority. +/// +/// The stored masternode list does not keep payout lists, so this is only meaningful for a +/// masternode as Core reported it, never for one read from the stored list. +fn effective_payout_address(state: &DMNState) -> Option<[u8; 20]> { + match (state.payout_address, state.payouts.as_deref()) { + (Some(payout_address), _) => Some(payout_address), + (None, Some([payout])) + if payout.script + == ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(payout.address)) => + { + Some(payout.address) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crate::platform_types::platform::Platform; + use crate::rpc::core::MockCoreRPCLike; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::{ProTxHash, PubkeyHash, ScriptBuf, ScriptHash, Txid}; + use dpp::dashcore_rpc::dashcore_rpc_json::{ + DMNPayout, DMNState, MasternodeListItem, MasternodeType, + }; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::{Identity, KeyID, Purpose}; + use dpp::prelude::Identifier; + use dpp::serialization::PlatformSerializable; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + const OWNER_ADDRESS: [u8; 20] = [0x22; 20]; + const PAYOUT_ADDRESS: [u8; 20] = [0x24; 20]; + const SECOND_PAYOUT_ADDRESS: [u8; 20] = [0x25; 20]; + + /// A Regular masternode as Core lists it: a shared one has neither owner, payout nor + /// collateral address; an extended-address one has `payouts` instead of `payout_address`. + fn masternode( + owner_address: Option<[u8; 20]>, + payout_address: Option<[u8; 20]>, + payouts: Option>, + ) -> MasternodeListItem { + MasternodeListItem { + node_type: MasternodeType::Regular, + pro_tx_hash: ProTxHash::from_byte_array([0x21; 32]), + collateral_hash: Txid::from_byte_array([0x27; 32]), + collateral_index: 0, + collateral_address: owner_address.map(|_| [0x28; 20]), + operator_reward: 0.0, + state: DMNState { + service: "1.2.3.4:9999".parse().expect("socket address"), + registered_height: 0, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: 0, + owner_address, + voting_address: [0x23; 20], + payout_address, + payouts, + pub_key_operator: vec![0x26; 48], + operator_payout_address: None, + platform_node_id: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, + }, + } + } + + fn p2pkh_payout(key_hash: [u8; 20], reward: u16) -> DMNPayout { + DMNPayout { + address: key_hash, + script: ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(key_hash)), + reward, + } + } + + fn legacy_masternode() -> MasternodeListItem { + masternode(Some(OWNER_ADDRESS), Some(PAYOUT_ADDRESS), None) + } + + #[test] + fn should_not_grant_transfer_authority_to_unsupported_or_inconsistent_payout_scripts() { + for script in [ + ScriptBuf::new_p2sh(&ScriptHash::from_byte_array(PAYOUT_ADDRESS)), + ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(SECOND_PAYOUT_ADDRESS)), + ] { + let identity = create_owner_identity( + &masternode( + Some(OWNER_ADDRESS), + None, + Some(vec![DMNPayout { + address: PAYOUT_ADDRESS, + script, + reward: 10000, + }]), + ), + PlatformVersion::latest(), + ) + .expect("owner identity"); + assert_eq!(identity.public_keys().len(), 1); + assert_eq!( + identity.public_keys().get(&1).expect("owner key").purpose(), + Purpose::OWNER + ); + } + } + + fn create_owner_identity( + masternode: &MasternodeListItem, + platform_version: &PlatformVersion, + ) -> Option { + Platform::::create_owner_identity(masternode, platform_version) + .expect("expected to decide the owner identity") + } + + fn serialized(identity: Option) -> Vec { + identity + .expect("expected an owner identity") + .serialize_to_bytes() + .expect("expected to serialize the owner identity") + } + + /// The owner identity holds the owner's keys, and a shared masternode has no owner key + /// (its owners are its share holders), so it gets no owner identity. + #[test] + fn should_create_no_owner_identity_for_a_shared_masternode() { + let shared = masternode(None, None, None); + + assert_eq!( + create_owner_identity(&shared, PlatformVersion::latest()), + None + ); + } + + /// Core turns a payout address into a payout list with one entry when a masternode moves + /// to extended addresses, so a single payout must give the owner identity a payout address + /// gives, which is the identity version 1 creates, byte for byte. + #[test] + fn should_create_the_same_owner_identity_for_a_single_payout_as_for_a_payout_address() { + let platform_version = PlatformVersion::latest(); + let single_payout = masternode( + Some(OWNER_ADDRESS), + None, + Some(vec![p2pkh_payout(PAYOUT_ADDRESS, 10000)]), + ); + + let legacy_owner_identity = serialized(create_owner_identity( + &legacy_masternode(), + platform_version, + )); + + assert_eq!( + serialized(create_owner_identity(&single_payout, platform_version)), + legacy_owner_identity + ); + assert_eq!( + serialized(create_owner_identity( + &legacy_masternode(), + PlatformVersion::get(13).expect("expected protocol version 13"), + )), + legacy_owner_identity, + "a payout address must give the identity version 1 gives" + ); + } + + /// Several payouts have no single address for the TRANSFER key id 0, so the owner identity + /// gets only the OWNER key id 1 a payout address's owner identity has, and key id 0 stays + /// free. + #[test] + fn should_create_an_owner_identity_with_only_the_owner_key_for_several_payouts() { + let platform_version = PlatformVersion::latest(); + let several_payouts = masternode( + Some(OWNER_ADDRESS), + None, + Some(vec![ + p2pkh_payout(PAYOUT_ADDRESS, 7000), + p2pkh_payout(SECOND_PAYOUT_ADDRESS, 3000), + ]), + ); + + let owner_key = create_owner_identity(&legacy_masternode(), platform_version) + .expect("expected an owner identity for a payout address") + .public_keys() + .get(&1) + .expect("expected the OWNER key id 1") + .clone(); + assert_eq!(owner_key.purpose(), Purpose::OWNER); + assert_eq!(owner_key.data().as_slice(), OWNER_ADDRESS.as_slice()); + + let several_payouts_owner_identity = + create_owner_identity(&several_payouts, platform_version) + .expect("expected an owner identity for several payouts"); + assert_eq!( + several_payouts_owner_identity.id(), + Identifier::from([0x21; 32]), + "the owner identity id is the ProTx hash" + ); + assert_eq!( + several_payouts_owner_identity.public_keys(), + &BTreeMap::from([(1 as KeyID, owner_key)]) + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_voter_identity/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_voter_identity/v0/mod.rs index b5a1b148cbd..0d667868338 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_voter_identity/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_voter_identity/v0/mod.rs @@ -60,7 +60,7 @@ mod tests { pro_tx_hash: ProTxHash::from_byte_array(pro_tx), collateral_hash: Txid::from_byte_array([0u8; 32]), collateral_index: 0, - collateral_address: [0u8; 20], + collateral_address: Some([0u8; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.2.3.4:1234").unwrap(), @@ -68,14 +68,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: [0u8; 20], + owner_address: Some([0u8; 20]), voting_address, - payout_address: [0u8; 20], + payout_address: Some([0u8; 20]), + payouts: None, pub_key_operator: vec![0u8; 48], operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/get_operator_identifier/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/get_operator_identifier/v0/mod.rs index 8cb6c517ca9..960bc25b933 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/get_operator_identifier/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/get_operator_identifier/v0/mod.rs @@ -37,7 +37,7 @@ mod tests { pro_tx_hash: ProTxHash::from_byte_array(pro_tx), collateral_hash: Txid::from_byte_array([0u8; 32]), collateral_index: 0, - collateral_address: [0u8; 20], + collateral_address: Some([0u8; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.2.3.4:1234").unwrap(), @@ -45,14 +45,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: [0u8; 20], + owner_address: Some([0u8; 20]), voting_address: [0u8; 20], - payout_address: [0u8; 20], + payout_address: Some([0u8; 20]), + payouts: None, pub_key_operator, operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/mod.rs index 169f1f4cd06..ecaf08fc676 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/mod.rs @@ -11,12 +11,17 @@ use drive::grovedb::Transaction; use std::collections::BTreeMap; mod v0; +mod v1; impl Platform where C: CoreRPCLike, { /// Update of the masternode identities + /// + /// Version 0 adds an owner identity for every added masternode. Version 1 (from protocol + /// version 14) adds none for a masternode `create_owner_identity` gives none, such as a + /// shared masternode. pub(in crate::execution) fn update_masternode_identities( &self, masternode_diff: MasternodeListDiff, @@ -41,11 +46,227 @@ where transaction, platform_version, ), + 1 => self.update_masternode_identities_v1( + masternode_diff, + removed_masternodes, + block_info, + platform_state, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "update_masternode_identities".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } } } + +#[cfg(test)] +mod tests { + use crate::config::PlatformConfig; + use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::block::block_info::BlockInfo; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::{Network, ProTxHash, PubkeyHash, ScriptBuf, Txid}; + use dpp::dashcore_rpc::dashcore_rpc_json::{ + DMNPayout, DMNState, DMNStateDiff, MasternodeListDiff, MasternodeListItem, MasternodeType, + }; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::{KeyID, Purpose}; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + /// A payout change of an extended-address masternode reaches Drive only as `payouts`, + /// never as `payout_address`. The historical updater leaves these keys unchanged. + /// Protocol version 13 is pinned to preserve replay of its payout-list policy. + #[test] + fn should_not_change_owner_identity_keys_on_a_payout_list_change() { + assert_owner_identity_keys_unchanged_by_payout_list_changes( + PlatformVersion::get(13).expect("expected protocol version 13"), + ); + } + + fn assert_owner_identity_keys_unchanged_by_payout_list_changes( + platform_version: &PlatformVersion, + ) { + let protocol_version = platform_version.protocol_version; + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let pro_tx_hash = ProTxHash::from_byte_array([0x51; 32]); + let owner_address = [0x52; 20]; + let payout_address = [0x53; 20]; + let masternode = MasternodeListItem { + node_type: MasternodeType::Regular, + pro_tx_hash, + collateral_hash: Txid::from_byte_array([0x54; 32]), + collateral_index: 0, + collateral_address: Some([0x55; 20]), + operator_reward: 0.0, + state: DMNState { + service: "1.2.3.4:9999".parse().expect("socket address"), + registered_height: 0, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: 0, + owner_address: Some(owner_address), + voting_address: [0x56; 20], + payout_address: Some(payout_address), + payouts: None, + pub_key_operator: vec![0x57; 48], + operator_payout_address: None, + platform_node_id: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, + }, + }; + + let transaction = platform.drive.grove.start_transaction(); + platform + .update_masternode_identities( + MasternodeListDiff { + base_height: 0, + block_height: 1, + added_mns: vec![masternode.clone()], + removed_mns: vec![], + updated_mns: vec![], + }, + &BTreeMap::new(), + &BlockInfo::default(), + None, + &transaction, + platform_version, + ) + .expect("expected to create the masternode identities"); + + let mut platform_state = PlatformState::default_with_protocol_versions( + protocol_version, + protocol_version, + &PlatformConfig::default_for_network(Network::Testnet), + ) + .expect("platform state"); + platform_state.insert_masternode(masternode); + + let apply_update = |state_diff: DMNStateDiff| { + platform + .update_masternode_identities( + MasternodeListDiff { + base_height: 1, + block_height: 2, + added_mns: vec![], + removed_mns: vec![], + updated_mns: vec![(pro_tx_hash, state_diff)], + }, + &BTreeMap::new(), + &BlockInfo::default(), + Some(&platform_state), + &transaction, + platform_version, + ) + .expect("expected to process the masternode update"); + }; + let owner_identity_keys = || -> BTreeMap, bool)> { + platform + .drive + .fetch_full_identity( + pro_tx_hash.to_byte_array(), + Some(&transaction), + platform_version, + ) + .expect("expected to fetch the owner identity") + .expect("expected the owner identity to exist") + .public_keys() + .iter() + .map(|(key_id, key)| { + ( + *key_id, + (key.purpose(), key.data().to_vec(), key.is_disabled()), + ) + }) + .collect() + }; + let keys_before = BTreeMap::from([ + (0, (Purpose::TRANSFER, payout_address.to_vec(), false)), + (1, (Purpose::OWNER, owner_address.to_vec(), false)), + ]); + assert_eq!( + owner_identity_keys(), + keys_before, + "protocol version {protocol_version}" + ); + + let payout = |address: [u8; 20], reward: u16| DMNPayout { + address, + script: ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(address)), + reward, + }; + for payouts in [ + // The payout address moved into a list, as Core does when a masternode moves to + // extended addresses. + vec![payout(payout_address, 10000)], + // Another single payout address. + vec![payout([0x58; 20], 10000)], + // Several payouts. + vec![payout([0x58; 20], 2500), payout([0x59; 20], 7500)], + ] { + apply_update(DMNStateDiff { + payouts: Some(payouts.clone()), + ..empty_state_diff() + }); + assert_eq!( + owner_identity_keys(), + keys_before, + "protocol version {protocol_version}, payouts {payouts:?}" + ); + } + + // The same processing does rotate the TRANSFER key on a payout address change. + apply_update(DMNStateDiff { + payout_address: Some([0x5a; 20]), + ..empty_state_diff() + }); + assert_eq!( + owner_identity_keys(), + BTreeMap::from([ + (0, (Purpose::TRANSFER, payout_address.to_vec(), true)), + (1, (Purpose::OWNER, owner_address.to_vec(), false)), + (2, (Purpose::TRANSFER, vec![0x5a; 20], false)), + ]), + "protocol version {protocol_version}" + ); + } + + fn empty_state_diff() -> DMNStateDiff { + DMNStateDiff { + service: None, + registered_height: None, + last_paid_height: None, + consecutive_payments: None, + pose_penalty: None, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: None, + owner_address: None, + voting_address: None, + payout_address: None, + payouts: None, + pub_key_operator: None, + operator_payout_address: None, + platform_node_id: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v0/mod.rs index 6cdff83e2f1..a4182e82e64 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v0/mod.rs @@ -1,3 +1,4 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::platform::Platform; use crate::platform_types::platform_state::PlatformState; @@ -54,7 +55,13 @@ where let mut drive_operations = vec![]; for masternode in added_mns { - let owner_identity = Self::create_owner_identity(&masternode, platform_version)?; + // Protocol versions 1 to 13, the ones that select this generation, select + // create_owner_identity 0 or 1, which create an identity or fail, and never return + // `None`. + let owner_identity = Self::create_owner_identity(&masternode, platform_version)? + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "create_owner_identity 0 and 1 always create an owner identity", + )))?; tracing::trace!( identity = ?owner_identity, diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v1/mod.rs new file mode 100644 index 00000000000..58380396215 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v1/mod.rs @@ -0,0 +1,540 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::rpc::core::CoreRPCLike; + +use dpp::block::block_info::BlockInfo; +use dpp::dashcore::{ProTxHash, PubkeyHash, ScriptBuf}; +use dpp::dashcore_rpc::dashcore_rpc_json::{DMNPayout, MasternodeListDiff}; +use dpp::dashcore_rpc::json::MasternodeListItem; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{KeyType, Purpose}; + +use dpp::version::PlatformVersion; + +use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyIDIdentityPublicKeyPairBTreeMap, KeyRequestType, +}; +use drive::util::batch::DriveOperation; +use drive::util::batch::DriveOperation::IdentityOperation; +use drive::util::batch::IdentityOperationType::{ + AddNewIdentity, AddNewKeysToIdentity, DisableIdentityKeys, ReEnableIdentityKeys, +}; + +use crate::platform_types::platform_state::PlatformStateV0Methods; + +use dpp::dashcore::hashes::Hash; +use drive::grovedb::Transaction; +use std::collections::BTreeMap; +use tracing::Level; + +impl Platform +where + C: CoreRPCLike, +{ + /// Updates the masternode identities like version 0, except that an added masternode may + /// have no owner identity. + /// + /// Version 0 is paired with `create_owner_identity` 0 and 1, which create an owner identity + /// for every masternode or fail. `create_owner_identity` 2 creates none for a masternode + /// without an owner address, such as a shared masternode, and this version then adds only + /// its voter and operator identities. Legacy payout-address changes and removals follow + /// version 0. Payout-list changes reconcile TRANSFER keys: only a sole supported P2PKH + /// recipient retains withdrawal authority. + pub(super) fn update_masternode_identities_v1( + &self, + masternode_diff: MasternodeListDiff, + removed_masternodes: &BTreeMap, + block_info: &BlockInfo, + platform_state: Option<&PlatformState>, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let MasternodeListDiff { + mut added_mns, + mut updated_mns, + .. + } = masternode_diff; + + let span = tracing::span!(Level::TRACE, "update_masternode_identities"); + let _enter = span.enter(); + + // We should don't trust the order of added mns or updated mns + + // Sort added_mns based on pro_tx_hash + added_mns.sort_by_key(|mn| mn.pro_tx_hash); + + // Sort updated_mns based on pro_tx_hash (the first element of the tuple) + updated_mns.sort_by_key(|mn| mn.0); + + if updated_mns + .iter() + .any(|(_, diff)| diff.payout_address.is_some() && diff.payouts.is_some()) + { + return Err(ExecutionError::DashCoreBadResponseError( + "masternode diff contains both a payout address and a payout list".to_string(), + ) + .into()); + } + + let mut drive_operations = vec![]; + + for masternode in added_mns { + let owner_identity = Self::create_owner_identity(&masternode, platform_version)?; + + tracing::trace!( + identity = ?owner_identity, + method = "update_masternode_identities_v1", + "create owner identity" + ); + + let voter_identity = Self::create_voter_identity_from_masternode_list_item( + &masternode, + platform_version, + )?; + + tracing::trace!( + identity = ?voter_identity, + method = "update_masternode_identities_v1", + "create voter identity" + ); + + let operator_identity = Self::create_operator_identity(&masternode, platform_version)?; + + tracing::trace!( + identity = ?operator_identity, + method = "update_masternode_identities_v1", + "create operator identity" + ); + + if let Some(owner_identity) = owner_identity { + drive_operations.push(IdentityOperation(AddNewIdentity { + identity: owner_identity, + is_masternode_identity: true, + })); + } + + drive_operations.push(IdentityOperation(AddNewIdentity { + identity: voter_identity, + is_masternode_identity: true, + })); + + drive_operations.push(IdentityOperation(AddNewIdentity { + identity: operator_identity, + is_masternode_identity: true, + })); + } + + if let Some(platform_state) = platform_state { + // On initialization there is no platform state, but we also don't need to update + // masternode identities. + for update in updated_mns.iter() { + let (pro_tx_hash, state_diff) = update; + if let Some(new_withdrawal_address) = state_diff.payout_address { + let owner_identifier: [u8; 32] = pro_tx_hash.to_byte_array(); + self.update_owner_withdrawal_address( + owner_identifier, + new_withdrawal_address, + block_info, + transaction, + &mut drive_operations, + platform_version, + )?; + } else if let Some(payouts) = state_diff.payouts.as_deref() { + self.reconcile_owner_payout_keys( + pro_tx_hash.to_byte_array(), + payouts, + transaction, + &mut drive_operations, + platform_version, + )?; + } + + self.update_voter_identity( + update, + block_info, + platform_state, + transaction, + &mut drive_operations, + platform_version, + )?; + if state_diff.platform_node_id.is_some() + || state_diff.operator_payout_address.is_some() + || state_diff.pub_key_operator.is_some() + { + self.update_operator_identity( + pro_tx_hash, + state_diff.pub_key_operator.as_ref(), + state_diff.operator_payout_address, + state_diff.platform_node_id, + platform_state, + transaction, + &mut drive_operations, + platform_version, + )?; + } + } + + for masternode in removed_masternodes.values() { + self.disable_identity_keys( + masternode, + block_info, + transaction, + &mut drive_operations, + platform_version, + )?; + } + } + + let previous_fee_versions = platform_state.map(|state| state.previous_fee_versions()); + self.drive.apply_drive_operations( + drive_operations, + true, + block_info, + Some(transaction), + platform_version, + previous_fee_versions, + )?; + + let height = block_info.height; + tracing::debug!(height, "Updated masternode identities"); + + Ok(()) + } + + /// A sole P2PKH recipient can spend the owner's balance. Other payout shapes must + /// retire that authority without changing the OWNER key or the accumulated credits. + fn reconcile_owner_payout_keys( + &self, + owner_identifier: [u8; 32], + payouts: &[DMNPayout], + transaction: &Transaction, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let authorized_address = match payouts { + [payout] + if payout.script + == ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(payout.address)) => + { + Some(payout.address) + } + _ => None, + }; + let keys = self + .drive + .fetch_identity_keys::( + IdentityKeysRequest { + identity_id: owner_identifier, + request_type: KeyRequestType::AllKeys, + limit: None, + offset: None, + }, + Some(transaction), + platform_version, + )?; + let Some((&last_key_id, _)) = keys.last_key_value() else { + return Err(ExecutionError::DriveMissingData( + "expected masternode owner identity to be in state".to_string(), + ) + .into()); + }; + // Prefer an enabled matching key, then a disabled one; numeric IDs break ties. + let reusable_key = authorized_address.and_then(|address| { + keys.iter() + .filter(|(_, key)| { + key.purpose() == Purpose::TRANSFER + && key.key_type() == KeyType::ECDSA_HASH160 + && key.data().as_slice() == address + }) + .min_by_key(|(key_id, key)| (key.is_disabled(), **key_id)) + }); + let new_key = match (authorized_address, reusable_key) { + (Some(address), None) => { + let key_id = last_key_id.checked_add(1).ok_or(ExecutionError::Overflow( + "masternode owner identity key id exhausted", + ))?; + Some(Self::get_owner_identity_withdrawal_key( + address, + key_id, + platform_version, + )?) + } + _ => None, + }; + let keys_ids = keys + .iter() + .filter_map(|(key_id, key)| { + (key.purpose() == Purpose::TRANSFER + && !key.is_disabled() + && reusable_key.map(|(reused_id, _)| reused_id) != Some(key_id)) + .then_some(*key_id) + }) + .collect::>(); + if !keys_ids.is_empty() { + drive_operations.push(IdentityOperation(DisableIdentityKeys { + identity_id: owner_identifier, + keys_ids, + })); + } + if let Some((key_id, key)) = reusable_key { + if key.is_disabled() { + drive_operations.push(IdentityOperation(ReEnableIdentityKeys { + identity_id: owner_identifier, + keys_ids: vec![*key_id], + })); + } + } + if let Some(key) = new_key { + drive_operations.push(IdentityOperation(AddNewKeysToIdentity { + identity_id: owner_identifier, + unique_keys_to_add: vec![], + non_unique_keys_to_add: vec![key], + })); + } + Ok(()) + } +} + +#[cfg(test)] +mod payout_tests; + +#[cfg(test)] +mod tests { + use crate::config::PlatformConfig; + use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::block::block_info::BlockInfo; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::{Network, ProTxHash, Txid}; + use dpp::dashcore_rpc::dashcore_rpc_json::{ + DMNState, DMNStateDiff, MasternodeListDiff, MasternodeListItem, MasternodeType, + }; + use dpp::identifier::MasternodeIdentifiers; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::Identity; + use dpp::prelude::Identifier; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + const PRO_TX_HASH: [u8; 32] = [0x61; 32]; + const VOTING_ADDRESS: [u8; 20] = [0x62; 20]; + const PUB_KEY_OPERATOR: [u8; 48] = [0x63; 48]; + + /// A shared masternode as Core lists it: no owner, payout or collateral address. + fn shared_masternode() -> MasternodeListItem { + MasternodeListItem { + node_type: MasternodeType::Regular, + pro_tx_hash: ProTxHash::from_byte_array(PRO_TX_HASH), + collateral_hash: Txid::from_byte_array([0x64; 32]), + collateral_index: 0, + collateral_address: None, + operator_reward: 0.0, + state: DMNState { + service: "1.2.3.4:9999".parse().expect("socket address"), + registered_height: 0, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: 0, + owner_address: None, + voting_address: VOTING_ADDRESS, + payout_address: None, + payouts: None, + pub_key_operator: PUB_KEY_OPERATOR.to_vec(), + operator_payout_address: None, + platform_node_id: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, + }, + } + } + + fn masternode_list_diff( + added_mns: Vec, + removed_mns: Vec, + updated_mns: Vec<(ProTxHash, DMNStateDiff)>, + ) -> MasternodeListDiff { + MasternodeListDiff { + base_height: 0, + block_height: 1, + added_mns, + removed_mns, + updated_mns, + } + } + + /// A shared masternode has no owner address, so `create_owner_identity` gives it no owner + /// identity. Its voter and operator identities do not depend on the owner and are added as + /// for any masternode. + #[test] + fn should_add_the_voter_and_operator_identities_but_no_owner_identity_for_a_shared_masternode() + { + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let transaction = platform.drive.grove.start_transaction(); + platform + .update_masternode_identities( + masternode_list_diff(vec![shared_masternode()], vec![], vec![]), + &BTreeMap::new(), + &BlockInfo::default(), + None, + &transaction, + platform_version, + ) + .expect("expected to add the masternode identities"); + + let identity_exists = |identity_id: Identifier| { + platform + .drive + .fetch_full_identity( + identity_id.to_buffer(), + Some(&transaction), + platform_version, + ) + .expect("expected to fetch an identity") + .is_some() + }; + assert!( + !identity_exists(PRO_TX_HASH.into()), + "a shared masternode has no owner identity" + ); + assert!( + identity_exists(Identifier::create_voter_identifier( + &PRO_TX_HASH, + &VOTING_ADDRESS + )), + "the voter identity must be added" + ); + assert!( + identity_exists(Identifier::create_operator_identifier( + &PRO_TX_HASH, + &PUB_KEY_OPERATOR + )), + "the operator identity must be added" + ); + } + + /// Only a `payout_address` change reads the owner identity, and Core prints none for a + /// shared masternode. A change of its voting address and operator key, and its removal, + /// update its voter and operator identities as for any masternode and do not fail for want + /// of an owner identity. + #[test] + fn should_update_and_remove_a_shared_masternode_without_an_owner_identity() { + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let transaction = platform.drive.grove.start_transaction(); + let masternode = shared_masternode(); + let pro_tx_hash = masternode.pro_tx_hash; + platform + .update_masternode_identities( + masternode_list_diff(vec![masternode.clone()], vec![], vec![]), + &BTreeMap::new(), + &BlockInfo::default(), + None, + &transaction, + platform_version, + ) + .expect("expected to add the masternode identities"); + + let mut platform_state = PlatformState::default_with_protocol_versions( + platform_version.protocol_version, + platform_version.protocol_version, + &PlatformConfig::default_for_network(Network::Testnet), + ) + .expect("platform state"); + platform_state.insert_masternode(masternode.clone()); + + let new_voting_address = [0x65; 20]; + let new_pub_key_operator = [0x66; 48]; + let state_diff = DMNStateDiff { + service: None, + registered_height: None, + last_paid_height: None, + consecutive_payments: None, + pose_penalty: None, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: None, + owner_address: None, + voting_address: Some(new_voting_address), + payout_address: None, + payouts: None, + pub_key_operator: Some(new_pub_key_operator.to_vec()), + operator_payout_address: None, + platform_node_id: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, + }; + platform + .update_masternode_identities( + masternode_list_diff(vec![], vec![], vec![(pro_tx_hash, state_diff.clone())]), + &BTreeMap::new(), + &BlockInfo::default(), + Some(&platform_state), + &transaction, + platform_version, + ) + .expect("expected to update the identities of the shared masternode"); + + let mut updated_masternode = masternode; + updated_masternode.state.apply_diff(state_diff); + platform_state.insert_masternode(updated_masternode.clone()); + platform + .update_masternode_identities( + masternode_list_diff(vec![], vec![pro_tx_hash], vec![]), + &BTreeMap::from([(pro_tx_hash, updated_masternode)]), + &BlockInfo::default(), + Some(&platform_state), + &transaction, + platform_version, + ) + .expect("expected to remove the shared masternode"); + + let fetch_identity = |identity_id: Identifier| -> Option { + platform + .drive + .fetch_full_identity( + identity_id.to_buffer(), + Some(&transaction), + platform_version, + ) + .expect("expected to fetch an identity") + }; + let has_an_enabled_key = |identity_id: Identifier| { + fetch_identity(identity_id) + .expect("expected the identity to exist") + .public_keys() + .values() + .any(|key| !key.is_disabled()) + }; + assert_eq!(fetch_identity(PRO_TX_HASH.into()), None); + assert!( + !has_an_enabled_key(Identifier::create_voter_identifier( + &PRO_TX_HASH, + &new_voting_address + )), + "the removed masternode's voter identity keys must be disabled" + ); + assert!( + !has_an_enabled_key(Identifier::create_operator_identifier( + &PRO_TX_HASH, + &new_pub_key_operator + )), + "the removed masternode's operator identity keys must be disabled" + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v1/payout_tests.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v1/payout_tests.rs new file mode 100644 index 00000000000..846bcdb94ab --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v1/payout_tests.rs @@ -0,0 +1,770 @@ +use crate::config::{PlatformConfig, PlatformTestConfig}; +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::fast_forward_to_block::fast_forward_to_block; +use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use assert_matches::assert_matches; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::signature::SignatureError; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::ConsensusError; +use dpp::dash_to_credits; +use dpp::dashcore::hashes::Hash; +use dpp::dashcore::{ProTxHash, PubkeyHash, ScriptBuf, ScriptHash, Txid}; +use dpp::dashcore_rpc::dashcore_rpc_json::{ + DMNPayout, DMNState, DMNStateDiff, MasternodeListDiff, MasternodeListItem, MasternodeType, +}; +use dpp::document::DocumentV0Getters; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::core_script::CoreScript; +use dpp::identity::hash::IdentityPublicKeyHashMethodsV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose}; +use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; +use dpp::serialization::PlatformSerializable; +use dpp::state_transition::identity_credit_withdrawal_transition::methods::{ + IdentityCreditWithdrawalTransitionMethodsV0, PreferredKeyPurposeForSigningWithdrawal, +}; +use dpp::state_transition::identity_credit_withdrawal_transition::IdentityCreditWithdrawalTransition; +use dpp::version::PlatformVersion; +use dpp::withdrawal::Pooling; +use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyIDIdentityPublicKeyPairBTreeMap, KeyRequestType, +}; +use drive::grovedb::Transaction; +use drive::util::batch::{DriveOperation, IdentityOperationType}; +use rand::prelude::StdRng; +use rand::SeedableRng; +use simple_signer::signer::SimpleSigner; +use std::collections::BTreeMap; +use std::sync::Arc; + +const PRO_TX_HASH: [u8; 32] = [0x71; 32]; +const UPDATE_TIME: u64 = 1_200_002_000; + +fn payout(address: [u8; 20], reward: u16) -> DMNPayout { + DMNPayout { + address, + script: ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(address)), + reward, + } +} + +fn empty_diff() -> DMNStateDiff { + serde_json::from_str("{}").expect("empty Core state diff") +} + +struct PayoutFixture { + platform: TempPlatform, + signer: SimpleSigner, + original_identity: Identity, + original_key: IdentityPublicKey, +} + +impl PayoutFixture { + fn new() -> Self { + Self::with_split_payout(false) + } + + fn with_split_payout(initial_split: bool) -> Self { + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_config(PlatformConfig { + testing_configs: PlatformTestConfig { + disable_instant_lock_signature_verification: true, + ..Default::default() + }, + ..Default::default() + }) + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + fast_forward_to_block(&platform, 1_200_000_000, 900, 42, 1, false); + + let mut rng = StdRng::seed_from_u64(713); + let (original_key, original_secret) = + IdentityPublicKey::random_masternode_transfer_key_with_rng( + 0, + &mut rng, + platform_version, + ) + .expect("payout key pair"); + let (owner_key, owner_secret) = + IdentityPublicKey::random_masternode_owner_key_with_rng(1, &mut rng, platform_version) + .expect("owner key pair"); + let mut signer = SimpleSigner::default(); + signer.add_identity_public_key(original_key.clone(), original_secret); + signer.add_identity_public_key(owner_key.clone(), owner_secret); + let masternode = MasternodeListItem { + node_type: MasternodeType::Regular, + pro_tx_hash: ProTxHash::from_byte_array(PRO_TX_HASH), + collateral_hash: Txid::from_byte_array([0x72; 32]), + collateral_index: 0, + collateral_address: Some([0x73; 20]), + operator_reward: 0.0, + state: DMNState { + service: "1.2.3.4:9999".parse().expect("service address"), + registered_height: 0, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: 0, + owner_address: Some(owner_key.public_key_hash().expect("owner hash")), + voting_address: [0x74; 20], + payout_address: None, + payouts: Some(if initial_split { + vec![ + payout(original_key.public_key_hash().expect("payout hash"), 5000), + payout([0x76; 20], 5000), + ] + } else { + vec![payout( + original_key.public_key_hash().expect("payout hash"), + 10000, + )] + }), + pub_key_operator: vec![0x75; 48], + operator_payout_address: None, + platform_node_id: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, + }, + }; + let transaction = platform.drive.grove.start_transaction(); + platform + .update_masternode_identities( + MasternodeListDiff { + base_height: 0, + block_height: 1, + added_mns: vec![masternode.clone()], + removed_mns: vec![], + updated_mns: vec![], + }, + &BTreeMap::new(), + &BlockInfo::default_with_time(1_200_001_000), + None, + &transaction, + platform_version, + ) + .expect("register sole-payout masternode"); + let credits = dash_to_credits!(0.5); + platform + .drive + .add_to_system_credits(credits, Some(&transaction), platform_version) + .expect("system credits"); + platform + .drive + .add_to_identity_balance( + PRO_TX_HASH, + credits, + &BlockInfo::default(), + true, + Some(&transaction), + platform_version, + ) + .expect("fund payout identity"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("commit registration"); + let mut state = platform.state.load().as_ref().clone(); + state.insert_masternode(masternode); + platform.state.store(Arc::new(state)); + let original_identity = platform + .drive + .fetch_full_identity(PRO_TX_HASH, None, platform_version) + .expect("fetch registered identity") + .expect("registered owner identity"); + Self { + platform, + signer, + original_identity, + original_key, + } + } + + fn identity(&self, transaction: &Transaction) -> Identity { + self.platform + .drive + .fetch_full_identity(PRO_TX_HASH, Some(transaction), PlatformVersion::latest()) + .expect("fetch owner identity") + .expect("owner identity") + } + + fn update(&self, payouts: Vec, transaction: &Transaction) { + self.apply_diff( + DMNStateDiff { + payouts: Some(payouts), + ..empty_diff() + }, + transaction, + ) + .expect("apply payout list update"); + } + + fn apply_diff(&self, diff: DMNStateDiff, transaction: &Transaction) -> Result<(), Error> { + self.platform.update_masternode_identities( + MasternodeListDiff { + base_height: 1, + block_height: 2, + added_mns: vec![], + removed_mns: vec![], + updated_mns: vec![(ProTxHash::from_byte_array(PRO_TX_HASH), diff)], + }, + &BTreeMap::new(), + &BlockInfo::default_with_time(UPDATE_TIME), + Some(&self.platform.state.load()), + transaction, + PlatformVersion::latest(), + ) + } + + fn add_later_credits(&self, transaction: &Transaction) { + let credits = dash_to_credits!(0.2); + let version = PlatformVersion::latest(); + self.platform + .drive + .add_to_system_credits(credits, Some(transaction), version) + .expect("later system credits"); + self.platform + .drive + .add_to_identity_balance( + PRO_TX_HASH, + credits, + &BlockInfo::default_with_time(UPDATE_TIME), + true, + Some(transaction), + version, + ) + .expect("later payout credits"); + } + + async fn withdraw( + &self, + identity: &Identity, + key: &IdentityPublicKey, + nonce: u64, + explicit_destination: bool, + transaction: &Transaction<'_>, + ) -> Vec { + let transition = IdentityCreditWithdrawalTransition::try_from_identity( + identity, + explicit_destination.then(|| CoreScript::random_p2pkh(&mut StdRng::seed_from_u64(714))), + dash_to_credits!(0.1), + Pooling::Never, + 1, + 0, + self.signer.clone(), + Some(key), + PreferredKeyPurposeForSigningWithdrawal::Any, + nonce, + PlatformVersion::latest(), + None, + ) + .await + .expect("sign withdrawal with the supplied enabled-key snapshot"); + self.platform + .process_raw_state_transitions( + &vec![transition + .serialize_to_bytes() + .expect("serialize withdrawal")], + &self.platform.state.load(), + &BlockInfo::default_with_time(UPDATE_TIME + 1), + transaction, + PlatformVersion::latest(), + false, + None, + ) + .expect("process signed withdrawal") + .into_execution_results() + } +} + +#[tokio::test] +async fn should_create_first_transfer_key_and_withdraw_from_an_initially_owner_only_identity() { + let mut fixture = PayoutFixture::with_split_payout(true); + let transaction = fixture.platform.drive.grove.start_transaction(); + let initial = fixture.identity(&transaction); + assert_eq!( + initial.public_keys().keys().copied().collect::>(), + vec![1] + ); + assert!(recent_keys(&fixture, &transaction).is_empty()); + fixture.update( + vec![payout( + fixture + .original_key + .public_key_hash() + .expect("sole payout hash"), + 10000, + )], + &transaction, + ); + let identity = fixture.identity(&transaction); + assert_eq!( + identity.public_keys().keys().copied().collect::>(), + vec![1, 2] + ); + assert_eq!( + identity.public_keys().get(&1), + initial.public_keys().get(&1) + ); + assert_eq!(identity.balance(), initial.balance()); + let transfer = identity + .public_keys() + .get(&2) + .expect("first transfer key") + .clone(); + assert_eq!(transfer.purpose(), Purpose::TRANSFER); + assert!(!transfer.is_disabled()); + assert_eq!( + recent_keys(&fixture, &transaction), + BTreeMap::from([(2, transfer.clone())]) + ); + let secret = *fixture + .signer + .private_keys + .get(&fixture.original_key) + .expect("payout secret"); + fixture + .signer + .add_identity_public_key(transfer.clone(), secret); + assert_matches!( + fixture + .withdraw(&identity, &transfer, 1, true, &transaction) + .await + .as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); +} + +#[tokio::test] +async fn should_allow_replacement_payout_and_preserve_owner_default_withdrawals() { + let mut fixture = PayoutFixture::new(); + let transaction = fixture.platform.drive.grove.start_transaction(); + let (key, secret) = IdentityPublicKey::random_masternode_transfer_key_with_rng( + 2, + &mut StdRng::seed_from_u64(715), + PlatformVersion::latest(), + ) + .expect("replacement key pair"); + let address = key.public_key_hash().expect("replacement hash"); + fixture.update(vec![payout(address, 10000)], &transaction); + fixture.add_later_credits(&transaction); + let identity = fixture.identity(&transaction); + let replacement = identity + .public_keys() + .get(&2) + .expect("replacement transfer key"); + fixture + .signer + .add_identity_public_key(replacement.clone(), secret); + assert_matches!( + fixture + .withdraw(&identity, replacement, 1, true, &transaction) + .await + .as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + let owner = identity.public_keys().get(&1).expect("owner key"); + assert_matches!( + fixture + .withdraw(&identity, owner, 2, false, &transaction) + .await + .as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + let documents = fixture + .platform + .drive + .fetch_oldest_withdrawal_documents(Some(&transaction), PlatformVersion::latest()) + .expect("withdrawal documents"); + assert!(documents.values().flatten().any(|document| document + .properties() + .get_bytes("outputScript") + .expect("output script") + == CoreScript::new_p2pkh(address).to_bytes())); + let before_repeat = fixture.identity(&transaction); + fixture.update(vec![payout(address, 10000)], &transaction); + assert_eq!(fixture.identity(&transaction), before_repeat); +} + +#[tokio::test] +async fn should_restore_single_payout_authority_without_duplicate_keys() { + let fixture = PayoutFixture::new(); + let transaction = fixture.platform.drive.grove.start_transaction(); + let original_address = fixture + .original_key + .public_key_hash() + .expect("original hash"); + fixture.update(vec![payout([0x76; 20], 10000)], &transaction); + let replacement_identity = fixture.identity(&transaction); + fixture.update(vec![], &transaction); + fixture.update(vec![payout(original_address, 10000)], &transaction); + let restored = fixture.identity(&transaction); + assert_eq!(restored.public_keys().len(), 3); + assert_eq!(restored.public_keys().get(&0), Some(&fixture.original_key)); + assert!(restored + .public_keys() + .get(&2) + .expect("replacement key") + .is_disabled()); + assert_matches!( + fixture + .withdraw(&restored, &fixture.original_key, 1, true, &transaction) + .await + .as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + assert_eq!( + restored.public_keys().get(&1), + replacement_identity.public_keys().get(&1) + ); +} + +#[tokio::test] +async fn should_revoke_p2pkh_authority_for_unsupported_or_inconsistent_payout_scripts() { + let fixture = PayoutFixture::new(); + let transaction = fixture.platform.drive.grove.start_transaction(); + let address = fixture.original_key.public_key_hash().expect("payout hash"); + for script in [ + ScriptBuf::new_p2sh(&ScriptHash::from_byte_array(address)), + ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([0x76; 20])), + ] { + fixture.update( + vec![DMNPayout { + address, + script, + reward: 10000, + }], + &transaction, + ); + assert_disabled_key( + &fixture + .withdraw( + &fixture.original_identity, + &fixture.original_key, + 1, + true, + &transaction, + ) + .await, + 0, + ); + fixture.update(vec![payout(address, 10000)], &transaction); + assert_eq!( + fixture.identity(&transaction).public_keys(), + fixture.original_identity.public_keys() + ); + } +} + +#[tokio::test] +async fn should_reuse_old_payout_keys_after_more_than_six_rotations_with_an_explicit_destination() { + let fixture = PayoutFixture::new(); + let transaction = fixture.platform.drive.grove.start_transaction(); + for address in 0x80..0x88 { + fixture.update(vec![payout([address; 20], 10000)], &transaction); + } + fixture.update( + vec![payout( + fixture.original_key.public_key_hash().expect("old hash"), + 10000, + )], + &transaction, + ); + let restored = fixture.identity(&transaction); + assert_eq!(restored.public_keys().len(), 10); + assert_eq!( + restored + .public_keys() + .values() + .filter(|key| key.purpose() == Purpose::TRANSFER && !key.is_disabled()) + .count(), + 1 + ); + assert_matches!( + fixture + .withdraw(&restored, &fixture.original_key, 1, true, &transaction) + .await + .as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + // The inherited default-destination lookup is bounded; revocation does not widen it. + assert_matches!( + fixture + .withdraw( + &restored, + restored.public_keys().get(&1).expect("owner key"), + 2, + false, + &transaction + ) + .await + .as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::IdentityPublicKeyIsDisabledError(error)) + )] if error.public_key_index() == 9 + ); +} + +fn recent_keys( + fixture: &PayoutFixture, + transaction: &Transaction, +) -> KeyIDIdentityPublicKeyPairBTreeMap { + fixture + .platform + .drive + .fetch_identity_keys( + IdentityKeysRequest { + identity_id: PRO_TX_HASH, + request_type: KeyRequestType::RecentWithdrawalKeys, + limit: None, + offset: None, + }, + Some(transaction), + PlatformVersion::latest(), + ) + .expect("withdrawal index keys") +} + +#[test] +fn should_rollback_payout_authority_and_retry_without_index_divergence() { + let fixture = PayoutFixture::new(); + let candidate = fixture.platform.drive.grove.start_transaction(); + let initial_recent = recent_keys(&fixture, &candidate); + fixture.update(vec![payout([0x76; 20], 10000)], &candidate); + let accepted_identity = fixture.identity(&candidate); + let accepted_recent = recent_keys(&fixture, &candidate); + assert!(accepted_recent + .get(&0) + .expect("old indexed key") + .is_disabled()); + assert!(!accepted_recent + .get(&2) + .expect("new indexed key") + .is_disabled()); + assert_eq!( + accepted_identity.balance(), + fixture.original_identity.balance() + ); + fixture + .platform + .drive + .grove + .rollback_transaction(&candidate) + .expect("rollback candidate"); + assert_eq!(fixture.identity(&candidate), fixture.original_identity); + assert_eq!(recent_keys(&fixture, &candidate), initial_recent); + drop(candidate); + let retry = fixture.platform.drive.grove.start_transaction(); + fixture.update(vec![payout([0x76; 20], 10000)], &retry); + assert_eq!(fixture.identity(&retry), accepted_identity); + assert_eq!(recent_keys(&fixture, &retry), accepted_recent); +} + +#[test] +fn should_reject_mixed_payout_representations_without_changing_identity() { + let fixture = PayoutFixture::new(); + let transaction = fixture.platform.drive.grove.start_transaction(); + assert_matches!( + fixture.apply_diff( + DMNStateDiff { + payout_address: Some([0x76; 20]), + payouts: Some(vec![payout([0x77; 20], 10000)]), + ..empty_diff() + }, + &transaction + ), + Err(Error::Execution(ExecutionError::DashCoreBadResponseError( + _ + ))) + ); + assert_eq!(fixture.identity(&transaction), fixture.original_identity); +} + +#[test] +fn should_not_reuse_owner_keys_as_transfer_keys_even_if_the_hash_matches() { + let fixture = PayoutFixture::new(); + let transaction = fixture.platform.drive.grove.start_transaction(); + // Core rejects this owner/payout collision; purpose matching is defensive for malformed data. + let owner_hash = fixture + .original_identity + .public_keys() + .get(&1) + .expect("owner key") + .public_key_hash() + .expect("owner hash"); + fixture.update(vec![payout(owner_hash, 10000)], &transaction); + let identity = fixture.identity(&transaction); + assert_eq!( + identity.public_keys().get(&1), + fixture.original_identity.public_keys().get(&1) + ); + assert_eq!( + identity + .public_keys() + .get(&2) + .expect("new payout key") + .purpose(), + Purpose::TRANSFER + ); +} + +#[test] +fn should_fail_new_key_allocation_at_exhaustion_but_allow_reuse_and_revocation() { + let fixture = PayoutFixture::new(); + let transaction = fixture.platform.drive.grove.start_transaction(); + let (maximum_key, _) = IdentityPublicKey::random_masternode_transfer_key_with_rng( + KeyID::MAX, + &mut StdRng::seed_from_u64(716), + PlatformVersion::latest(), + ) + .expect("maximum-id key"); + fixture + .platform + .drive + .apply_drive_operations( + vec![DriveOperation::IdentityOperation( + IdentityOperationType::AddNewKeysToIdentity { + identity_id: PRO_TX_HASH, + unique_keys_to_add: vec![], + non_unique_keys_to_add: vec![maximum_key], + }, + )], + true, + &BlockInfo::default(), + Some(&transaction), + PlatformVersion::latest(), + None, + ) + .expect("add maximum key id"); + let before = fixture.identity(&transaction); + let mut operations = vec![]; + assert_matches!( + fixture.platform.reconcile_owner_payout_keys( + PRO_TX_HASH, + &[payout([0x76; 20], 10000)], + &transaction, + &mut operations, + PlatformVersion::latest() + ), + Err(Error::Execution(ExecutionError::Overflow(_))) + ); + assert!(operations.is_empty()); + assert_eq!(fixture.identity(&transaction), before); + fixture.update( + vec![payout( + fixture + .original_key + .public_key_hash() + .expect("original hash"), + 10000, + )], + &transaction, + ); + assert!(!fixture + .identity(&transaction) + .public_keys() + .get(&0) + .expect("original key") + .is_disabled()); + fixture.update(vec![], &transaction); + assert!(fixture + .identity(&transaction) + .public_keys() + .values() + .filter(|key| key.purpose() == Purpose::TRANSFER) + .all(|key| key.is_disabled())); +} + +fn assert_disabled_key(results: &[StateTransitionExecutionResult], expected_id: u32) { + assert_matches!(results, [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::SignatureError(SignatureError::PublicKeyIsDisabledError(error)) + )] if error.public_key_id() == expected_id); +} + +async fn rejects_obsolete_payout(split: bool) { + let fixture = PayoutFixture::new(); + let transaction = fixture.platform.drive.grove.start_transaction(); + assert_matches!( + fixture + .withdraw( + &fixture.original_identity, + &fixture.original_key, + 1, + true, + &transaction + ) + .await + .as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + let old_address = fixture + .original_key + .public_key_hash() + .expect("old payout hash"); + let payouts = if split { + vec![payout(old_address, 5000), payout([0x76; 20], 5000)] + } else { + vec![payout([0x76; 20], 10000)] + }; + fixture.update(payouts, &transaction); + fixture.add_later_credits(&transaction); + let balance_before = fixture.identity(&transaction).balance(); + let documents_before = fixture + .platform + .drive + .fetch_oldest_withdrawal_documents(Some(&transaction), PlatformVersion::latest()) + .expect("withdrawal documents before rejection"); + // An attacker retains the old enabled key and can still sign. Only node-side validation + // of the updated stored identity can remove that key's authority over later rewards. + let results = fixture + .withdraw( + &fixture.original_identity, + &fixture.original_key, + 2, + true, + &transaction, + ) + .await; + assert_disabled_key(&results, 0); + if split { + assert!(fixture + .identity(&transaction) + .public_keys() + .values() + .filter(|key| key.purpose() == Purpose::TRANSFER) + .all(|key| key.is_disabled())); + } + assert_eq!(fixture.identity(&transaction).balance(), balance_before); + assert_eq!( + fixture + .platform + .drive + .fetch_oldest_withdrawal_documents(Some(&transaction), PlatformVersion::latest()) + .expect("withdrawal documents after rejection"), + documents_before + ); + assert_eq!( + fixture.identity(&transaction).public_keys().get(&1), + fixture.original_identity.public_keys().get(&1) + ); +} + +#[tokio::test] +async fn should_reject_obsolete_payout_key_withdrawals_after_recipient_replacement() { + rejects_obsolete_payout(false).await; +} + +#[tokio::test] +async fn should_reject_withdrawals_by_a_recipient_remaining_in_a_split() { + rejects_obsolete_payout(true).await; +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_operator_identity/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_operator_identity/v0/mod.rs index e8ff92b902a..f059b250783 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_operator_identity/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_operator_identity/v0/mod.rs @@ -528,7 +528,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.0.1.1:1234").unwrap(), @@ -536,14 +536,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: rng.gen::<[u8; 20]>(), - payout_address: rng.gen::<[u8; 20]>(), + payout_address: Some(rng.gen::<[u8; 20]>()), + payouts: None, pub_key_operator: pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id_bytes), - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; @@ -605,7 +609,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.0.1.1:1234").unwrap(), @@ -613,14 +617,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: rng.gen::<[u8; 20]>(), - payout_address: rng.gen::<[u8; 20]>(), + payout_address: Some(rng.gen::<[u8; 20]>()), + payouts: None, pub_key_operator: pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id), - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; @@ -665,7 +673,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.0.1.1:1234").unwrap(), @@ -673,14 +681,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: rng.gen::<[u8; 20]>(), - payout_address: rng.gen::<[u8; 20]>(), + payout_address: Some(rng.gen::<[u8; 20]>()), + payouts: None, pub_key_operator: pub_key_operator.clone(), operator_payout_address: Some(new_operator_payout_address), platform_node_id: Some(node_id), - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; @@ -738,7 +750,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.0.1.1:1234").unwrap(), @@ -746,14 +758,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: rng.gen::<[u8; 20]>(), - payout_address: rng.gen::<[u8; 20]>(), + payout_address: Some(rng.gen::<[u8; 20]>()), + payouts: None, pub_key_operator: pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id_bytes), - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; @@ -815,7 +831,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.0.1.1:1234").unwrap(), @@ -823,14 +839,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: rng.gen::<[u8; 20]>(), - payout_address: rng.gen::<[u8; 20]>(), + payout_address: Some(rng.gen::<[u8; 20]>()), + payouts: None, pub_key_operator: pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(original_node_id), - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; @@ -876,7 +896,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.0.1.1:1234").unwrap(), @@ -884,14 +904,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: rng.gen::<[u8; 20]>(), - payout_address: rng.gen::<[u8; 20]>(), + payout_address: Some(rng.gen::<[u8; 20]>()), + payouts: None, pub_key_operator: pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(new_platform_node_id), - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; @@ -964,7 +988,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.0.1.1:1234").unwrap(), @@ -972,14 +996,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: rng.gen::<[u8; 20]>(), - payout_address: rng.gen::<[u8; 20]>(), + payout_address: Some(rng.gen::<[u8; 20]>()), + payouts: None, pub_key_operator: original_pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id_bytes), - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; @@ -1094,7 +1122,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.0.1.1:1234").unwrap(), @@ -1102,14 +1130,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: rng.gen::<[u8; 20]>(), - payout_address: rng.gen::<[u8; 20]>(), + payout_address: Some(rng.gen::<[u8; 20]>()), + payouts: None, pub_key_operator: original_pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id_bytes), - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; @@ -1195,7 +1227,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.0.1.1:1234").unwrap(), @@ -1203,14 +1235,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: rng.gen::<[u8; 20]>(), - payout_address: rng.gen::<[u8; 20]>(), + payout_address: Some(rng.gen::<[u8; 20]>()), + payouts: None, pub_key_operator: original_pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id_bytes), - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; @@ -1256,7 +1292,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str("1.0.1.1:1234").unwrap(), @@ -1264,14 +1300,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: rng.gen::<[u8; 20]>(), - payout_address: rng.gen::<[u8; 20]>(), + payout_address: Some(rng.gen::<[u8; 20]>()), + payouts: None, pub_key_operator: new_pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id_bytes), - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/mod.rs index a656a484c91..5ba3ac81174 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/mod.rs @@ -73,12 +73,27 @@ where mod test { use super::*; use crate::config::PlatformConfig; - use crate::test::helpers::setup::TestPlatformBuilder; + use crate::platform_types::platform_state::platform_state_for_saving::v2::{ + deserialize_masternode_entry, serialize_masternode_entry, + }; + use crate::platform_types::platform_state::PlatformStateV0Methods; + use crate::platform_types::validator::v0::{NewValidatorIfMasternodeInState, ValidatorV0}; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::ProTxHash; use dpp::dashcore_rpc::json::MasternodeListDiff; + use dpp::identifier::MasternodeIdentifiers; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::{KeyID, Purpose}; + use dpp::prelude::Identifier; + use std::collections::BTreeMap; use std::env; use std::fs::File; use std::io::BufReader; use std::path::PathBuf; + use std::str::FromStr; #[test] fn test_update_masternode_list() { @@ -198,4 +213,314 @@ mod test { .unwrap() .expect("expected to commit"); } + + /// A platform at `platform_version` whose mock Core returns Dash Core v24 `protx listdiff` + /// output: the diff from height 1 to 1250 adds a legacy Evo, a shared masternode, Regular + /// masternodes with one and with two payouts (one of them with a Tor primary address), an + /// Evo without addresses and an IPv6 Evo with two payouts; the diff to 1260 moves the + /// legacy Evo to extended addresses and changes two payout lists. + fn platform_with_core_v24_masternode_list( + platform_version: &PlatformVersion, + ) -> TempPlatform { + let mut platform = TestPlatformBuilder::new() + .with_config(PlatformConfig::default()) + .with_initial_protocol_version(platform_version.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + platform + .core_rpc + .expect_get_protx_diff_with_masternodes() + .returning(move |_base_block, block| { + let file_name = if block == 1250 { + "1-1250.json" + } else { + "1250-1260.json" + }; + let json = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/supporting_files/core_v24_protx_list_diffs") + .join(file_name), + ) + .expect("expected to read the masternode list diff"); + // Parse it as the Core RPC client parses the `protx listdiff` response, so a + // response the client rejects fails the update with the same error. + Ok(serde_json::from_str(&json)?) + }); + + platform + } + + /// Up to protocol version 13 an added masternode needs a payout and an owner address for + /// its owner identity. A Dash Core v24 list adds shared and extended-address masternodes + /// that have neither or no payout address, so the update fails with + /// `DashCoreBadResponseError` and the block with it, as on every release that cannot parse + /// such a list. + #[test] + fn should_fail_on_a_core_v24_masternode_list_up_to_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + let platform = platform_with_core_v24_masternode_list(platform_version); + + let transaction = platform.drive.grove.start_transaction(); + let mut init_chain_platform_state = platform.state.load().as_ref().clone(); + + match platform.update_masternode_list( + None, + &mut init_chain_platform_state, + 1250, + true, + &BlockInfo { + height: 1, + core_height: 1250, + time_ms: 1, + ..Default::default() + }, + &transaction, + platform_version, + ) { + Err(Error::Execution(ExecutionError::DashCoreBadResponseError(message))) => assert!( + message.ends_with("has no payout address"), + "unexpected message: {message}" + ), + other => panic!("expected DashCoreBadResponseError, got {other:?}"), + } + } + + /// Dash Core v24 prints masternodes that differ from every earlier list: a shared + /// masternode has no owner, payout or collateral address; an extended-address masternode + /// has a `payouts` list instead of `payoutAddress`; a Tor primary address is not an + /// `ip:port` `service`; an Evo without addresses has `-1` platform ports. From protocol + /// version 14 the list is processed rather than failing the block, and the masternode + /// identities are: + /// - shared: voter and operator identities, no owner identity (it has no owner key); + /// - one payout: the same owner identity as a legacy payout address (TRANSFER key 0, + /// OWNER key 1); + /// - several payouts: an owner identity with only the OWNER key 1; + /// - a later payout-list change: revoke obsolete keys and authorize only a sole P2PKH + /// recipient, preserving the OWNER key. + #[test] + fn should_update_masternode_identities_from_a_core_v24_masternode_list() { + let platform_version = PlatformVersion::latest(); + + let platform = platform_with_core_v24_masternode_list(platform_version); + + let genesis_core_block_height = 1250; + let first_block_core_block_height = 1260; + + let transaction = platform.drive.grove.start_transaction(); + + let mut init_chain_platform_state = platform.state.load().as_ref().clone(); + + platform + .update_masternode_list( + None, + &mut init_chain_platform_state, + genesis_core_block_height, + true, + &BlockInfo { + height: 1, + core_height: genesis_core_block_height, + time_ms: 1, + ..Default::default() + }, + &transaction, + platform_version, + ) + .expect("expected to process the Core v24 masternode list"); + + let platform_state = init_chain_platform_state.clone(); + let mut block_platform_state = platform_state.clone(); + + platform + .update_masternode_list( + Some(&platform_state), + &mut block_platform_state, + first_block_core_block_height, + false, + &BlockInfo { + height: 1, + core_height: first_block_core_block_height, + time_ms: 2, + ..Default::default() + }, + &transaction, + platform_version, + ) + .expect("expected to process the Core v24 masternode list diff"); + + assert_eq!(block_platform_state.full_masternode_list().len(), 7); + assert_eq!(block_platform_state.hpmn_masternode_list().len(), 3); + + // An Evo without addresses prints `-1` platform ports, which read as no port, so it + // cannot be a validator, like any Evo without ports. The legacy Evo is one, with the + // ports and address the diff gave it. + let validator = |pro_tx_hash: &str| { + ValidatorV0::new_validator_if_masternode_in_state( + ProTxHash::from_str(pro_tx_hash).expect("expected a pro tx hash"), + None, + &block_platform_state, + ) + }; + assert!( + validator("6f1757595185032c808321af3e2e8468fae10b8f91e2a657d7a4c7122f4b2706").is_none() + ); + let legacy_evo_validator = + validator("ca3d32c4f2ef62eaf736bf41bb3235a832ec1eb6d8dfaccd4d3555e70f6757b0") + .expect("expected the legacy Evo to be a validator"); + assert_eq!(legacy_evo_validator.node_ip, "192.0.2.40"); + assert_eq!(legacy_evo_validator.platform_p2p_port, 36656); + assert_eq!(legacy_evo_validator.platform_http_port, 1443); + + // Every parsed masternode survives a store and reload, except for the payout list and + // nested addresses, which are not stored. + for masternode in block_platform_state.full_masternode_list().values() { + let bytes = serialize_masternode_entry(masternode, platform_version) + .expect("expected to serialize the masternode entry"); + let mut expected = masternode.clone(); + expected.state.payouts = None; + expected.state.addresses = None; + assert_eq!( + deserialize_masternode_entry(&bytes) + .expect("expected to deserialize the masternode entry"), + expected + ); + } + + let hash160 = |hex: &str| -> Vec { hex::decode(hex).expect("expected hex") }; + + // The keys and disabled state of an identity; `None` if there is no identity. + let identity_keys = + |identity_id: Identifier| -> Option, bool)>> { + platform + .drive + .fetch_full_identity( + identity_id.to_buffer(), + Some(&transaction), + platform_version, + ) + .expect("expected to fetch an identity") + .map(|identity| { + identity + .public_keys() + .iter() + .map(|(key_id, key)| { + ( + *key_id, + (key.purpose(), key.data().to_vec(), key.is_disabled()), + ) + }) + .collect() + }) + }; + + let owner_key = |hex: &str| (Purpose::OWNER, hash160(hex), false); + let transfer_key = |hex: &str| (Purpose::TRANSFER, hash160(hex), false); + + let voting_421c = "421c03add2c804421451c4e022258778175e60d8"; + let voting_064c = "064cd21ff210c1a92adaea49a4768a41c2aef1bc"; + + // (pro_tx_hash, voting key hash, operator public key, owner identity keys) + let expected = [ + ( + // Legacy Evo whose payout address the diff moves into `payouts` + "ca3d32c4f2ef62eaf736bf41bb3235a832ec1eb6d8dfaccd4d3555e70f6757b0", + voting_421c, + "8ed3f0c208efbcfc815cbfb94490dc68cf2e29d44dd9f8a91e20e06057aa110d7062c8ab7ccc85a9ff0c88760157f563".to_string(), + Some(BTreeMap::from([ + (0, transfer_key("75d57974b6e29a4a70df57a3b11195ce0a0dc817")), + (1, owner_key("1f67d90f35e3c5070c368ae6f3635aac357e47df")), + ])), + ), + ( + // One payout, which the diff replaces with two + "27978dd892b7c876c238be1a6141461c2824f3497dd0058e160979bc8f0a0bef", + voting_421c, + "a792ce1af5f7bb9281053b3934cb8b08d00d075a56498e1a525388ce467f188e8a80911fd96a20982baa9b9678452534".to_string(), + Some(BTreeMap::from([ + (0, (Purpose::TRANSFER, hash160("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), true)), + (1, owner_key("b740a2ab3f631e4dfe15debf746364f1d8352c50")), + ])), + ), + ( + // Shared + "a4d26868017c0ccffe2efe50944ef4211834660cca834c6e9f86dec6a88246fa", + voting_421c, + "b1".repeat(48), + None, + ), + ( + // One payout, which the diff replaces with another single payout + "813a7c3f28817988a8e6ce66e07e43e261e78398373bfbaae94c898645111d6b", + voting_421c, + "b2".repeat(48), + Some(BTreeMap::from([ + (0, (Purpose::TRANSFER, hash160("5555555555555555555555555555555555555555"), true)), + (2, transfer_key("f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1")), + (1, owner_key("0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a")), + ])), + ), + ( + // Tor primary address, two payouts; the owner and voting keys are the same + "3023ffd989974768b0dfc347410ad923fa6d3f1eee90180bd0c435e81cb1a82f", + voting_064c, + "b3".repeat(48), + Some(BTreeMap::from([(1, owner_key(voting_064c))])), + ), + ( + // Evo without addresses, one payout + "6f1757595185032c808321af3e2e8468fae10b8f91e2a657d7a4c7122f4b2706", + voting_421c, + "b4".repeat(48), + Some(BTreeMap::from([ + (0, transfer_key("9999999999999999999999999999999999999999")), + (1, owner_key("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")), + ])), + ), + ( + // Evo with an IPv6 primary address, two payouts + "aecd2830b843e6a84283ba290492a213e355cea7c5026e6118a21e1bfbc36783", + voting_421c, + "b5".repeat(48), + Some(BTreeMap::from([( + 1, + owner_key("0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c"), + )])), + ), + ]; + + for (pro_tx_hash, voting_key_hash, operator_public_key, owner_identity_keys) in expected { + let pro_tx_hash = ProTxHash::from_str(pro_tx_hash) + .expect("expected a pro tx hash") + .to_byte_array(); + let voting_key_hash: [u8; 20] = hash160(voting_key_hash) + .try_into() + .expect("expected 20 bytes"); + let operator_public_key = hex::decode(operator_public_key).expect("expected hex"); + + assert_eq!( + identity_keys(pro_tx_hash.into()), + owner_identity_keys, + "owner identity of {}", + hex::encode(pro_tx_hash) + ); + assert!( + identity_keys(Identifier::create_voter_identifier( + &pro_tx_hash, + &voting_key_hash + )) + .is_some(), + "voter identity of {}", + hex::encode(pro_tx_hash) + ); + assert!( + identity_keys(Identifier::create_operator_identifier( + &pro_tx_hash, + &operator_public_key + )) + .is_some(), + "operator identity of {}", + hex::encode(pro_tx_hash) + ); + } + } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs index f3ada50f19b..1138c3aad35 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs @@ -72,11 +72,13 @@ where validator.node_ip = address.ip().to_string(); } - if let Some(p2p_port) = dmn_state_diff.platform_p2p_port { + #[allow(deprecated)] + if let Some(p2p_port) = dmn_state_diff.legacy_platform_p2p_port { validator.platform_p2p_port = p2p_port as u16; } - if let Some(http_port) = dmn_state_diff.platform_http_port { + #[allow(deprecated)] + if let Some(http_port) = dmn_state_diff.legacy_platform_http_port { validator.platform_http_port = http_port as u16; } } @@ -169,10 +171,12 @@ where } // these 3 fields are the only fields that are useful for validators. If they change we need to update // validator sets + #[allow(deprecated)] + let platform_p2p_port_changed = state_diff.legacy_platform_p2p_port.is_some(); if is_hpmn && (state_diff.pose_ban_height.is_some() || state_diff.service.is_some() - || state_diff.platform_p2p_port.is_some()) + || platform_p2p_port_changed) { // we updated the ban status the IP or the platform port, we need to update the validator in the validator list Self::update_masternode_in_validator_sets(pro_tx_hash, state_diff, state); @@ -222,7 +226,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array([0u8; 32]), collateral_index: 0, - collateral_address: [0u8; 20], + collateral_address: Some([0u8; 20]), operator_reward: 0.0, state: DMNState { service: "1.2.3.4:1234".parse().expect("socket address"), @@ -230,14 +234,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: [0u8; 20], + owner_address: Some([0u8; 20]), voting_address: [0u8; 20], - payout_address: [0u8; 20], + payout_address: Some([0u8; 20]), + payouts: None, pub_key_operator: vec![0u8; 48], operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, } } @@ -257,11 +265,15 @@ mod tests { owner_address: None, voting_address: None, payout_address: None, + payouts: None, pub_key_operator: None, operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 2a18b37a56c..a6661af0cfc 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -191,8 +191,15 @@ impl Platform { .max() .expect("there must be keys, we already checked"); + let owner_address = masternode.state.owner_address.ok_or_else(|| { + Error::Execution(ExecutionError::DashCoreBadResponseError(format!( + "masternode {} has no owner address", + masternode.pro_tx_hash + ))) + })?; + let new_owner_key = Self::get_owner_identity_owner_key( - masternode.state.owner_address, + owner_address, last_key_id + 1, platform_version, )?; @@ -1050,6 +1057,138 @@ mod tests { assert!(result.is_ok()); } + /// Version 4 gives every owner identity the OWNER key of its masternode's owner address, + /// after the keys it has. A masternode without an owner identity is skipped before its + /// owner address is read. One with an owner identity but no owner address (a shared + /// masternode) has no key to give, so the transition fails with `DashCoreBadResponseError` + /// instead of leaving the identity without an OWNER key. + #[test] + fn should_fail_transition_to_version_4_for_an_owner_identity_whose_masternode_has_no_owner_address( + ) { + use dpp::dashcore::{ProTxHash, Txid}; + use dpp::dashcore_rpc::dashcore_rpc_json::{DMNState, MasternodeListItem, MasternodeType}; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::{Identity, KeyID, Purpose}; + use std::collections::BTreeMap; + + let platform_version = PlatformVersion::get(4).expect("expected protocol version 4"); + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(platform_version.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + // A masternode as Core lists it; a shared one has no owner, payout or collateral + // address. + let masternode = + |pro_tx_hash: [u8; 32], owner_address: Option<[u8; 20]>| MasternodeListItem { + node_type: MasternodeType::Regular, + pro_tx_hash: ProTxHash::from_byte_array(pro_tx_hash), + collateral_hash: Txid::from_byte_array([0x41; 32]), + collateral_index: 0, + collateral_address: owner_address.map(|_| [0x42; 20]), + operator_reward: 0.0, + state: DMNState { + service: "1.2.3.4:9999".parse().expect("socket address"), + registered_height: 0, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: 0, + owner_address, + voting_address: [0x43; 20], + payout_address: owner_address.map(|_| [0x44; 20]), + payouts: None, + pub_key_operator: vec![0x45; 48], + operator_payout_address: None, + platform_node_id: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, + }, + }; + let owner_address = [0x46; 20]; + let with_owner_address = [0x47; 32]; + let shared_without_owner_identity = [0x48; 32]; + let shared_with_owner_identity = [0x49; 32]; + + // Owner identities as created before version 4: only the TRANSFER key id 0. + for pro_tx_hash in [with_owner_address, shared_with_owner_identity] { + let mut identity = + Identity::create_basic_identity(pro_tx_hash.into(), platform_version) + .expect("expected an identity"); + identity.add_public_keys([ + Platform::::get_owner_identity_withdrawal_key( + [0x44; 20], + 0, + platform_version, + ) + .expect("expected a withdrawal key"), + ]); + platform + .drive + .add_new_identity( + identity, + true, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add the owner identity"); + } + + let transaction = platform.drive.grove.start_transaction(); + let platform_state_with = |masternodes: Vec| { + let mut platform_state = platform.state.load().as_ref().clone(); + for masternode in masternodes { + platform_state.insert_masternode(masternode); + } + platform_state + }; + + platform + .transition_to_version_4( + &platform_state_with(vec![ + masternode(with_owner_address, Some(owner_address)), + masternode(shared_without_owner_identity, None), + ]), + &BlockInfo::default(), + &transaction, + platform_version, + ) + .expect("expected a masternode without an owner identity to be skipped"); + assert_eq!( + platform + .drive + .fetch_full_identity(with_owner_address, Some(&transaction), platform_version) + .expect("expected to fetch the owner identity") + .expect("expected the owner identity to exist") + .public_keys() + .iter() + .map(|(key_id, key)| (*key_id, (key.purpose(), key.data().to_vec()))) + .collect::)>>(), + BTreeMap::from([ + (0, (Purpose::TRANSFER, vec![0x44; 20])), + (1, (Purpose::OWNER, owner_address.to_vec())), + ]) + ); + + match platform.transition_to_version_4( + &platform_state_with(vec![masternode(shared_with_owner_identity, None)]), + &BlockInfo::default(), + &transaction, + platform_version, + ) { + Err(Error::Execution(ExecutionError::DashCoreBadResponseError(message))) => assert!( + message.ends_with("has no owner address"), + "unexpected message: {message}" + ), + other => panic!("expected DashCoreBadResponseError, got {other:?}"), + } + } + #[test] fn test_transition_to_version_6_inserts_wallet_utils_contract() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/storage/store_platform_state/v1/mod.rs b/packages/rs-drive-abci/src/execution/storage/store_platform_state/v1/mod.rs index 762e21bbbd2..c4919e1fd19 100644 --- a/packages/rs-drive-abci/src/execution/storage/store_platform_state/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/storage/store_platform_state/v1/mod.rs @@ -231,7 +231,7 @@ mod tests { pro_tx_hash: pro_tx_hash(byte), collateral_hash: Txid::from_byte_array([0u8; 32]), collateral_index: 0, - collateral_address: [0u8; 20], + collateral_address: Some([0u8; 20]), operator_reward: 0.0, state: DMNState { service: "1.2.3.4:1234".parse().expect("socket address"), @@ -239,14 +239,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: [byte; 20], + owner_address: Some([byte; 20]), voting_address: [0u8; 20], - payout_address: [0u8; 20], + payout_address: Some([0u8; 20]), + payouts: None, pub_key_operator: vec![0u8; 48], operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, } } @@ -432,11 +436,15 @@ mod tests { owner_address: None, voting_address: None, payout_address: None, + payouts: None, pub_key_operator: None, operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, )); let removed_quorum = QuorumHash::from_byte_array([0xAA; 32]); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 08cab23c758..71d3753fbe2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -759,7 +759,7 @@ pub(in crate::execution) mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen()), collateral_index: 0, - collateral_address: rng.gen(), + collateral_address: Some(rng.gen()), operator_reward: 0.0, state: DMNState { service: SocketAddr::new(IpAddr::V4(random_ip), 19999), @@ -767,14 +767,18 @@ pub(in crate::execution) mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address, + owner_address: Some(owner_address), voting_address: rng.gen(), - payout_address, + payout_address: Some(payout_address), + payouts: None, pub_key_operator: vec![], operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }, ); @@ -848,7 +852,7 @@ pub(in crate::execution) mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen()), collateral_index: 0, - collateral_address: rng.gen(), + collateral_address: Some(rng.gen()), operator_reward: 0.0, state: DMNState { service: SocketAddr::new(IpAddr::V4(random_ip), 19999), @@ -856,14 +860,18 @@ pub(in crate::execution) mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen(), + owner_address: Some(rng.gen()), voting_address, - payout_address: rng.gen(), + payout_address: Some(rng.gen()), + payouts: None, pub_key_operator: vec![], operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }, ); diff --git a/packages/rs-drive-abci/src/platform_types/masternode/v0/accessors.rs b/packages/rs-drive-abci/src/platform_types/masternode/v0/accessors.rs index 6406323f28c..c48a8957993 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/v0/accessors.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/v0/accessors.rs @@ -11,7 +11,8 @@ pub trait MasternodeAccessorsV0 { fn collateral_hash(&self) -> Txid; /// The index of the collateral transaction output. fn collateral_index(&self) -> u32; - /// The address where the collateral is stored. + /// The address where the collateral is stored; zero bytes when it has none, as for a + /// shared masternode. fn collateral_address(&self) -> [u8; 20]; /// The amount of the operator's reward for running the masternode. fn operator_reward(&self) -> f32; diff --git a/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs b/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs index 43e9dba0359..c67b646feb8 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs @@ -23,7 +23,8 @@ pub struct MasternodeV0 { pub collateral_hash: Txid, /// The index of the collateral transaction output. pub collateral_index: u32, - /// The address where the collateral is stored. + /// The address where the collateral is stored; zero bytes when it has none, as for a + /// shared masternode. pub collateral_address: [u8; 20], /// The amount of the operator's reward for running the masternode. pub operator_reward: f32, @@ -62,7 +63,7 @@ impl From for MasternodeV0 { pro_tx_hash, collateral_hash, collateral_index, - collateral_address, + collateral_address: stored_address(collateral_address), operator_reward, state: state.into(), } @@ -86,13 +87,27 @@ impl From for MasternodeListItem { pro_tx_hash, collateral_hash, collateral_index, - collateral_address, + collateral_address: loaded_address(collateral_address), operator_reward, state: state.into(), } } } +/// The stored form of an address a masternode may not have. The stored format has no room for +/// an absent address, so it is stored as zero bytes, and zero bytes read back as absent. That +/// loses no owner address: Core refuses a null owner key for every masternode but a shared one, +/// which has none. A payout or collateral address of zero bytes also reads back as absent; +/// nothing reads those from the stored list. +fn stored_address(address: Option<[u8; 20]>) -> [u8; 20] { + address.unwrap_or_default() +} + +/// The address a [`stored_address`] holds. +fn loaded_address(address: [u8; 20]) -> Option<[u8; 20]> { + (address != [0u8; 20]).then_some(address) +} + /// A `MasternodeState` contains information about a masternode's state. #[derive(Clone, PartialEq, Eq, Debug, Encode, Decode)] pub struct MasternodeStateV0 { @@ -112,13 +127,15 @@ pub struct MasternodeStateV0 { /// Reason for the masternode's revocation (encoded as an integer). pub revocation_reason: u32, - /// The masternode owner's public address. + /// The masternode owner's public address; zero bytes when it has none, as a shared + /// masternode. pub owner_address: [u8; 20], /// The masternode voting public address. pub voting_address: [u8; 20], - /// The masternode payout public address. + /// The masternode payout public address; zero bytes when it has none, as a shared + /// masternode or one with a payout list. pub payout_address: [u8; 20], /// The masternode operator's public key. @@ -139,6 +156,8 @@ pub struct MasternodeStateV0 { impl From for MasternodeStateV0 { fn from(value: DMNState) -> Self { + // The payout list and the nested addresses are not stored. + #[allow(deprecated)] let DMNState { service, registered_height, @@ -148,11 +167,13 @@ impl From for MasternodeStateV0 { owner_address, voting_address, payout_address, + payouts: _, pub_key_operator, operator_payout_address, platform_node_id, - platform_p2p_port, - platform_http_port, + legacy_platform_p2p_port: platform_p2p_port, + legacy_platform_http_port: platform_http_port, + addresses: _, } = value; Self { @@ -161,9 +182,9 @@ impl From for MasternodeStateV0 { pose_revived_height, pose_ban_height, revocation_reason, - owner_address, + owner_address: stored_address(owner_address), voting_address, - payout_address, + payout_address: stored_address(payout_address), pub_key_operator, operator_payout_address, platform_node_id, @@ -191,20 +212,150 @@ impl From for DMNState { platform_http_port, } = value; + #[allow(deprecated)] Self { service, registered_height, pose_revived_height, pose_ban_height, revocation_reason, - owner_address, + owner_address: loaded_address(owner_address), voting_address, - payout_address, + payout_address: loaded_address(payout_address), + payouts: None, pub_key_operator, operator_payout_address, platform_node_id, - platform_p2p_port, - platform_http_port, + legacy_platform_p2p_port: platform_p2p_port, + legacy_platform_http_port: platform_http_port, + addresses: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::platform_types::platform_state::platform_state_for_saving::v2::{ + deserialize_masternode_entry, serialize_masternode_entry, + }; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::{ProTxHash, PubkeyHash, ScriptBuf, Txid}; + use dpp::dashcore_rpc::dashcore_rpc_json::{ + DMNPayout, DMNState, MasternodeAddresses, MasternodeListItem, MasternodeType, + }; + use dpp::version::PlatformVersion; + + fn masternode( + node_type: MasternodeType, + collateral_address: Option<[u8; 20]>, + owner_address: Option<[u8; 20]>, + payout_address: Option<[u8; 20]>, + payouts: Option>, + ) -> MasternodeListItem { + let is_evo = node_type == MasternodeType::Evo; + MasternodeListItem { + node_type, + pro_tx_hash: ProTxHash::from_byte_array([0x31; 32]), + collateral_hash: Txid::from_byte_array([0x32; 32]), + collateral_index: 1, + collateral_address, + operator_reward: 0.0, + state: DMNState { + service: "1.2.3.4:9999".parse().expect("socket address"), + registered_height: 10, + pose_revived_height: None, + pose_ban_height: Some(20), + revocation_reason: 0, + owner_address, + voting_address: [0x33; 20], + payout_address, + payouts, + pub_key_operator: vec![0x34; 48], + operator_payout_address: Some([0x35; 20]), + platform_node_id: is_evo.then_some([0x36; 20]), + #[allow(deprecated)] + legacy_platform_p2p_port: is_evo.then_some(26656), + #[allow(deprecated)] + legacy_platform_http_port: is_evo.then_some(443), + addresses: None, + }, } } + + fn p2pkh_payout(key_hash: [u8; 20], reward: u16) -> DMNPayout { + DMNPayout { + address: key_hash, + script: ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(key_hash)), + reward, + } + } + + fn stored_and_loaded(masternode: &MasternodeListItem) -> MasternodeListItem { + let bytes = serialize_masternode_entry(masternode, PlatformVersion::latest()) + .expect("expected to serialize the masternode entry"); + deserialize_masternode_entry(&bytes).expect("expected to deserialize the masternode entry") + } + + /// A masternode from a list Core printed before v24 has every address, and reloads as + /// it was stored. + #[test] + fn should_reload_a_masternode_with_every_address_unchanged() { + let masternode = masternode( + MasternodeType::Evo, + Some([0x37; 20]), + Some([0x38; 20]), + Some([0x39; 20]), + None, + ); + + assert_eq!(stored_and_loaded(&masternode), masternode); + } + + /// The stored format has no room for an absent address: a shared masternode's owner, + /// payout and collateral addresses are stored as zero bytes and must reload as absent, + /// not as an address of zeros. The payout list and nested addresses are not stored. + #[test] + fn should_reload_absent_addresses_as_absent() { + let mut shared = masternode(MasternodeType::Regular, None, None, None, None); + shared.state.addresses = Some(MasternodeAddresses { + core_p2p: vec!["1.2.3.4:9999".to_string()], + platform_p2p: vec![], + platform_https: vec![], + }); + let mut expected = shared.clone(); + expected.state.addresses = None; + assert_eq!(stored_and_loaded(&shared), expected); + + let multi_payout = masternode( + MasternodeType::Evo, + Some([0x37; 20]), + Some([0x38; 20]), + None, + Some(vec![ + p2pkh_payout([0x3a; 20], 5000), + p2pkh_payout([0x3b; 20], 5000), + ]), + ); + let mut expected = multi_payout.clone(); + expected.state.payouts = None; + assert_eq!(stored_and_loaded(&multi_payout), expected); + } + + /// Zero bytes stand for an absent address in the stored list, so an address of zero bytes + /// reads back as absent. Core never prints one: it refuses a null owner key for every + /// masternode but a shared one, and nothing reads a stored payout or collateral address. + #[test] + fn should_reload_an_address_of_zero_bytes_as_absent() { + let zero = masternode( + MasternodeType::Regular, + Some([0u8; 20]), + Some([0u8; 20]), + Some([0u8; 20]), + None, + ); + let reloaded = stored_and_loaded(&zero); + assert_eq!(reloaded.collateral_address, None); + assert_eq!(reloaded.state.owner_address, None); + assert_eq!(reloaded.state.payout_address, None); + } } diff --git a/packages/rs-drive-abci/src/platform_types/platform_state/accessors.rs b/packages/rs-drive-abci/src/platform_types/platform_state/accessors.rs index c3b16df2223..73a1990a4ea 100644 --- a/packages/rs-drive-abci/src/platform_types/platform_state/accessors.rs +++ b/packages/rs-drive-abci/src/platform_types/platform_state/accessors.rs @@ -895,7 +895,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array([0u8; 32]), collateral_index: 0, - collateral_address: [0u8; 20], + collateral_address: Some([0u8; 20]), operator_reward: 0.0, state: DMNState { service: "1.2.3.4:1234".parse().expect("socket address"), @@ -903,14 +903,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: [0u8; 20], + owner_address: Some([0u8; 20]), voting_address: [0u8; 20], - payout_address: [0u8; 20], + payout_address: Some([0u8; 20]), + payouts: None, pub_key_operator: vec![0u8; 48], operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, } } @@ -928,11 +932,15 @@ mod tests { owner_address: None, voting_address: None, payout_address: None, + payouts: None, pub_key_operator: None, operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, } } diff --git a/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs b/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs index f52b335b54e..2fbab063bd1 100644 --- a/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs @@ -22,12 +22,13 @@ impl NewValidatorIfMasternodeInState for ValidatorV0 { ) -> Option { let MasternodeListItem { state, .. } = state.hpmn_masternode_list().get(&pro_tx_hash)?; + #[allow(deprecated)] let DMNState { service, platform_node_id, pose_ban_height, - platform_p2p_port, - platform_http_port, + legacy_platform_p2p_port: platform_p2p_port, + legacy_platform_http_port: platform_http_port, .. } = state; let Some(platform_http_port) = platform_http_port else { diff --git a/packages/rs-drive-abci/tests/strategy_tests/masternode_list_item_helpers.rs b/packages/rs-drive-abci/tests/strategy_tests/masternode_list_item_helpers.rs index 82f61aaf0ba..3ada8f30353 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/masternode_list_item_helpers.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/masternode_list_item_helpers.rs @@ -15,8 +15,10 @@ impl UpdateMasternodeListItem for MasternodeListItem { .filter(|&field_idx| match field_idx { 4 => self.state.operator_payout_address.is_some(), 5 => self.state.platform_node_id.is_some(), - 6 => self.state.platform_p2p_port.is_some(), - 7 => self.state.platform_http_port.is_some(), + #[allow(deprecated)] + 6 => self.state.legacy_platform_p2p_port.is_some(), + #[allow(deprecated)] + 7 => self.state.legacy_platform_http_port.is_some(), _ => true, }) .collect(); @@ -29,9 +31,9 @@ impl UpdateMasternodeListItem for MasternodeListItem { available_fields.retain(|&idx| idx != field_idx); match field_idx { - 0 => self.state.owner_address = rng.gen::<[u8; 20]>(), + 0 => self.state.owner_address = Some(rng.gen::<[u8; 20]>()), 1 => self.state.voting_address = rng.gen::<[u8; 20]>(), - 2 => self.state.payout_address = rng.gen::<[u8; 20]>(), + 2 => self.state.payout_address = Some(rng.gen::<[u8; 20]>()), 3 => { let private_key_operator_bytes = bls_signatures::PrivateKey::generate_dash(rng) .expect("expected to generate a private key") @@ -55,13 +57,17 @@ impl UpdateMasternodeListItem for MasternodeListItem { *address = rng.gen::<[u8; 20]>(); } } - 6 => { - if let Some(ref mut port) = self.state.platform_p2p_port { + 6 => + { + #[allow(deprecated)] + if let Some(ref mut port) = self.state.legacy_platform_p2p_port { *port = rng.gen_range(1024..=65535); } } - 7 => { - if let Some(ref mut port) = self.state.platform_http_port { + 7 => + { + #[allow(deprecated)] + if let Some(ref mut port) = self.state.legacy_platform_http_port { *port = rng.gen_range(1024..=65535); } } @@ -101,7 +107,7 @@ mod tests { pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str(format!("1.0.{}.{}:1234", i / 256, i % 256).as_str()) @@ -110,14 +116,18 @@ mod tests { pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: rng.gen::<[u8; 20]>(), - payout_address: rng.gen::<[u8; 20]>(), + payout_address: Some(rng.gen::<[u8; 20]>()), + payouts: None, pub_key_operator, operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; diff --git a/packages/rs-drive-abci/tests/strategy_tests/masternodes.rs b/packages/rs-drive-abci/tests/strategy_tests/masternodes.rs index 55c2794bf59..60d53f8fabc 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/masternodes.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/masternodes.rs @@ -244,7 +244,7 @@ pub fn generate_test_masternodes( pro_tx_hash, collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str(format!("1.0.{}.{}:1234", i / 256, i % 256).as_str()) @@ -253,14 +253,18 @@ pub fn generate_test_masternodes( pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: generate_voting_address(rng, add_voting_keys_to_signer), - payout_address: generate_payout_address(rng, add_payout_keys_to_signer), + payout_address: Some(generate_payout_address(rng, add_payout_keys_to_signer)), + payouts: None, pub_key_operator, operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: None, }, }; @@ -381,7 +385,7 @@ pub fn generate_test_masternodes( pro_tx_hash: ProTxHash::from_byte_array(rng.gen::<[u8; 32]>()), collateral_hash: Txid::from_byte_array(rng.gen::<[u8; 32]>()), collateral_index: 0, - collateral_address: [0; 20], + collateral_address: Some([0; 20]), operator_reward: 0.0, state: DMNState { service: SocketAddr::from_str(format!("1.1.{}.{}:1234", i / 256, i % 256).as_str()) @@ -390,14 +394,18 @@ pub fn generate_test_masternodes( pose_revived_height: None, pose_ban_height: None, revocation_reason: 0, - owner_address: rng.gen::<[u8; 20]>(), + owner_address: Some(rng.gen::<[u8; 20]>()), voting_address: generate_voting_address(rng, add_voting_keys_to_signer), - payout_address: generate_payout_address(rng, add_payout_keys_to_signer), + payout_address: Some(generate_payout_address(rng, add_payout_keys_to_signer)), + payouts: None, pub_key_operator, operator_payout_address: None, platform_node_id: Some(rng.gen::<[u8; 20]>()), - platform_p2p_port: Some(3010), - platform_http_port: Some(8080), + #[allow(deprecated)] + legacy_platform_p2p_port: Some(3010), + #[allow(deprecated)] + legacy_platform_http_port: Some(8080), + addresses: None, }, }; @@ -530,12 +538,14 @@ pub fn generate_test_masternodes( SocketAddr::new(IpAddr::V4(random_ip), old_port); } if update.p2p_port { - if let Some(port) = hpmn_list_item_b.state.platform_p2p_port.as_mut() { + #[allow(deprecated)] + if let Some(port) = hpmn_list_item_b.state.legacy_platform_p2p_port.as_mut() { *port += 1 } } if update.http_port { - if let Some(port) = hpmn_list_item_b.state.platform_http_port.as_mut() { + #[allow(deprecated)] + if let Some(port) = hpmn_list_item_b.state.legacy_platform_http_port.as_mut() { *port += 1 } } diff --git a/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/1-1250.json b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/1-1250.json new file mode 100644 index 00000000000..9c2826584a9 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/1-1250.json @@ -0,0 +1,279 @@ +{ + "baseHeight": 1, + "blockHeight": 1250, + "addedMNs": [ + { + "type": "Evo", + "proTxHash": "ca3d32c4f2ef62eaf736bf41bb3235a832ec1eb6d8dfaccd4d3555e70f6757b0", + "collateralHash": "5c1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8", + "collateralIndex": 0, + "collateralAddress": "yh6REqVqc88VTnJ5rCXPYnQNtrc72YmiXB", + "operatorReward": 0, + "state": { + "version": 2, + "service": "192.0.2.40:9999", + "addresses": { + "core_p2p": [ + "192.0.2.40:9999" + ], + "platform_https": [ + "192.0.2.40:443" + ], + "platform_p2p": [ + "192.0.2.40:26656" + ] + }, + "registeredHeight": 900, + "lastPaidHeight": 1190, + "consecutivePayments": 0, + "PoSePenalty": 0, + "PoSeRevivedHeight": -1, + "PoSeBanHeight": -1, + "revocationReason": 0, + "ownerAddress": "yPBWCdMRY5PsS3hJzs7csbdWQVRR85yxUz", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "platformNodeID": "9e391c2c041a122a779bafe09d6c47ea600dfcbe", + "platformP2PPort": 26656, + "platformHTTPPort": 443, + "payoutAddress": "yX4Ve7Q8Y4jscV4LZJD8HVCHKyePzR3MhA", + "pubKeyOperator": "8ed3f0c208efbcfc815cbfb94490dc68cf2e29d44dd9f8a91e20e06057aa110d7062c8ab7ccc85a9ff0c88760157f563" + } + }, + { + "type": "Regular", + "proTxHash": "27978dd892b7c876c238be1a6141461c2824f3497dd0058e160979bc8f0a0bef", + "collateralHash": "6d2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809", + "collateralIndex": 1, + "collateralAddress": "yguoUwpMVtQCgq49SzH8r5QUuUmYJd89fR", + "operatorReward": 0, + "state": { + "version": 3, + "service": "192.0.2.41:9999", + "addresses": { + "core_p2p": [ + "192.0.2.41:9999" + ] + }, + "registeredHeight": 1100, + "lastPaidHeight": 1180, + "consecutivePayments": 0, + "PoSePenalty": 0, + "PoSeRevivedHeight": -1, + "PoSeBanHeight": -1, + "revocationReason": 0, + "ownerAddress": "yd2PwFoqtEJdnJVSEzBDMxVnFVgEvJyvyY", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "payouts": [ + { + "address": "yi6ouEk1CRe5j27k3rsnQftoqRiD29vExs", + "script": "76a914eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee88ac", + "reward": 10000 + } + ], + "pubKeyOperator": "a792ce1af5f7bb9281053b3934cb8b08d00d075a56498e1a525388ce467f188e8a80911fd96a20982baa9b9678452534" + } + }, + { + "type": "Regular", + "proTxHash": "a4d26868017c0ccffe2efe50944ef4211834660cca834c6e9f86dec6a88246fa", + "collateralHash": "a4d26868017c0ccffe2efe50944ef4211834660cca834c6e9f86dec6a88246fa", + "collateralIndex": 1, + "operatorReward": 0, + "state": { + "version": 3, + "service": "192.0.2.30:9999", + "addresses": { + "core_p2p": [ + "192.0.2.30:9999" + ] + }, + "registeredHeight": 1210, + "lastPaidHeight": 0, + "consecutivePayments": 0, + "PoSePenalty": 0, + "PoSeRevivedHeight": -1, + "PoSeBanHeight": -1, + "revocationReason": 0, + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "shares": [ + { + "amount": 60000000000, + "refundAddress": "yMsgnH1xKGa85n4bq2imrZbG2KgrmGttAV", + "refundScript": "76a914111111111111111111111111111111111111111188ac", + "rewardAddress": "yPRviMJaEHGafsc5EKovpa5Nw2Jewo9mdj", + "rewardScript": "76a914222222222222222222222222222222222222222288ac", + "ownerAddress": "yQzAeRbC9Hy3Fy9Ydcu5naZVqivT95Ka2R" + }, + { + "amount": 40000000000, + "refundAddress": "ySYQaVsp4JfVr4h22uzEkb3ckRYFHJwGq9", + "refundScript": "76a914444444444444444444444444444444444444444488ac", + "rewardAddress": "ySYQaVsp4JfVr4h22uzEkb3ckRYFHJwGq9", + "rewardScript": "76a914444444444444444444444444444444444444444488ac", + "ownerAddress": "yVetSeT3tL4R2FmxqWAYgc1rZpmqbMcFhL" + } + ], + "earlyPeriodBlocks": 1000, + "earlyPenalty": 100000000, + "pubKeyOperator": "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1" + } + }, + { + "type": "Regular", + "proTxHash": "813a7c3f28817988a8e6ce66e07e43e261e78398373bfbaae94c898645111d6b", + "collateralHash": "ff6226e6c97bfcf40b6d04e12e3f75678024988823bfba28cde2a9ac11b1a765", + "collateralIndex": 1, + "collateralAddress": "yNqYnF9sHURjwRmhZMLFGQ3WjC5DZNJMUi", + "operatorReward": 0, + "state": { + "version": 3, + "service": "192.0.2.31:9999", + "addresses": { + "core_p2p": [ + "192.0.2.31:9999" + ] + }, + "registeredHeight": 1220, + "lastPaidHeight": 0, + "consecutivePayments": 0, + "PoSePenalty": 0, + "PoSeRevivedHeight": -1, + "PoSeBanHeight": -1, + "revocationReason": 0, + "ownerAddress": "yMEXdf8kwSX7NSipRnrtQ66d4WFtBCi71e", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "payouts": [ + { + "address": "yU6eWaARyKMxSAEVSD5PibXjf8A3TH4gqJ", + "script": "76a914555555555555555555555555555555555555555588ac", + "reward": 10000 + } + ], + "pubKeyOperator": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + } + }, + { + "type": "Regular", + "proTxHash": "3023ffd989974768b0dfc347410ad923fa6d3f1eee90180bd0c435e81cb1a82f", + "collateralHash": "35215134107b5e423d327cab12d2b4c60a9b769301096e05a95916676d2f7867", + "collateralIndex": 0, + "collateralAddress": "yd2PwFoqtEJdnJVSEzBDMxVnFVgEvJyvyY", + "operatorReward": 0, + "state": { + "version": 3, + "service": "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:9999", + "addresses": { + "core_p2p": [ + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:9999", + "192.0.2.32:9999" + ] + }, + "registeredHeight": 1230, + "lastPaidHeight": 0, + "consecutivePayments": 0, + "PoSePenalty": 0, + "PoSeRevivedHeight": -1, + "PoSeBanHeight": -1, + "revocationReason": 0, + "ownerAddress": "yLtkvxSueGSufQZQq8L9GVHch9QRqJqGkZ", + "votingAddress": "yLtkvxSueGSufQZQq8L9GVHch9QRqJqGkZ", + "payouts": [ + { + "address": "yYmNJo2HiMTLCSrue6Lrccz6PE1S1ELq2T", + "script": "76a914888888888888888888888888888888888888888888ac", + "reward": 7000 + }, + { + "address": "8qK9EafotWgreuSxH2x8ySZnKVDVivy3Zf", + "script": "a914777777777777777777777777777777777777777787", + "reward": 3000 + } + ], + "pubKeyOperator": "b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3" + } + }, + { + "type": "Evo", + "proTxHash": "6f1757595185032c808321af3e2e8468fae10b8f91e2a657d7a4c7122f4b2706", + "collateralHash": "cbf3c744b1c18fe1866e79972818c98fb1a268736f1757595185032c808321af", + "collateralIndex": 0, + "collateralAddress": "ybhjexnMcGckdJCyUwFu3F25zPo4mqQg1k", + "operatorReward": 0, + "state": { + "version": 3, + "service": "[::]:0", + "addresses": {}, + "registeredHeight": 1240, + "lastPaidHeight": 0, + "consecutivePayments": 0, + "PoSePenalty": 0, + "PoSeRevivedHeight": -1, + "PoSeBanHeight": -1, + "revocationReason": 0, + "ownerAddress": "yMKqWbyVzZPFkvLndPz1kSba4CBAbA9oZg", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "platformNodeID": "cbf3c744b1c18fe1866e79972818c98fb1a26873", + "platformP2PPort": -1, + "platformHTTPPort": -1, + "payouts": [ + { + "address": "yaKcEsJudN9nnYQP3PS1adUDHvdE6sjNiQ", + "script": "76a914999999999999999999999999999999999999999988ac", + "reward": 10000 + } + ], + "pubKeyOperator": "b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4" + } + }, + { + "type": "Evo", + "proTxHash": "aecd2830b843e6a84283ba290492a213e355cea7c5026e6118a21e1bfbc36783", + "collateralHash": "59cd030a1a4cd43a53c3a600c20f64ed07399873aecd2830b843e6a84283ba29", + "collateralIndex": 0, + "collateralAddress": "yNqYnF9sHURjwRmhZMLFGQ3WjC5DZNJMUi", + "operatorReward": 0, + "state": { + "version": 3, + "service": "[2001:db8::4]:9999", + "addresses": { + "core_p2p": [ + "[2001:db8::4]:9999" + ], + "platform_p2p": [ + "[2001:db8::4]:26656" + ], + "platform_https": [ + "[2001:db8::4]:443" + ] + }, + "registeredHeight": 1250, + "lastPaidHeight": 0, + "consecutivePayments": 0, + "PoSePenalty": 0, + "PoSeRevivedHeight": -1, + "PoSeBanHeight": -1, + "revocationReason": 0, + "ownerAddress": "yMR9PYpF3gFQ9Pxkq1796o6X3t6SxnHXrq", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "platformNodeID": "4bcc85253e395ec272998a0722ac3ee1dd3965f7", + "platformP2PPort": 26656, + "platformHTTPPort": 443, + "payouts": [ + { + "address": "ybsrAwbXYNrFNdwrSgXAYdxLCdF2GdFbqY", + "script": "76a914aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa88ac", + "reward": 5000 + }, + { + "address": "ydS671t9TPYhxjVKqycKWeST7KrpUM1c4t", + "script": "76a914bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb88ac", + "reward": 5000 + } + ], + "pubKeyOperator": "b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5" + } + } + ], + "removedMNs": [], + "updatedMNs": [] +} diff --git a/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/1250-1260.json b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/1250-1260.json new file mode 100644 index 00000000000..e935e1508df --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/1250-1260.json @@ -0,0 +1,61 @@ +{ + "baseHeight": 1250, + "blockHeight": 1260, + "addedMNs": [], + "removedMNs": [], + "updatedMNs": [ + { + "ca3d32c4f2ef62eaf736bf41bb3235a832ec1eb6d8dfaccd4d3555e70f6757b0": { + "version": 3, + "service": "192.0.2.40:9999", + "payouts": [ + { + "address": "yX4Ve7Q8Y4jscV4LZJD8HVCHKyePzR3MhA", + "script": "76a91475d57974b6e29a4a70df57a3b11195ce0a0dc81788ac", + "reward": 10000 + } + ], + "platformP2PPort": 36656, + "platformHTTPPort": 1443, + "addresses": { + "core_p2p": [ + "192.0.2.40:9999" + ], + "platform_p2p": [ + "192.0.2.40:36656" + ], + "platform_https": [ + "192.0.2.40:1443" + ] + } + } + }, + { + "27978dd892b7c876c238be1a6141461c2824f3497dd0058e160979bc8f0a0bef": { + "payouts": [ + { + "address": "yezL36AmNQFAYq2oFGhUUeva22UccZnF8F", + "script": "76a914cccccccccccccccccccccccccccccccccccccccc88ac", + "reward": 2500 + }, + { + "address": "ygYZyATPHQwd8vaGeZndSfQgvj6Qro5WbT", + "script": "76a914dddddddddddddddddddddddddddddddddddddddd88ac", + "reward": 7500 + } + ] + } + }, + { + "813a7c3f28817988a8e6ce66e07e43e261e78398373bfbaae94c898645111d6b": { + "payouts": [ + { + "address": "yiNjY5GEMnEWtSyeegFATjPepVU48ATT7E", + "script": "76a914f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f188ac", + "reward": 10000 + } + ] + } + } + ] +} diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs index 1cd73cac365..06bc0616f06 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs @@ -22,6 +22,14 @@ use crate::version::drive_abci_versions::drive_abci_method_versions::{ /// `decode_raw_state_transitions` 1 refuses bytes left over after a raw state transition. /// `add_distribute_storage_fee_to_epochs_operations` 1 claws each pending storage refund back /// from the epochs it was priced for. +/// `create_owner_identity` 2 and `update_masternode_identities` 1 decide the owner identity of +/// the masternodes Dash Core lists from v24 on, on which version 1 fails: none without an owner +/// address (a shared masternode), the version 1 identity for a legacy payout address or a sole +/// payout with a matching P2PKH script, and only the OWNER key for other payout lists. +/// Payout-list changes reconcile TRANSFER keys: retain, re-enable or add the supported sole +/// recipient and disable obsolete authority; split, empty or unsupported lists disable all +/// TRANSFER keys while preserving OWNER and balance. Historical updater behavior and legacy +/// payout-address rotation remain unchanged. /// Everything else matches `DRIVE_ABCI_METHOD_VERSIONS_V9`. pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMethodVersions { engine: DriveAbciEngineMethodVersions { @@ -47,10 +55,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet get_voter_identifier_from_masternode_list_item: 0, get_operator_identifier_from_masternode_list_item: 0, create_operator_identity: 0, - create_owner_identity: 1, + create_owner_identity: 2, // no owner identity without an owner; TRANSFER authority requires a sole supported payout create_voter_identity: 0, disable_identity_keys: 0, - update_masternode_identities: 0, + update_masternode_identities: 1, // tolerates absent owner identities and reconciles payout-list TRANSFER authority update_operator_identity: 0, update_owner_withdrawal_address: 1, update_voter_identity: 0, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 5bb2c1e7b23..7dd131fdd7d 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1990,6 +1990,26 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// admission estimate. Actual fees remain metered. Nullifiers revealed by /// shields before this version are not added. /// +/// 77. **Owner identities for shared and extended-address masternodes**: from +/// v24 on, Dash Core lists shared masternodes, which have no owner, payout +/// or collateral address, and extended-address masternodes, which have a +/// `payouts` list instead of a `payoutAddress`. `create_owner_identity` 1 +/// needs both addresses and fails on such a masternode with +/// `DashCoreBadResponseError`, which fails the block. With +/// `create_owner_identity` 2 and `update_masternode_identities` 1 +/// (`DRIVE_ABCI_METHOD_VERSIONS_V10`), a masternode without an owner +/// address gets no owner identity, only its voter and operator identities; +/// one with an owner address and either a legacy payout address or a sole +/// payout with a matching P2PKH script gets the version 1 identity, +/// TRANSFER key id 0 and OWNER key id 1, byte for byte; other payout shapes +/// get only OWNER key id 1. Legacy payout-address rotation is unchanged. +/// Payout-list changes retain, re-enable or add the sole supported P2PKH +/// TRANSFER key and disable obsolete TRANSFER keys. Split, empty or +/// unsupported lists disable all TRANSFER authority while preserving OWNER +/// and balance. Historical updaters keep their payout-list policy. This +/// version must be active on a network before its Dash Core activates V24, +/// since earlier versions keep failing on these masternodes. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's