diff --git a/Cargo.lock b/Cargo.lock index 3c59ed9fc84..2e2e8ebd017 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1949,7 +1949,9 @@ dependencies = [ "cxx", "cxx-build", "dash-sdk", + "drive", "platform-encryption", + "tenderdash-abci", "tokio", "zeroize", ] diff --git a/packages/rs-drive/src/util/common/asset_lock.rs b/packages/rs-drive/src/util/common/asset_lock.rs new file mode 100644 index 00000000000..25b2025e245 --- /dev/null +++ b/packages/rs-drive/src/util/common/asset_lock.rs @@ -0,0 +1,130 @@ +//! The record Drive keeps under `SpentAssetLockTransactions` for each asset +//! lock outpoint a transition used. + +use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; +use dpp::asset_lock::StoredAssetLockInfo; + +/// Reads the item Drive keeps for an asset lock outpoint: none before any +/// transition used the lock, an empty one once a transition used it up, and +/// otherwise the lock's remaining value, which `decode_remaining` +/// deserializes as far as the caller trusts the bytes. +pub fn stored_asset_lock_info( + item: Option<&[u8]>, + decode_remaining: impl FnOnce(&[u8]) -> Result, +) -> Result { + match item { + None => Ok(StoredAssetLockInfo::NotPresent), + Some([]) => Ok(StoredAssetLockInfo::FullyConsumed), + Some(bytes) => decode_remaining(bytes).map(StoredAssetLockInfo::PartiallyConsumed), + } +} + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + use crate::drive::RootTree; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::platform_value::Bytes36; + use dpp::serialization::{PlatformDeserializableTrusted, PlatformDeserializableUntrusted}; + use dpp::version::PlatformVersion; + use grovedb::Element; + + /// Drive's writer, its consensus fetch and this reader agree on every + /// record: none, a used-up lock, and a partly used one. + #[test] + fn should_read_records_as_drive_writes_and_fetches_them() { + let platform_version = PlatformVersion::latest(); + let drive_version = &platform_version.drive; + let drive = setup_drive_with_initial_state_structure(None); + let left = AssetLockValue::new(2_000, vec![0x76], 1_500, vec![], platform_version) + .expect("an asset lock value"); + + let cases = [ + (Bytes36::new([1; 36]), None), + ( + Bytes36::new([2; 36]), + Some(AssetLockValue::new(2_000, vec![0x76], 0, vec![], platform_version).unwrap()), + ), + (Bytes36::new([3; 36]), Some(left.clone())), + ]; + for (outpoint, value) in &cases { + let Some(value) = value else { continue }; + let operations = drive + .add_asset_lock_outpoint_operations( + outpoint, + value.clone(), + &mut None, + platform_version, + ) + .expect("the operations adding an asset lock outpoint"); + drive + .apply_batch_low_level_drive_operations( + None, + None, + operations, + &mut vec![], + drive_version, + ) + .expect("an asset lock outpoint added"); + } + + let path: [&[u8]; 1] = [&[RootTree::SpentAssetLockTransactions as u8]]; + let expected = [ + StoredAssetLockInfo::NotPresent, + StoredAssetLockInfo::FullyConsumed, + StoredAssetLockInfo::PartiallyConsumed(left), + ]; + for ((outpoint, _), expected) in cases.iter().zip(expected) { + let fetched = drive + .fetch_asset_lock_outpoint_info(outpoint, None, drive_version) + .expect("an asset lock record fetched"); + let element = drive + .grove + .get_raw_optional( + (&path).into(), + outpoint.as_slice(), + None, + &drive_version.grove_version, + ) + .unwrap() + .expect("a raw read"); + let item = element + .as_ref() + .map(Element::as_item_bytes) + .transpose() + .unwrap(); + + assert_eq!(fetched, expected); + assert_eq!( + stored_asset_lock_info(item, AssetLockValue::deserialize_from_bytes_trusted) + .unwrap(), + expected + ); + assert_eq!( + stored_asset_lock_info( + item, + AssetLockValue::deserialize_from_bytes_untrusted_exact + ) + .unwrap(), + expected + ); + } + } + + #[test] + fn should_hand_a_nonempty_item_to_the_callers_decoder() { + let refuse = |_: &[u8]| Err::("undecodable"); + assert_eq!( + stored_asset_lock_info(Some(&[0xff; 3]), refuse), + Err("undecodable") + ); + assert_eq!( + stored_asset_lock_info(Some(&[]), refuse), + Ok(StoredAssetLockInfo::FullyConsumed) + ); + assert_eq!( + stored_asset_lock_info(None, refuse), + Ok(StoredAssetLockInfo::NotPresent) + ); + } +} diff --git a/packages/rs-drive/src/util/common/mod.rs b/packages/rs-drive/src/util/common/mod.rs index 1c80d24e06b..270fd75b583 100644 --- a/packages/rs-drive/src/util/common/mod.rs +++ b/packages/rs-drive/src/util/common/mod.rs @@ -1,2 +1,3 @@ +pub mod asset_lock; pub mod decode; pub mod encode; diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index c0ab82e2d34..3ccc4075171 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -2303,8 +2303,8 @@ impl Drive { } StateTransition::ShieldFromAssetLock(st) => { use crate::drive::RootTree; + use crate::util::common::asset_lock::stored_asset_lock_info; use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; - use dpp::asset_lock::StoredAssetLockInfo; use dpp::identity::state_transition::AssetLockProved; use dpp::serialization::PlatformDeserializableUntrusted; use dpp::state_transition::proof_result::StateTransitionProofResult::{ @@ -2413,16 +2413,14 @@ impl Drive { )) })?; + // Inert for every protocol version: the helper reads an empty item as + // FullyConsumed and hands any other to the same untrusted decoder this + // arm called inline, so each item verifies as before. let info = match outpoint_element { - Some(Element::Item(bytes, _)) => { - if bytes.is_empty() { - StoredAssetLockInfo::FullyConsumed - } else { - StoredAssetLockInfo::PartiallyConsumed( - AssetLockValue::deserialize_from_bytes_untrusted(&bytes)?, - ) - } - } + Some(Element::Item(bytes, _)) => stored_asset_lock_info( + Some(&bytes), + AssetLockValue::deserialize_from_bytes_untrusted, + )?, Some(_) => { return Err(Error::Proof(ProofError::CorruptedProof( "expected an item element for asset lock outpoint".to_string(), @@ -2456,19 +2454,13 @@ impl Drive { ))); } + // Inert for every protocol version, as in the surplus arm above. let info = if let Some(proved) = proved_key_values.pop() { match proved.2 { - Some(Element::Item(bytes, _)) => { - if bytes.is_empty() { - StoredAssetLockInfo::FullyConsumed - } else { - StoredAssetLockInfo::PartiallyConsumed( - AssetLockValue::deserialize_from_bytes_untrusted( - &bytes, - )?, - ) - } - } + Some(Element::Item(bytes, _)) => stored_asset_lock_info( + Some(&bytes), + AssetLockValue::deserialize_from_bytes_untrusted, + )?, Some(_) => { return Err(Error::Proof(ProofError::CorruptedProof( "expected an item element for asset lock outpoint" @@ -6070,6 +6062,131 @@ mod tests { ); } + // --- ShieldFromAssetLock reads the outpoint's record alike in both arms, + // at protocol version 13 (the last of verifier generation 0), 14 and the + // latest. An empty item is FullyConsumed, a nonempty one decodes untrusted + // with trailing bytes ignored, and an absent record is a corrupted proof, + // because the transition executed. + #[test] + fn should_read_the_shield_from_asset_lock_record_alike_from_protocol_version_13() { + use crate::drive::RootTree; + use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; + use dpp::asset_lock::StoredAssetLockInfo; + use dpp::dashcore::OutPoint; + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use dpp::identity::state_transition::asset_lock_proof::AssetLockProof; + use dpp::platform_value::BinaryData; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::shield_from_asset_lock_transition::v0::ShieldFromAssetLockTransitionV0; + use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; + use grovedb::Element; + use StateTransitionProofResult::{ + VerifiedAssetLockConsumed, VerifiedAssetLockConsumedWithAddressInfos, + }; + + let path: [&[u8]; 1] = [&[RootTree::SpentAssetLockTransactions as u8]]; + for protocol_version in [13, 14, PlatformVersion::latest().protocol_version] { + let platform_version = + PlatformVersion::get(protocol_version).expect("a known protocol version"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let left = AssetLockValue::new(2_000, vec![0x76], 1_500, vec![], platform_version) + .expect("an asset lock value"); + let encoded = left + .serialize_to_bytes() + .expect("an encoded asset lock value"); + let padded = [encoded.as_slice(), &[0xab, 0xcd]].concat(); + let rows = [ + (1, Some(vec![]), Some(StoredAssetLockInfo::FullyConsumed)), + ( + 2, + Some(encoded), + Some(StoredAssetLockInfo::PartiallyConsumed(left.clone())), + ), + ( + 3, + Some(padded), + Some(StoredAssetLockInfo::PartiallyConsumed(left)), + ), + (4, None, None), + ]; + + for (seed, item, expected) in rows { + let out_point = [seed; 36]; + if let Some(item) = item { + drive + .grove_insert( + (&path).into(), + &out_point, + Element::new_item(item), + None, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("a record inserted"); + } + + for surplus_output in [None, Some(PlatformAddress::P2pkh([0x33; 20]))] { + let with_surplus = surplus_output.is_some(); + let transition = StateTransition::ShieldFromAssetLock( + ShieldFromAssetLockTransition::V0(ShieldFromAssetLockTransitionV0 { + asset_lock_proof: AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 100, + out_point: OutPoint::from(out_point), + }), + actions: vec![], + value_balance: 0, + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + surplus_output, + signature: BinaryData::new(vec![]), + }), + ); + let proof = drive + .prove_state_transition(&transition, None, platform_version) + .expect("a proof") + .into_data() + .expect("proof bytes"); + + let verified = Drive::verify_state_transition_was_executed_with_proof( + &transition, + &BlockInfo::default(), + &proof, + &|_| Ok(None), + platform_version, + ) + .map(|(_, outcome)| outcome.into_result()); + + let row = format!( + "protocol version {protocol_version}, record {seed}, surplus output {with_surplus}" + ); + match (&expected, verified) { + (Some(expected), Ok(VerifiedAssetLockConsumed(info))) if !with_surplus => { + assert_eq!(&info, expected, "{row}") + } + ( + Some(expected), + Ok(VerifiedAssetLockConsumedWithAddressInfos(info, _)), + ) if with_surplus => { + assert_eq!(&info, expected, "{row}") + } + ( + None, + Err(crate::error::Error::Proof(ProofError::CorruptedProof(message))), + ) => assert!( + message.contains("asset lock outpoint is absent from proof"), + "{row}: {message}" + ), + (expected, verified) => { + panic!("{row}: expected {expected:?}, got {verified:?}") + } + } + } + } + } + } + // --- IdentityCreateFromAddresses: empty proof returns error. #[test] fn verify_identity_create_from_addresses_empty_proof_returns_error() { diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs index de4fd11d5b7..c672b75cdbd 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -15,8 +15,11 @@ //! - [`submit_with_cl_height_retry`] — re-exported from //! `dash_sdk::platform::transition::asset_lock_submission`: the //! retry-on-10506 wrapper that bumps `user_fee_increase` between -//! attempts so Tenderdash's invalid-tx hash cache (24h on -//! mainnet/testnet) can't silently drop resubmits. +//! attempts so no attempt repeats bytes a node refused: Tenderdash +//! keeps refused hashes with no time limit (the mempool's 24 h +//! `ttl-duration` covers admitted transactions only) and drops +//! identical-bytes resubmits; see the SDK module's "Refused +//! transitions". //! - [`AssetLockManager::resolve_funding_with_is_timeout_fallback`] — //! maps an [`AssetLockFunding`] choice to a [`FundingResolution`] //! that the caller can drive into an IS→CL retry when the IS-lock diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs index 7cd3f05deef..7f0f2913aec 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs @@ -24,7 +24,8 @@ //! consensus code 10506) — Platform's observed Core tip is briefly //! behind the wallet's SPV-verified CL. Bump //! `PutSettings::user_fee_increase` (changes ST signable bytes → -//! different ST hash → bypasses Tenderdash's 24h invalid-tx cache) +//! different ST hash → not dropped by Tenderdash's cache of refused +//! hashes, which keeps them with no time limit) //! and resubmit the same proof. See //! [`submit_with_cl_height_retry`]. //! diff --git a/packages/rs-sdk-cxx/Cargo.toml b/packages/rs-sdk-cxx/Cargo.toml index 10ae77cdf49..95a61b21692 100644 --- a/packages/rs-sdk-cxx/Cargo.toml +++ b/packages/rs-sdk-cxx/Cargo.toml @@ -23,5 +23,20 @@ tokio = { version = "1.40", features = ["rt-multi-thread"] } async-trait = "0.1" zeroize = "1.8" +[dev-dependencies] +tokio = { version = "1.40", features = ["macros", "test-util"] } +# The top-up tests answer the mock SDK with Drive's own proofs, signed as a +# quorum signs them. +dash-sdk = { path = "../rs-sdk", default-features = false, features = [ + "mocks", + "dpns-contract", + "dashpay-contract", + "core_key_wallet", +] } +drive = { path = "../rs-drive" } +tenderdash-abci = { git = "https://github.com/dashpay/rs-tenderdash-abci", tag = "v1.8.1", features = [ + "crypto", +], default-features = false } + [build-dependencies] cxx-build = "1.0" diff --git a/packages/rs-sdk-cxx/include/dash/sdk/callbacks.h b/packages/rs-sdk-cxx/include/dash/sdk/callbacks.h index ca4cef5f820..a285b83158d 100644 --- a/packages/rs-sdk-cxx/include/dash/sdk/callbacks.h +++ b/packages/rs-sdk-cxx/include/dash/sdk/callbacks.h @@ -18,8 +18,9 @@ //! The C++ objects the SDK calls back into. Each wraps std::function //! callbacks the embedder supplies, and each method is noexcept: a callback //! that is empty, throws, or returns false is a refusal, and the SDK call it -//! serves fails. Answers are written into fixed-size buffers owned by Rust, -//! so nothing crosses the FFI by allocation. +//! serves fails (CoreContext::QuorumPublicKey answers Unavailable instead). +//! Answers are written into fixed-size buffers owned by Rust, so nothing +//! crosses the FFI by allocation. //! //! The SDK calls these on its own worker threads, possibly concurrently //! with other calls on the same client, and may destroy them on any thread. @@ -128,21 +129,38 @@ class ContactKeys CompactXpubFn m_compact_xpub; }; +//! What the embedder knows of the quorum a proof names. +enum class QuorumLookup : uint8_t { + //! The quorum could have signed: `key_out` holds its 48-byte BLS public key. + Found = 0, + //! The embedder's complete local evidence shows the quorum could not have + //! signed: the proof is bad, and the endpoint that sent it is at fault. + Invalid = 1, + //! The embedder cannot decide now (it is behind, misses data, or failed): + //! no fault of the proof or of the endpoint that sent it. + Unavailable = 2, +}; + //! The embedder's chain state, which the SDK verifies every proof against. //! -//! QuorumPublicKey: the BLS public key of the quorum of type `llmq_type` -//! with hash `quorum_hash`, from the embedder's own mined commitments. The -//! hash is in the embedder's internal uint256 byte order. The client only -//! asks for its configured Platform LLMQ type; the callback must match both -//! type and hash and answer false for any quorum it does not hold. Any key -//! that is not exactly 48 bytes is refused. There is no fallback source. +//! QuorumPublicKey: whether the quorum of type `llmq_type` with hash +//! `quorum_hash` could have signed Platform state whose signed metadata +//! carries `core_chain_locked_height`, judged from the embedder's own mined +//! commitments, and its BLS public key when it could. The hash is in the +//! embedder's internal uint256 byte order; the height is the signed one, as +//! the proof carries it. The client only asks for its configured Platform +//! LLMQ type. Invalid is passed on: the proof is bad. Unavailable, a Found +//! whose key is not exactly 48 bytes, a value outside QuorumLookup, a throw +//! and an empty callback all reach the client as Unavailable, never as a +//! bad proof. There is no fallback source. //! //! PlatformActivationHeight: the core height Platform activated at. class CoreContext { public: - using QuorumPublicKeyFn = std::function& quorum_hash, - uint32_t core_chain_locked_height, std::vector& key_out)>; + using QuorumPublicKeyFn = + std::function& quorum_hash, + uint32_t core_chain_locked_height, std::vector& key_out)>; using PlatformActivationHeightFn = std::function; CoreContext(QuorumPublicKeyFn quorum_public_key, PlatformActivationHeightFn platform_activation_height) @@ -151,18 +169,25 @@ class CoreContext { } - bool QuorumPublicKey(uint8_t llmq_type, const std::array& quorum_hash, - uint32_t core_chain_locked_height, std::array& key_out) const noexcept + QuorumLookup QuorumPublicKey(uint8_t llmq_type, const std::array& quorum_hash, + uint32_t core_chain_locked_height, std::array& key_out) const noexcept { - return detail::Guard([&] { + try { + if (!m_quorum_public_key) return QuorumLookup::Unavailable; std::vector key; - if (!m_quorum_public_key || !m_quorum_public_key(llmq_type, quorum_hash, core_chain_locked_height, key) || - key.size() != key_out.size()) { - return false; + switch (m_quorum_public_key(llmq_type, quorum_hash, core_chain_locked_height, key)) { + case QuorumLookup::Found: + if (key.size() != key_out.size()) return QuorumLookup::Unavailable; + std::copy(key.begin(), key.end(), key_out.begin()); + return QuorumLookup::Found; + case QuorumLookup::Invalid: + return QuorumLookup::Invalid; + case QuorumLookup::Unavailable: + break; } - std::copy(key.begin(), key.end(), key_out.begin()); - return true; - }); + } catch (...) { + } + return QuorumLookup::Unavailable; } bool PlatformActivationHeight(uint32_t& height_out) const noexcept diff --git a/packages/rs-sdk-cxx/src/client.rs b/packages/rs-sdk-cxx/src/client.rs index 2d37a2cf3dd..58bf78eeb56 100644 --- a/packages/rs-sdk-cxx/src/client.rs +++ b/packages/rs-sdk-cxx/src/client.rs @@ -13,6 +13,8 @@ use std::str::FromStr; use std::sync::{Arc, Mutex}; use dash_sdk::dapi_client::transport::{ProxyEndpoint, Socks5Auth, Socks5Proxy}; +use dash_sdk::dpp::asset_lock::reduced_asset_lock_value::AssetLockValueGettersV0; +use dash_sdk::dpp::asset_lock::StoredAssetLockInfo; use dash_sdk::dpp::consensus::codes::ErrorWithCode; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::document::DocumentV0Getters; @@ -27,13 +29,19 @@ use dash_sdk::dpp::ProtocolError; use dash_sdk::platform::dashpay::{ ContactRequestInput, EcdhProvider, RecipientIdentity, SendContactRequestInput, }; -use dash_sdk::platform::dpns_usernames::{convert_to_homograph_safe_chars, RegisterDpnsNameInput}; +use dash_sdk::platform::dpns_usernames::{ + convert_to_homograph_safe_chars, DpnsContestPolicy, RegisterDpnsNameInput, +}; +use dash_sdk::platform::transition::asset_lock_submission::{ + submit_funding_with_fee_floor, FundingFailure, FundingFailureKind, UnsettledLockRecord, +}; use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; use dash_sdk::platform::types::identity::PublicKeyHash; use dash_sdk::platform::Fetch; use dash_sdk::sdk::{Address, AddressList}; use dash_sdk::{Error, Sdk, SdkBuilder}; +use zeroize::Zeroizing; use crate::context::CoreContextProvider; use crate::ffi::{self, Status, StatusKind}; @@ -100,7 +108,21 @@ pub fn classify(error: &Error) -> Status { ..Status::new(StatusKind::Consensus, broadcast.message.clone()) } } + // The record's own failure, under the top-up's message. + Error::AssetLockNotUsedUp { + record: UnsettledLockRecord::Unread(read), + .. + } => { + return Status { + message: error.to_string(), + ..classify(read) + } + } + Error::DpnsNameContested { .. } => StatusKind::Contested, _ if error.is_quorum_source_unavailable() => StatusKind::Unavailable, + Error::AssetLockNotUsedUp { .. } | Error::FeeIncreaseFloorAboveCap { .. } => { + StatusKind::Unavailable + } Error::DapiClientError(_) | Error::TimeoutReached(..) | Error::Cancelled(_) @@ -141,7 +163,30 @@ result_from!(IdentityResult { result_from!(IdResult { id: [u8; 32] = [0; 32] }); -result_from!(BalanceResult { balance: u64 = 0 }); + +/// One result struct per funding call, built from its outcome and report. +macro_rules! funding_result_from { + ($result:ident { $field:ident: $value:ty = $empty:expr }) => { + impl From<(Result<$value, Status>, ffi::FundingReport)> for ffi::$result { + fn from((outcome, funding): (Result<$value, Status>, ffi::FundingReport)) -> Self { + let (status, $field) = match outcome { + Ok(value) => (Status::ok(), value), + Err(status) => (status, $empty), + }; + ffi::$result { + status, + $field, + funding, + } + } + } + }; +} + +funding_result_from!(RegisterIdentityResult { + identity: ffi::Identity = Default::default() +}); +funding_result_from!(TopUpIdentityResult { balance: u64 = 0 }); result_from!(VoteStateResult { state: ffi::VoteState = marshal::empty_vote_state() }); @@ -161,6 +206,31 @@ impl From, bool), Status>> for ffi::ContactRequ } } +/// A failed read answers `NotPresent`, the state that settles nothing. +impl From> for ffi::AssetLockStateResult { + fn from(outcome: Result) -> Self { + let (status, state, remaining_credits) = match outcome { + Ok(StoredAssetLockInfo::NotPresent) => { + (Status::ok(), ffi::AssetLockState::NotPresent, 0) + } + Ok(StoredAssetLockInfo::PartiallyConsumed(value)) => ( + Status::ok(), + ffi::AssetLockState::PartiallyConsumed, + value.remaining_credit_value(), + ), + Ok(StoredAssetLockInfo::FullyConsumed) => { + (Status::ok(), ffi::AssetLockState::FullyConsumed, 0) + } + Err(status) => (status, ffi::AssetLockState::NotPresent, 0), + }; + ffi::AssetLockStateResult { + status, + state, + remaining_credits, + } + } +} + impl From, bool), Status>> for ffi::NamesResult { fn from(outcome: Result<(Vec, bool), Status>) -> Self { let (status, (names, complete)) = match outcome { @@ -419,9 +489,11 @@ impl PlatformClient { keys: &[ffi::IdentityKey], proof: &ffi::AssetLockProof, asset_lock_key_ref: u32, + user_fee_increase_floor: u16, signer: cxx::UniquePtr, - ) -> ffi::IdentityResult { - self.call("register_identity", |sdk| { + ) -> ffi::RegisterIdentityResult { + let built = Arc::new(Mutex::new(None)); + let call = self.call("register_identity", |sdk| { let public_keys = keys .iter() .map(|key| marshal::new_identity_key(key).map(|key| (key.id(), key))) @@ -432,24 +504,34 @@ impl PlatformClient { .ok_or_else(|| "duplicate identity key ids".to_string()) }); let proof = marshal::asset_lock_proof(proof); + let built = Arc::clone(&built); async move { // The id is derived from the asset lock when the transition is built. let identity = bare_identity(Identifier::default(), public_keys?); let signer = WalletSigner::new(signer)?; - let created = identity - .put_to_platform_and_wait_for_response_with_signer( - &sdk, - proof?, - &DerivationPath::master(), - &signer.asset_lock(asset_lock_key_ref), - &signer, - None, - ) - .await?; - Ok(marshal::identity(&created)) + let proof = proof?; + let asset_lock_signer = signer.asset_lock(asset_lock_key_ref); + let path = DerivationPath::master(); + let created = submit_funding_with_fee_floor( + &proof, + user_fee_increase_floor, + |fee| *lock(&built) = Some(fee), + |settings| { + identity.put_to_platform_and_wait_for_response_with_signer( + &sdk, + proof.clone(), + &path, + &asset_lock_signer, + &signer, + Some(settings), + ) + }, + ) + .await; + Ok(created.map(|created| marshal::identity(&created))) } - }) - .into() + }); + funding_report(call, user_fee_increase_floor, &built).into() } pub fn top_up_identity( @@ -457,24 +539,38 @@ impl PlatformClient { identity: &[u8; 32], proof: &ffi::AssetLockProof, asset_lock_key_ref: u32, + user_fee_increase_floor: u16, signer: cxx::UniquePtr, - ) -> ffi::BalanceResult { - self.call("top_up_identity", |sdk| { - // A top-up names only the identity's id. - let identity = bare_identity(Identifier::from(*identity), BTreeMap::new()); + ) -> ffi::TopUpIdentityResult { + let built = Arc::new(Mutex::new(None)); + let call = self.call("top_up_identity", |sdk| { + let identity = Identifier::from(*identity); let proof = marshal::asset_lock_proof(proof); + let built = Arc::clone(&built); async move { let signer = WalletSigner::new(signer)?; - Ok(identity - .top_up_identity_with_signer( + let proof = proof?; + let asset_lock_signer = signer.asset_lock(asset_lock_key_ref); + // A top-up names only the identity's id. + Ok(bare_identity(identity, BTreeMap::new()) + .top_up_identity_settled( &sdk, - proof?, + proof, &DerivationPath::master(), - &signer.asset_lock(asset_lock_key_ref), - None, + &asset_lock_signer, + user_fee_increase_floor, + move |fee| *lock(&built) = Some(fee), ) - .await?) + .await) } + }); + funding_report(call, user_fee_increase_floor, &built).into() + } + + pub fn asset_lock_state(&self, txid: &[u8; 32], vout: u32) -> ffi::AssetLockStateResult { + self.call("asset_lock_state", |sdk| { + let out_point = marshal::out_point(txid, vout); + async move { Ok(sdk.asset_lock_outpoint_state(&out_point).await?) } }) .into() } @@ -484,24 +580,31 @@ impl PlatformClient { label: &str, identity: &[u8; 32], key_id: u32, - contest_fund: u64, + policy: &ffi::ContestPolicy, signer: cxx::UniquePtr, ) -> ffi::IdResult { self.call("register_dpns_name", |sdk| { let label = label.to_string(); - let identity = Identifier::from(*identity); + let owner = Identifier::from(*identity); + let policy = *policy; async move { let signer = WalletSigner::new(signer)?; - let (identity, key) = identity_and_key(&sdk, identity, key_id).await?; + let policy = contest_policy(policy)?; + // A label refused by its spelling is refused before the identity is read. + policy.check_label(&label)?; + let (identity, key) = identity_and_key(&sdk, owner, key_id).await?; let registered = sdk - .register_dpns_name(RegisterDpnsNameInput { - label, - identity, - identity_public_key: key, - signer, - preorder_callback: None, - contest_fund: (contest_fund != 0).then_some(contest_fund), - }) + .register_dpns_name_under_policy( + RegisterDpnsNameInput { + label, + identity, + identity_public_key: key, + signer, + preorder_callback: None, + contest_fund: None, + }, + policy, + ) .await?; Ok(registered.domain_document.id().to_buffer()) } @@ -528,7 +631,7 @@ impl PlatformClient { let recipient = input.recipient; let ecdh = EcdhProvider::::ClientSide { get_shared_secret: move |recipient_key: &PublicKey| { - let mut secret = [0u8; 32]; + let mut secret = Zeroizing::new([0u8; 32]); let answered = blocking(|| { ecdh_keys.SharedSecret( sender_key, @@ -536,7 +639,7 @@ impl PlatformClient { &mut secret, ) }); - ready(answered.then_some(secret).ok_or_else(|| { + ready(answered.then(|| *secret).ok_or_else(|| { Error::Generic("the wallet refused the ECDH".to_string()) })) }, @@ -622,6 +725,53 @@ async fn identity_and_key( Ok((identity, key)) } +/// What the embedder does next with the lock, as the bridge numbers it. +fn funding_outcome(kind: FundingFailureKind) -> ffi::FundingOutcome { + match kind { + FundingFailureKind::NeedsChainLockProof => ffi::FundingOutcome::NeedsChainLockProof, + FundingFailureKind::PlatformBehindCore => ffi::FundingOutcome::PlatformBehindCore, + FundingFailureKind::FeeCapReached => ffi::FundingOutcome::FeeCapReached, + FundingFailureKind::ReportedConsumed => ffi::FundingOutcome::ReportedConsumed, + FundingFailureKind::Rejected => ffi::FundingOutcome::Rejected, + FundingFailureKind::Submitted => ffi::FundingOutcome::Submitted, + _ => ffi::FundingOutcome::Unavailable, + } +} + +/// A funding call's result and report. A call that failed before any SDK +/// error (bad input, shutdown) settles nothing. `built` holds the highest +/// fee an attempt started with. +fn funding_report( + call: Result, Status>, + floor: u16, + built: &Mutex>, +) -> (Result, ffi::FundingReport) { + let (result, outcome) = match call { + Ok(Ok(value)) => (Ok(value), ffi::FundingOutcome::Ok), + Ok(Err(failure)) => (Err(classify(&failure.error)), funding_outcome(failure.kind)), + Err(status) => (Err(status), ffi::FundingOutcome::Unavailable), + }; + let last_user_fee_increase = lock(built).unwrap_or(floor.saturating_sub(1)); + ( + result, + ffi::FundingReport { + outcome, + last_user_fee_increase, + }, + ) +} + +fn contest_policy(policy: ffi::ContestPolicy) -> Result { + match policy.kind { + ffi::ContestPolicyKind::Refuse => Ok(DpnsContestPolicy::Refuse), + ffi::ContestPolicyKind::Cap => Ok(DpnsContestPolicy::Cap(policy.cap)), + other => Err(Status::internal(format!( + "unknown contest policy {}", + other.repr + ))), + } +} + /// The embedder's proxy setting. Anything malformed is an error, so a /// client never connects directly when a proxy was meant. fn proxy(cfg: &ffi::Proxy) -> Result, String> { @@ -672,12 +822,145 @@ fn endpoint(uri: &str, proxied: bool) -> Result { } } +#[cfg(test)] +mod top_up_tests; + #[cfg(test)] mod tests { use super::*; - use dash_sdk::dapi_client::DapiClientError; + use dash_sdk::dapi_client::{CanRetry, DapiClientError}; use dash_sdk::dpp::consensus::ConsensusError; + use dash_sdk::dpp::dashcore::{OutPoint, Txid}; use dash_sdk::error::{ContextProviderError, StateTransitionBroadcastError}; + use dash_sdk::ProofVerifierError; + + /// What the provider's answer becomes once a proof names the quorum: a + /// gap in the embedder's data is Unavailable and bans no endpoint; a + /// quorum it knows invalid is the proof's fault, and its node's. + #[test] + fn should_ban_an_endpoint_only_for_an_invalid_quorum() { + let verified = |error| { + Error::from(ProofVerifierError::QuorumKeyUnavailable { + quorum_type: 106, + quorum_hash: [1; 32], + core_chain_locked_height: 9, + error, + }) + }; + let gap = verified(ContextProviderError::QuorumSourceUnavailable("x".into())); + assert_eq!(classify(&gap).kind, StatusKind::Unavailable); + assert!( + !gap.can_retry(), + "an endpoint is banned for the embedder's gap" + ); + let invalid = verified(ContextProviderError::InvalidQuorum("x".into())); + assert_eq!(classify(&invalid).kind, StatusKind::Rejected); + assert!(invalid.can_retry()); + } + + /// Each way a funding call can leave its lock unsettled keeps its own + /// bridge value, and a kind the bridge does not know is Unavailable. + #[test] + fn should_map_each_funding_failure_to_its_bridge_outcome() { + for (kind, outcome) in [ + ( + FundingFailureKind::NeedsChainLockProof, + ffi::FundingOutcome::NeedsChainLockProof, + ), + ( + FundingFailureKind::PlatformBehindCore, + ffi::FundingOutcome::PlatformBehindCore, + ), + ( + FundingFailureKind::FeeCapReached, + ffi::FundingOutcome::FeeCapReached, + ), + ( + FundingFailureKind::ReportedConsumed, + ffi::FundingOutcome::ReportedConsumed, + ), + (FundingFailureKind::Rejected, ffi::FundingOutcome::Rejected), + ( + FundingFailureKind::Unavailable, + ffi::FundingOutcome::Unavailable, + ), + ( + FundingFailureKind::Submitted, + ffi::FundingOutcome::Submitted, + ), + ] { + assert_eq!(funding_outcome(kind), outcome, "{kind:?}"); + } + } + + /// A call that never built a transition reports one below its floor, so + /// the next floor is the same value; one cut short reports its attempts. + /// A failure keeps its kind, and its error's status. + #[test] + fn should_report_what_a_failed_funding_call_built() { + let built = Mutex::new(None); + let (result, report) = funding_report::(Err(Status::internal("bad proof")), 4, &built); + assert_eq!(result.unwrap_err().kind, StatusKind::Internal); + assert_eq!( + (report.outcome, report.last_user_fee_increase), + (ffi::FundingOutcome::Unavailable, 3) + ); + assert_eq!( + funding_report::(Err(Status::internal("x")), 0, &built) + .1 + .last_user_fee_increase, + 0 + ); + *lock(&built) = Some(6); + let shut_down = Status::new(StatusKind::Unavailable, "client is shut down"); + let (_, report) = funding_report::(Err(shut_down), 4, &built); + assert_eq!( + (report.outcome, report.last_user_fee_increase), + (ffi::FundingOutcome::Unavailable, 6) + ); + + let capped = FundingFailure { + kind: FundingFailureKind::FeeCapReached, + error: Error::FeeIncreaseFloorAboveCap { + floor: 101, + cap: 100, + }, + }; + let (result, report) = funding_report::(Ok(Err(capped)), 101, &Mutex::new(None)); + let status = result.unwrap_err(); + assert_eq!(status.kind, StatusKind::Unavailable); + assert!( + status.message.contains("nothing was submitted"), + "{status:?}" + ); + assert_eq!( + (report.outcome, report.last_user_fee_increase), + (ffi::FundingOutcome::FeeCapReached, 100) + ); + } + + /// The bridge's policy is the SDK's, with the cap as given; any other + /// kind is refused before anything is read. + #[test] + fn should_map_each_contest_policy_to_the_sdks() { + let policy = |kind, cap| contest_policy(ffi::ContestPolicy { kind, cap }); + assert_eq!( + policy(ffi::ContestPolicyKind::Refuse, 7).unwrap(), + DpnsContestPolicy::Refuse + ); + for cap in [0, 5_000_000] { + assert_eq!( + policy(ffi::ContestPolicyKind::Cap, cap).unwrap(), + DpnsContestPolicy::Cap(cap) + ); + } + assert_eq!( + policy(ffi::ContestPolicyKind { repr: 9 }, 0) + .unwrap_err() + .kind, + StatusKind::Internal + ); + } /// Only a proof yields `Ok` or `NotFound`; consensus refusals keep /// their code; nothing else may read as either. @@ -700,6 +983,7 @@ mod tests { ); let quorum = Error::ContextProviderError; + let out_point = OutPoint::new(Txid::from_byte_array([4; 32]), 1); for (error, kind) in [ ( Error::DapiClientError(DapiClientError::NoAvailableAddresses), @@ -724,9 +1008,46 @@ mod tests { StatusKind::Rejected, ), (Error::Generic("x".into()), StatusKind::Internal), + ( + Error::DpnsNameContested { + label: "Alice".into(), + index: None, + }, + StatusKind::Contested, + ), + ( + Error::FeeIncreaseFloorAboveCap { + floor: 101, + cap: 100, + }, + StatusKind::Unavailable, + ), + ( + Error::AssetLockNotUsedUp { + out_point, + record: UnsettledLockRecord::NotPresent, + }, + StatusKind::Unavailable, + ), ] { assert_eq!(classify(&error).kind, kind, "{error}"); } + + // A top-up whose lock record could not be read keeps the read's + // status under the top-up's message. + let unread = classify(&Error::AssetLockNotUsedUp { + out_point, + record: UnsettledLockRecord::Unread(Box::new(Error::Protocol( + ProtocolError::ConsensusError(Box::new(ConsensusError::DefaultError)), + ))), + }); + assert_eq!(unread.kind, StatusKind::Consensus); + assert!( + unread + .message + .contains("is not proved used up: its record could not be read"), + "{unread:?}" + ); } /// Tor stream isolation; the C++ smoke covers the refusals. diff --git a/packages/rs-sdk-cxx/src/client/top_up_tests.rs b/packages/rs-sdk-cxx/src/client/top_up_tests.rs new file mode 100644 index 00000000000..51c42f98e89 --- /dev/null +++ b/packages/rs-sdk-cxx/src/client/top_up_tests.rs @@ -0,0 +1,564 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! A top-up's funding outcome when Platform answers with Drive's own +//! proofs, signed as a quorum signs them, through the mock SDK's real +//! verification: the wait verifies a snapshot of the identity's balance +//! whether or not the top-up executed, so only the lock's proved record may +//! settle it. + +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use dash_sdk::dapi_client::transport::TransportRequest; +use dash_sdk::dapi_client::{DumpData, ExecutionResponse}; +use dash_sdk::dapi_grpc::mock::Mockable; +use dash_sdk::dapi_grpc::platform::v0::{ + get_path_elements_request, get_path_elements_response, + wait_for_state_transition_result_response, BroadcastStateTransitionResponse, + GetPathElementsRequest, GetPathElementsResponse, Proof, ResponseMetadata, + WaitForStateTransitionResultResponse, +}; +use dash_sdk::dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; +use dash_sdk::dpp::block::block_info::BlockInfo; +use dash_sdk::dpp::block::epoch::Epoch; +use dash_sdk::dpp::bls; +use dash_sdk::dpp::dashcore::ecdsa::{EcdsaPublicKey, EcdsaSignature}; +use dash_sdk::dpp::dashcore::{OutPoint, Txid}; +use dash_sdk::dpp::data_contract::TokenConfiguration; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dash_sdk::dpp::key_wallet::signer::{Signer as KeyWalletSigner, SignerMethod}; +use dash_sdk::dpp::platform_value::{Bytes32, Bytes36}; +use dash_sdk::dpp::prelude::{AssetLockProof, CoreBlockHeight, DataContract}; +use dash_sdk::dpp::secp256k1::ecdsa::RecoverableSignature; +use dash_sdk::dpp::secp256k1::Message; +use dash_sdk::dpp::state_transition::identity_topup_transition::methods::IdentityTopUpTransitionMethodsV0; +use dash_sdk::dpp::state_transition::identity_topup_transition::IdentityTopUpTransition; +use dash_sdk::dpp::state_transition::signable_bytes_hasher::SignableBytesHasher; +use dash_sdk::dpp::state_transition::StateTransition; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::error::ContextProviderError; +use dash_sdk::platform::transition::broadcast_request::BroadcastRequestForStateTransition; +use dash_sdk::platform::ContextProvider; +use drive::drive::{Drive, RootTree}; +use drive::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; +use drive::state_transition_action::identity::identity_topup::v0::IdentityTopUpTransitionActionV0; +use drive::state_transition_action::identity::identity_topup::IdentityTopUpTransitionAction; +use drive::util::batch::{DriveOperation, SystemOperationType}; +use drive::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use tenderdash_abci::proto::types::{CanonicalVote, SignedMsgType, StateId}; +use tenderdash_abci::signatures::{Hashable, Signable}; + +use super::*; + +/// The quorum whose signature every answer carries. +fn quorum_key() -> bls::SecretKey { + bls::SecretKey::from_be_bytes(&[0x11; 32]).expect("a BLS secret key") +} + +/// Serves the test quorum's key for every quorum. +struct TestQuorum([u8; 48]); + +impl ContextProvider for TestQuorum { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok(None) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + Ok(self.0) + } + + fn get_platform_activation_height(&self) -> Result { + Ok(1) + } +} + +/// The asset lock's key, held in-process. +struct LockKey(SecretKey); + +#[async_trait] +impl KeyWalletSigner for LockKey { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + _path: &DerivationPath, + sighash: [u8; 32], + ) -> Result<(EcdsaSignature, EcdsaPublicKey), String> { + let signature = + RecoverableSignature::sign_ecdsa_recoverable(Message::from_digest(sighash), &self.0); + Ok(( + signature.to_standard().into(), + PublicKey::from_secret_key(&self.0).into(), + )) + } + + async fn public_key(&self, _path: &DerivationPath) -> Result { + Ok(PublicKey::from_secret_key(&self.0).into()) + } +} + +fn lock_key() -> LockKey { + LockKey(SecretKey::from_secret_bytes([0x42; 32]).expect("a secp256k1 secret key")) +} + +/// A directory of mock DAPI answers, removed when dropped. +struct Answers(PathBuf); + +impl Answers { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "dash-sdk-cxx-top-up-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&dir).expect("an answers directory"); + Answers(dir) + } + + fn answer(&self, request: &R, response: R::Response) + where + R: TransportRequest + Mockable, + R::Response: Mockable, + { + let answered = Ok(ExecutionResponse { + inner: response, + retries: 0, + address: "https://127.0.0.1:1".parse().expect("an address"), + }); + let dump = DumpData::new(request, &answered); + dump.save(&self.0.join(dump.filename().expect("a file name"))) + .expect("the answer is saved"); + } +} + +impl Drop for Answers { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +/// `grovedb_proof` of Drive state `root`, at `metadata`, signed by `key` as a +/// quorum signs a precommit. +fn signed( + key: &bls::SecretKey, + metadata: &ResponseMetadata, + root: [u8; 32], + grovedb_proof: Vec, +) -> Proof { + let mut proof = Proof { + grovedb_proof, + quorum_hash: vec![2; 32], + signature: vec![], + round: 1, + block_id_hash: vec![3; 32], + quorum_type: 106, + }; + let state_id = StateId { + app_version: metadata.protocol_version as u64, + core_chain_locked_height: metadata.core_chain_locked_height, + time: metadata.time_ms, + app_hash: root.to_vec(), + height: metadata.height, + }; + let vote = CanonicalVote { + r#type: SignedMsgType::Precommit.into(), + block_id: proof.block_id_hash.clone(), + chain_id: metadata.chain_id.clone(), + height: metadata.height as i64, + round: proof.round as i64, + state_id: state_id + .calculate_msg_hash( + &metadata.chain_id, + metadata.height as i64, + proof.round as i32, + ) + .expect("the signed state hashes"), + }; + let digest = vote + .calculate_sign_hash( + &metadata.chain_id, + proof.quorum_type as u8, + &[2; 32], + metadata.height as i64, + proof.round as i32, + ) + .expect("the vote hashes"); + proof.signature = key.sign(&digest).expect("signs").to_bytes().to_vec(); + proof +} + +fn record_query(out_point: &OutPoint) -> (Vec>, Vec>) { + ( + vec![vec![RootTree::SpentAssetLockTransactions as u8]], + vec![<[u8; 36]>::from(*out_point).to_vec()], + ) +} + +/// What the SDK's top-up returns, what the binding reports for the same +/// top-up, and what `asset_lock_state` reads, when Platform accepts the +/// broadcast and answers the wait and the lock's record from `drive` as it +/// stands. +struct Answered { + sdk_top_up: Result, + binding: (Result, ffi::FundingReport), + state: ffi::AssetLockStateResult, +} + +fn top_up_answered_by(drive: &Drive, identity: Identifier, proof: &AssetLockProof) -> Answered { + top_up_answered_with_record(drive, identity, proof, |_| ()) +} + +/// As [`top_up_answered_by`], with the signed proof of the lock's record +/// passed through `tamper` first. +fn top_up_answered_with_record( + drive: &Drive, + identity: Identifier, + proof: &AssetLockProof, + tamper: fn(&mut Proof), +) -> Answered { + let version = PlatformVersion::latest(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("a runtime"); + let topped_up = bare_identity(identity, BTreeMap::new()); + let path = DerivationPath::master(); + let transition: StateTransition = runtime + .block_on(IdentityTopUpTransition::try_from_identity_with_signer( + &topped_up, + proof.clone(), + &path, + &lock_key(), + 0, + version, + None, + )) + .expect("the top-up the SDK builds"); + + let metadata = ResponseMetadata { + height: 10, + core_chain_locked_height: 100, + epoch: 0, + time_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("after 1970") + .as_millis() as u64, + protocol_version: version.protocol_version, + chain_id: "dash-sdk-cxx-test".to_string(), + }; + let root = drive + .grove + .root_hash(None, &version.drive.grove_version) + .unwrap() + .expect("a root hash"); + let key = quorum_key(); + let answers = Answers::new(); + answers.answer( + &transition + .broadcast_request_for_state_transition() + .expect("a broadcast"), + BroadcastStateTransitionResponse {}, + ); + let wait_proof = drive + .prove_state_transition(&transition, None, version) + .expect("Drive proves the top-up's result") + .into_data() + .expect("a proof"); + answers.answer( + &transition + .wait_for_state_transition_result_request() + .expect("a wait"), + WaitForStateTransitionResultResponse { + version: Some(wait_for_state_transition_result_response::Version::V0( + wait_for_state_transition_result_response::WaitForStateTransitionResultResponseV0 { + result: Some(wait_for_state_transition_result_response::wait_for_state_transition_result_response_v0::Result::Proof( + signed(&key, &metadata, root, wait_proof), + )), + metadata: Some(metadata.clone()), + }, + )), + }, + ); + let out_point = proof.out_point().expect("an outpoint"); + let (record_path, record_keys) = record_query(&out_point); + let record_proof = drive + .prove_elements(record_path.clone(), record_keys.clone(), None, version) + .expect("Drive proves the lock's record"); + let mut record_proof = signed(&key, &metadata, root, record_proof); + tamper(&mut record_proof); + answers.answer( + &GetPathElementsRequest { + version: Some(get_path_elements_request::Version::V0( + get_path_elements_request::GetPathElementsRequestV0 { + path: record_path, + keys: record_keys, + prove: true, + }, + )), + }, + GetPathElementsResponse { + version: Some(get_path_elements_response::Version::V0( + get_path_elements_response::GetPathElementsResponseV0 { + result: Some( + get_path_elements_response::get_path_elements_response_v0::Result::Proof( + record_proof, + ), + ), + metadata: Some(metadata), + }, + )), + }, + ); + + let mut public = [0u8; 48]; + public.copy_from_slice(&key.public_key().to_bytes()); + let mut sdk = SdkBuilder::new_mock() + .with_version(version) + .with_context_provider(TestQuorum(public)) + .build() + .expect("a mock SDK"); + sdk.mock() + .load_expectations_sync(&answers.0) + .expect("the answers load"); + + runtime.block_on(async { + let sdk_top_up = topped_up + .top_up_identity_with_signer(&sdk, proof.clone(), &path, &lock_key(), None) + .await; + let built = Mutex::new(None); + let call = bare_identity(identity, BTreeMap::new()) + .top_up_identity_settled(&sdk, proof.clone(), &path, &lock_key(), 0, |fee| { + *lock(&built) = Some(fee) + }) + .await; + let state = sdk + .asset_lock_outpoint_state(&out_point) + .await + .map_err(Status::from) + .into(); + Answered { + sdk_top_up, + binding: funding_report(Ok(call), 0, &built), + state, + } + }) +} + +/// The SDK accepts a valid snapshot of the identity's unchanged balance for +/// a top-up that never executed; the binding settles the lock only once +/// Drive's record of it, proved, shows it used up. +#[test] +fn should_settle_a_top_up_only_when_its_lock_is_proved_used_up() { + let version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let identity = Identity::random_identity(1, Some(7), version).expect("an identity"); + let before = identity.balance(); + drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .expect("the identity is stored"); + let out_point = OutPoint::new(Txid::from_byte_array([4; 32]), 1); + let proof = AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 100, + out_point, + }); + let record = Bytes36::new(out_point.into()); + let lock_value = |remaining| { + AssetLockValue::new(2_000, vec![0x76], remaining, vec![], version).expect("a lock value") + }; + let apply = |operations| { + drive + .apply_drive_operations(operations, true, &BlockInfo::default(), None, version, None) + .expect("Drive applies the operations"); + }; + + // Not executed: the wait's proof is a genuine, unchanged snapshot. + let answered = top_up_answered_by(&drive, identity.id(), &proof); + assert_eq!(answered.sdk_top_up.expect("the SDK verifies it"), before); + let (result, report) = answered.binding; + let status = result.expect_err("an unchanged snapshot settles nothing"); + assert_eq!(status.kind, StatusKind::Unavailable); + assert!( + status.message.contains("no record of its use"), + "{status:?}" + ); + assert_eq!(report.outcome, ffi::FundingOutcome::Submitted); + assert_eq!(report.last_user_fee_increase, 0); + assert_eq!(answered.state.status.kind, StatusKind::Ok); + assert_eq!(answered.state.state, ffi::AssetLockState::NotPresent); + + // A failed transition used part of the lock: still not settled. + apply(vec![DriveOperation::SystemOperation( + SystemOperationType::AddUsedAssetLock { + asset_lock_outpoint: record, + asset_lock_value: lock_value(1_500), + }, + )]); + let answered = top_up_answered_by(&drive, identity.id(), &proof); + let (result, report) = answered.binding; + assert!(result + .unwrap_err() + .message + .contains("1500 of its credits are unspent")); + assert_eq!(report.outcome, ffi::FundingOutcome::Submitted); + assert_eq!( + (answered.state.state, answered.state.remaining_credits), + (ffi::AssetLockState::PartiallyConsumed, 1_500) + ); + + // Executed: Drive's own top-up operations credit the identity and use + // the lock up. + let executed = IdentityTopUpTransitionAction::V0(IdentityTopUpTransitionActionV0 { + signable_bytes_hasher: SignableBytesHasher::PreHashed(Bytes32([9; 32])), + top_up_asset_lock_value: lock_value(1_500), + identity_id: identity.id(), + asset_lock_outpoint: record, + user_fee_increase: 0, + }); + apply( + executed + .into_high_level_drive_operations(&Epoch::new(0).expect("an epoch"), version) + .expect("the top-up's operations"), + ); + let answered = top_up_answered_by(&drive, identity.id(), &proof); + let (result, report) = answered.binding; + assert_eq!(result.expect("settled"), before + 1_500); + assert_eq!(report.outcome, ffi::FundingOutcome::Ok); + assert_eq!(answered.state.state, ffi::AssetLockState::FullyConsumed); +} + +/// A record whose proof fails verification settles nothing, and the top-up +/// says it failed verification, as `asset_lock_state` does, though the lock +/// is used up. +#[test] +fn should_keep_a_failed_record_proof_rejected_and_the_top_up_submitted() { + let version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let identity = Identity::random_identity(1, Some(7), version).expect("an identity"); + drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .expect("the identity is stored"); + let out_point = OutPoint::new(Txid::from_byte_array([4; 32]), 1); + let proof = AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 100, + out_point, + }); + drive + .apply_drive_operations( + vec![DriveOperation::SystemOperation( + SystemOperationType::AddUsedAssetLock { + asset_lock_outpoint: Bytes36::new(out_point.into()), + asset_lock_value: AssetLockValue::new(2_000, vec![0x76], 0, vec![], version) + .expect("a lock value"), + }, + )], + true, + &BlockInfo::default(), + None, + version, + None, + ) + .expect("Drive applies the operation"); + assert_eq!( + top_up_answered_by(&drive, identity.id(), &proof) + .state + .state, + ffi::AssetLockState::FullyConsumed + ); + + let answered = top_up_answered_with_record(&drive, identity.id(), &proof, |record| { + record.signature[0] ^= 1 + }); + assert_eq!(answered.state.status.kind, StatusKind::Rejected); + let result: ffi::TopUpIdentityResult = answered.binding.into(); + assert_eq!( + (result.status.kind, result.funding.outcome, result.balance), + (StatusKind::Rejected, ffi::FundingOutcome::Submitted, 0) + ); + assert!( + result.status.message.contains("could not be read"), + "{:?}", + result.status + ); +} + +/// A lock record that could not be read settles nothing either, and keeps +/// its read's status. +#[test] +fn should_leave_a_top_up_submitted_when_its_lock_record_is_unread() { + let out_point = OutPoint::new(Txid::from_byte_array([4; 32]), 1); + for (error, kind) in [ + ( + Error::TimeoutReached(std::time::Duration::from_secs(1), "down".into()), + StatusKind::Unavailable, + ), + ( + Error::InvalidProvedResponse("not a record".into()), + StatusKind::Rejected, + ), + ] { + let unread = FundingFailure { + kind: FundingFailureKind::Submitted, + error: Error::AssetLockNotUsedUp { + out_point, + record: UnsettledLockRecord::Unread(Box::new(error)), + }, + }; + let result: ffi::TopUpIdentityResult = + funding_report::(Ok(Err(unread)), 0, &Mutex::new(Some(0))).into(); + assert_eq!( + (result.status.kind, result.funding.outcome, result.balance), + (kind, ffi::FundingOutcome::Submitted, 0) + ); + assert!( + result.status.message.contains("could not be read"), + "{:?}", + result.status + ); + } + let failed: ffi::AssetLockStateResult = + Err::(Status::new(StatusKind::Rejected, "bad proof")).into(); + assert_eq!( + (failed.status.kind, failed.state), + (StatusKind::Rejected, ffi::AssetLockState::NotPresent) + ); +} diff --git a/packages/rs-sdk-cxx/src/context.rs b/packages/rs-sdk-cxx/src/context.rs index 9831606c481..179148e9121 100644 --- a/packages/rs-sdk-cxx/src/context.rs +++ b/packages/rs-sdk-cxx/src/context.rs @@ -24,18 +24,29 @@ use crate::runtime::blocking; unsafe impl Send for ffi::CoreContext {} unsafe impl Sync for ffi::CoreContext {} +/// What a [`QuorumSource`] knows of the quorum a proof names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QuorumAnswer { + /// The quorum could have signed; its BLS public key. + Found([u8; 48]), + /// The source's complete evidence shows it could not have signed. + Invalid, + /// The source cannot decide now: no fault of the proof. + Unavailable, +} + /// Where quorum keys come from: the embedder's `CoreContext`, or a fake in -/// tests. Both answer `None` for anything they do not hold. +/// tests. pub trait QuorumSource: Send + Sync { - fn quorum_public_key(&self, llmq_type: u8, hash: &[u8; 32], height: u32) -> Option<[u8; 48]>; + fn quorum_public_key(&self, llmq_type: u8, hash: &[u8; 32], height: u32) -> QuorumAnswer; fn platform_activation_height(&self) -> Option; } impl QuorumSource for cxx::UniquePtr { - fn quorum_public_key(&self, llmq_type: u8, hash: &[u8; 32], height: u32) -> Option<[u8; 48]> { + fn quorum_public_key(&self, llmq_type: u8, hash: &[u8; 32], height: u32) -> QuorumAnswer { let mut key = [0u8; 48]; - self.QuorumPublicKey(llmq_type, hash, height, &mut key) - .then_some(key) + let lookup = self.QuorumPublicKey(llmq_type, hash, height, &mut key); + answer(lookup, key) } fn platform_activation_height(&self) -> Option { @@ -44,6 +55,16 @@ impl QuorumSource for cxx::UniquePtr { } } +/// The embedder's lookup: a value outside the enum is its fault, never the +/// proof's. +fn answer(lookup: ffi::QuorumLookup, key: [u8; 48]) -> QuorumAnswer { + match lookup { + ffi::QuorumLookup::Found => QuorumAnswer::Found(key), + ffi::QuorumLookup::Invalid => QuorumAnswer::Invalid, + _ => QuorumAnswer::Unavailable, + } +} + pub struct CoreContextProvider { pub source: S, pub platform_llmq_type: u8, @@ -75,7 +96,10 @@ impl ContextProvider for CoreContextProvider { /// `quorum_hash` arrives in proof byte order, the reverse of the /// embedder's internal `uint256` order. The type is range-checked before - /// it is narrowed and must be the configured Platform type. + /// it is narrowed and must be the configured Platform type. The signed + /// height goes to the embedder unchanged. `Unavailable` is the source's + /// gap (`QuorumSourceUnavailable`, which bans no endpoint); only + /// `Invalid` indicts the proof. fn get_quorum_public_key( &self, quorum_type: u32, @@ -89,18 +113,26 @@ impl ContextProvider for CoreContextProvider { ))); } let internal_order = reversed(&quorum_hash); - blocking(|| { + let answer = blocking(|| { self.source.quorum_public_key( self.platform_llmq_type, &internal_order, core_chain_locked_height, ) - }) - .ok_or_else(|| { - ContextProviderError::InvalidQuorum( - "no local Platform quorum with that hash".to_string(), - ) - }) + }); + match answer { + QuorumAnswer::Found(key) => Ok(key), + QuorumAnswer::Invalid => Err(ContextProviderError::InvalidQuorum(format!( + "no local Platform quorum with that hash could sign at Core height \ + {core_chain_locked_height}" + ))), + QuorumAnswer::Unavailable => { + Err(ContextProviderError::QuorumSourceUnavailable(format!( + "the local quorum data cannot decide this quorum at Core height \ + {core_chain_locked_height} yet" + ))) + } + } } /// A refusal is the local source's gap, not a bad proof. (Its only SDK @@ -121,22 +153,24 @@ mod tests { const PLATFORM_LLMQ: u8 = 106; - /// Holds one key, under its hash in the embedder's byte order, and - /// records every lookup it is asked for. - struct OneQuorum { - hash: [u8; 32], + /// Finds one quorum and knows another invalid, under their hashes in the + /// embedder's byte order; cannot decide any other. Records every lookup. + struct Scripted { + found: [u8; 32], + invalid: [u8; 32], asked: Mutex>, } - impl QuorumSource for OneQuorum { - fn quorum_public_key( - &self, - llmq_type: u8, - hash: &[u8; 32], - height: u32, - ) -> Option<[u8; 48]> { + impl QuorumSource for Scripted { + fn quorum_public_key(&self, llmq_type: u8, hash: &[u8; 32], height: u32) -> QuorumAnswer { self.asked.lock().unwrap().push((llmq_type, *hash, height)); - (*hash == self.hash).then_some([7; 48]) + if *hash == self.found { + QuorumAnswer::Found([7; 48]) + } else if *hash == self.invalid { + QuorumAnswer::Invalid + } else { + QuorumAnswer::Unavailable + } } fn platform_activation_height(&self) -> Option { @@ -144,60 +178,96 @@ mod tests { } } - fn provider(hash: [u8; 32]) -> CoreContextProvider { + fn hash(first: u8) -> [u8; 32] { + let mut hash = [0u8; 32]; + hash[0] = first; + hash + } + + fn provider() -> CoreContextProvider { CoreContextProvider { - source: OneQuorum { - hash, + source: Scripted { + found: hash(0xaa), + invalid: hash(0xbb), asked: Mutex::new(Vec::new()), }, platform_llmq_type: PLATFORM_LLMQ, } } + /// Found gives the key, Invalid indicts the proof, Unavailable is the + /// source's gap. The hash is reversed once and the signed height passed + /// as is. #[test] - fn quorum_lookups_check_the_type_and_pass_the_hash_in_core_order() { - let mut core_order = [0u8; 32]; - core_order[0] = 0xaa; - let provider = provider(core_order); - let mut proof_order = core_order; - proof_order.reverse(); + fn should_map_each_quorum_answer_to_its_own_result() { + let provider = provider(); let platform = u32::from(PLATFORM_LLMQ); + let signed_height = 4_000_001; assert_eq!( provider - .get_quorum_public_key(platform, proof_order, 9) + .get_quorum_public_key(platform, reversed(&hash(0xaa)), signed_height) .unwrap(), [7; 48] ); - // Unknown to the embedder: refused, with no fallback. assert!(matches!( - provider.get_quorum_public_key(platform, core_order, 9), + provider.get_quorum_public_key(platform, reversed(&hash(0xbb)), signed_height), Err(ContextProviderError::InvalidQuorum(_)) )); - // Another type, including one that would wrap to the Platform type - // when narrowed to u8, never reaches the embedder. - for other in [platform + 1, platform + 256, u32::MAX] { - assert!(matches!( - provider.get_quorum_public_key(other, proof_order, 9), - Err(ContextProviderError::InvalidQuorum(_)) - )); - } + assert!(matches!( + provider.get_quorum_public_key(platform, reversed(&hash(0xcc)), signed_height), + Err(ContextProviderError::QuorumSourceUnavailable(_)) + )); assert_eq!( *provider.source.asked.lock().unwrap(), vec![ - (PLATFORM_LLMQ, core_order, 9), - (PLATFORM_LLMQ, proof_order, 9) + (PLATFORM_LLMQ, hash(0xaa), signed_height), + (PLATFORM_LLMQ, hash(0xbb), signed_height), + (PLATFORM_LLMQ, hash(0xcc), signed_height), ] ); + } + + #[test] + fn should_never_ask_the_embedder_about_another_quorum_type() { + let provider = provider(); + let platform = u32::from(PLATFORM_LLMQ); + // Including one that would wrap to the Platform type when narrowed. + for other in [platform + 1, platform + 256, u32::MAX] { + assert!(matches!( + provider.get_quorum_public_key(other, reversed(&hash(0xaa)), 9), + Err(ContextProviderError::InvalidQuorum(_)) + )); + } + assert!(provider.source.asked.lock().unwrap().is_empty()); assert!(matches!( provider.get_platform_activation_height(), Err(ContextProviderError::QuorumSourceUnavailable(_)) )); } + /// A lookup value the enum does not name is the embedder's fault. + #[test] + fn should_read_an_unknown_embedder_lookup_as_unavailable() { + assert_eq!( + answer(ffi::QuorumLookup::Found, [3; 48]), + QuorumAnswer::Found([3; 48]) + ); + assert_eq!( + answer(ffi::QuorumLookup::Invalid, [3; 48]), + QuorumAnswer::Invalid + ); + for repr in [2, 3, 0xff] { + assert_eq!( + answer(ffi::QuorumLookup { repr }, [3; 48]), + QuorumAnswer::Unavailable + ); + } + } + #[test] fn system_contracts_are_served_locally() { - let provider = provider([0; 32]); + let provider = provider(); let version = PlatformVersion::latest(); for contract in [SystemDataContract::DPNS, SystemDataContract::Dashpay] { assert!(provider diff --git a/packages/rs-sdk-cxx/src/lib.rs b/packages/rs-sdk-cxx/src/lib.rs index 394b71f933c..f311609a264 100644 --- a/packages/rs-sdk-cxx/src/lib.rs +++ b/packages/rs-sdk-cxx/src/lib.rs @@ -82,6 +82,9 @@ pub mod ffi { Consensus = 4, /// Bad input from the embedder, a refused callback, or a bug. Internal = 5, + /// `register_dpns_name` under `ContestPolicyKind::Refuse`: the label + /// would join a contest, so nothing was sent or charged. + Contested = 6, } #[derive(Debug, Clone)] @@ -123,10 +126,126 @@ pub mod ffi { id: [u8; 32], } + /// What the embedder does next with the asset lock an identity funding + /// call submitted. Nothing here proves the lock unconsumed, and only + /// `Ok` proves it used up: the embedder keeps the lock until a proof + /// settles it, and never assumes a call it cancelled sent nothing. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum FundingOutcome { + /// Settled, proved. A registration: the identity the lock creates + /// exists. A top-up: Platform's record of the lock shows it used up + /// (see `asset_lock_state`). That record names no transition, so it + /// does not prove this identity was credited: another use of the + /// lock's key, or failed transitions, can use a lock up too. + Ok = 0, + /// Platform could not use the InstantSend proof (a stale islock, or + /// one that made the transition too large): submit a ChainLock proof + /// for the same outpoint once the lock is ChainLocked. + NeedsChainLockProof = 1, + /// Platform's view of Core stayed below the ChainLock proof's height + /// (consensus 10506) through every attempt the SDK's retry budget + /// allowed, all below the fee cap. Retry later with the next floor. + PlatformBehindCore = 2, + /// A node reported this exact outpoint already consumed. Not + /// authenticated: check with `asset_lock_state`, or for a + /// registration with `get_identity` of the id + /// `identity_id_from_asset_lock_outpoint` derives. + ReportedConsumed = 3, + /// Platform refused the transition with another consensus error, + /// raw 10504 included. + Rejected = 4, + /// Nothing settled: a transport failure, a timeout, a response that + /// failed verification, a refused callback, bad input or shutdown. + /// A transition that was sent may still execute. + Unavailable = 5, + /// A top-up whose wait returned a verified snapshot of the + /// identity's balance, while the lock is not proved used up (no + /// record of it yet, credits left in it, or the record could not be + /// read). The snapshot binds neither this top-up nor this lock: + /// settle it with `asset_lock_state`. The status is `Unavailable`, + /// or the failed read's own (`Rejected` when its proof failed + /// verification). + Submitted = 6, + /// No fee step is left for this proof: the floor is above 100, so + /// nothing was submitted, or the attempt at 100 still met 10506. + /// Tenderdash keeps the hash of every transition its CheckTx + /// refused, with no time limit, until its cache evicts it or the + /// node restarts, and a node that holds it never admits those bytes + /// again: do not resend this proof at any step. Send a ChainLock + /// proof for the same outpoint at a height it has not carried (the + /// lock's mined height if no ChainLock proof was sent for it, + /// otherwise one above the last; at most the embedder's best + /// ChainLock), from floor 0. The lock is not settled. + FeeCapReached = 7, + } + + /// `last_user_fee_increase`: the highest `user_fee_increase` an attempt + /// of this call started with, or one below the floor (saturating at 0) + /// when it started none. Every attempt may have reached Platform, so the + /// next call with the same proof passes one more than this as its floor. + #[derive(Debug, Clone)] + struct FundingReport { + outcome: FundingOutcome, + last_user_fee_increase: u16, + } + + #[derive(Debug, Clone)] + struct RegisterIdentityResult { + status: Status, + identity: Identity, + funding: FundingReport, + } + + /// `balance`: under `Ok`, the identity's balance in the proof the wait + /// returned. A node may answer that from a block before this top-up, + /// so it need not include it: read `get_identity` for a balance to show. #[derive(Debug, Clone)] - struct BalanceResult { + struct TopUpIdentityResult { status: Status, balance: u64, + funding: FundingReport, + } + + /// Platform's record of an asset lock, proved, as of the answering + /// node's block. A record is never removed. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum AssetLockState { + /// No transition has used it yet; one in flight still may. + NotPresent = 0, + /// A transition that failed after its asset-lock signature checked + /// out used part of it; `remaining_credits` can still fund one. + PartiallyConsumed = 1, + /// Used up: nothing can spend it again. + FullyConsumed = 2, + } + + #[derive(Debug, Clone)] + struct AssetLockStateResult { + status: Status, + state: AssetLockState, + remaining_credits: u64, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum ContestPolicyKind { + /// Ordinary names only: a label that would join a contest is refused + /// (`StatusKind::Contested`) before anything is written. + Refuse = 0, + /// Join a contest if the label is contested, paying at most `cap` + /// credits into it; Platform refuses, and charges the transition's + /// fees, when joining costs more. + Cap = 1, + } + + /// How `register_dpns_name` treats a label that would join a contest. + /// `cap` is read under `Cap` only. + #[derive(Debug, Clone, Copy)] + struct ContestPolicy { + kind: ContestPolicyKind, + cap: u64, } /// A DPNS name. `identity` is the identity it resolves to, all zero @@ -269,9 +388,19 @@ pub mod ffi { public_key: [u8; 33], } + /// What the embedder knows of a quorum; defined in `callbacks.h`. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum QuorumLookup { + Found = 0, + Invalid = 1, + Unavailable = 2, + } + unsafe extern "C++" { include!("dash/sdk/callbacks.h"); + type QuorumLookup; type WalletSigner; type ContactKeys; type CoreContext; @@ -310,7 +439,7 @@ pub mod ffi { quorum_hash: &[u8; 32], core_chain_locked_height: u32, key_out: &mut [u8; 48], - ) -> bool; + ) -> QuorumLookup; fn PlatformActivationHeight(self: &CoreContext, height_out: &mut u32) -> bool; } @@ -372,34 +501,55 @@ pub mod ffi { direction: ContactDirection, since_ms: u64, ) -> ContactRequestsResult; + /// Platform's record of credit output `vout` of asset lock + /// transaction `txid` (internal uint256 byte order). Any proved + /// state is `Ok`. + fn asset_lock_state( + self: &PlatformClient, + txid: &[u8; 32], + vout: u32, + ) -> AssetLockStateResult; // Writes: one end-to-end SDK call each, waiting for the proved // result. Callback objects are moved in. + // + // Identity funding: each attempt is built with a `user_fee_increase` + // from `user_fee_increase_floor` up, never above 100 (at most double + // the processing fee, paid from the lock's own credits). While + // Platform's view of Core is below a ChainLock proof's height + // (10506) the SDK resubmits, one higher each time, for at most + // 210 s. A floor above 100 makes no attempt: `FeeCapReached`. /// Creates the identity holding `keys` (each must be enabled). fn register_identity( self: &PlatformClient, keys: &[IdentityKey], proof: &AssetLockProof, asset_lock_key_ref: u32, + user_fee_increase_floor: u16, signer: UniquePtr, - ) -> IdentityResult; - /// The identity's balance after the top-up. + ) -> RegisterIdentityResult; + /// Tops up `identity`, then reads the lock's record (as + /// `asset_lock_state`): `Ok` only once it shows the lock used up, + /// `Submitted` otherwise. fn top_up_identity( self: &PlatformClient, identity: &[u8; 32], proof: &AssetLockProof, asset_lock_key_ref: u32, + user_fee_increase_floor: u16, signer: UniquePtr, - ) -> BalanceResult; + ) -> TopUpIdentityResult; /// Preorder then domain, signed with `key_id`; the domain document - /// id. `contest_fund`: the most a contested name may pay into its - /// contest; 0 lets the SDK price it just before the domain is sent. + /// id. Under `Refuse`, a label `is_contested_username` calls + /// contested, or whose domain the network's DPNS contract puts in a + /// contest, is `Contested` before any write; any other label states + /// a contest fund of 0. Under `Cap` the domain states `cap`. fn register_dpns_name( self: &PlatformClient, label: &str, identity: &[u8; 32], key_id: u32, - contest_fund: u64, + policy: &ContestPolicy, signer: UniquePtr, ) -> IdResult; /// The contact request document id. @@ -418,6 +568,10 @@ pub mod ffi { shared_secret: &[u8; 32], encrypted_public_key: &[u8], ) -> Result; + /// The id of the identity that credit output `vout` of asset lock + /// transaction `txid` (internal uint256 byte order) registers, as + /// Platform derives it. + fn identity_id_from_asset_lock_outpoint(txid: &[u8; 32], vout: u32) -> IdResult; } } @@ -469,6 +623,15 @@ fn contested(check: fn(&str) -> bool, label: &str) -> bool { guarded("is_contested_username", || check(label), |_| true) } +fn identity_id_from_asset_lock_outpoint(txid: &[u8; 32], vout: u32) -> ffi::IdResult { + guarded( + "identity_id_from_asset_lock_outpoint", + || Ok(marshal::identity_id_from_outpoint(txid, vout)), + |message| Err(ffi::Status::from(message)), + ) + .into() +} + fn decrypt_contact_xpub( shared_secret: &[u8; 32], encrypted_public_key: &[u8], diff --git a/packages/rs-sdk-cxx/src/marshal.rs b/packages/rs-sdk-cxx/src/marshal.rs index 776f7a787c3..2111a6140b0 100644 --- a/packages/rs-sdk-cxx/src/marshal.rs +++ b/packages/rs-sdk-cxx/src/marshal.rs @@ -7,7 +7,7 @@ use std::collections::BTreeMap; use dash_sdk::dpp::dashcore::consensus::Decodable; -use dash_sdk::dpp::dashcore::{InstantLock, OutPoint, Transaction}; +use dash_sdk::dpp::dashcore::{InstantLock, OutPoint, Transaction, Txid}; use dash_sdk::dpp::document::{Document, DocumentV0Getters}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -31,6 +31,20 @@ pub fn reversed(hash: &[u8; 32]) -> [u8; 32] { out } +/// An asset lock's credit output; `txid` is in the embedder's internal byte +/// order, which is also the consensus encoding's. +pub fn out_point(txid: &[u8; 32], vout: u32) -> OutPoint { + OutPoint::new(Txid::from_byte_array(*txid), vout) +} + +/// The id of the identity an asset lock's credit output registers, by dpp's +/// own derivation. +pub fn identity_id_from_outpoint(txid: &[u8; 32], vout: u32) -> [u8; 32] { + ChainAssetLockProof::new(0, out_point(txid, vout).into()) + .create_identifier() + .to_buffer() +} + pub fn identity(identity: &Identity) -> ffi::Identity { ffi::Identity { id: identity.id().to_buffer(), @@ -182,8 +196,56 @@ pub fn contact_request(document: &Document) -> Result context(std::shared_ptr sentinel = nullptr) { return std::make_unique( - [sentinel](uint8_t, const std::array&, uint32_t, std::vector&) { return false; }, + [sentinel](uint8_t, const std::array&, uint32_t, std::vector&) { + return QuorumLookup::Unavailable; + }, [](uint32_t&) { return false; }); } @@ -79,10 +81,17 @@ bool refused(Config cfg, std::unique_ptr ctx = context()) int main() { - static_assert(sizeof(Network) == 1 && sizeof(StatusKind) == 1); + static_assert(sizeof(Network) == 1 && sizeof(StatusKind) == 1 && sizeof(QuorumLookup) == 1); static_assert(static_cast(StatusKind::NotFound) == 1); static_assert(static_cast(StatusKind::Consensus) == 4); + static_assert(static_cast(StatusKind::Contested) == 6); static_assert(static_cast(AssetLockProofKind::Chain) == 1); + static_assert(static_cast(QuorumLookup::Unavailable) == 2); + static_assert(static_cast(FundingOutcome::Unavailable) == 5); + static_assert(static_cast(FundingOutcome::Submitted) == 6); + static_assert(static_cast(FundingOutcome::FeeCapReached) == 7); + static_assert(static_cast(AssetLockState::FullyConsumed) == 2); + static_assert(static_cast(ContestPolicyKind::Cap) == 1); // Configs the client cannot honour are errors, never a direct fallback. { @@ -140,6 +149,9 @@ int main() check(unproved(client->contested_vote_state("a11ce").status), "contested_vote_state"); const auto requests = client->contact_requests(id, ContactDirection::Received, 0); check(unproved(requests.status) && !requests.complete, "contact_requests"); + const std::array lock_txid{}; + const auto lock_state = client->asset_lock_state(lock_txid, 1); + check(unproved(lock_state.status) && lock_state.state == AssetLockState::NotPresent, "asset_lock_state"); check(client->contact_requests(id, static_cast(9), 0).status.kind == StatusKind::Internal, "unknown contact direction accepted"); @@ -177,19 +189,41 @@ int main() const std::vector keys{key}; const rust::Slice key_slice(keys.data(), keys.size()); - const auto created = client->register_identity(key_slice, proof, 77, signer(true)); + // A refused signature settles nothing about the lock; the attempt that + // asked for it is reported, so the next floor is above it. + const auto created = client->register_identity(key_slice, proof, 77, 3, signer(true)); check(created.status.kind == StatusKind::Internal, "a throwing asset-lock signer was not a refusal"); - const auto topped_up = client->top_up_identity(id, proof, 77, signer(false)); + check(created.funding.outcome == FundingOutcome::Unavailable && created.funding.last_user_fee_increase == 3, + "a refused registration was reported wrongly"); + const auto topped_up = client->top_up_identity(id, proof, 77, 0, signer(false)); check(topped_up.status.kind == StatusKind::Internal, "a refusing asset-lock signer was not a refusal"); + check(topped_up.funding.outcome == FundingOutcome::Unavailable && topped_up.funding.last_user_fee_increase == 0, + "a refused top-up was reported wrongly"); check(asset_lock_calls == 2, "the asset-lock signer was not reached"); check(identity_key_calls <= 1, "the identity key was signed more than once"); + // A floor above the cap submits nothing, and says no fee step is left. + const auto capped = client->top_up_identity(id, proof, 77, 101, signer(false)); + check(capped.funding.outcome == FundingOutcome::FeeCapReached && asset_lock_calls == 2, + "a floor above the fee cap was submitted, or not reported as the cap"); proof.kind = static_cast(5); - check(client->register_identity(key_slice, proof, 77, signer(false)).status.kind == StatusKind::Internal, - "unknown proof kind accepted"); - check(client->register_identity(key_slice, proof, 77, nullptr).status.kind == StatusKind::Internal, + const auto bad_proof = client->register_identity(key_slice, proof, 77, 4, signer(false)); + check(bad_proof.status.kind == StatusKind::Internal && bad_proof.funding.outcome == FundingOutcome::Unavailable && + bad_proof.funding.last_user_fee_increase == 3, + "unknown proof kind accepted, or reported as an attempt"); + check(client->register_identity(key_slice, proof, 77, 0, nullptr).status.kind == StatusKind::Internal, "missing signer accepted"); - check(unproved(client->register_dpns_name("alice", id, 1, 0, signer(false)).status), "register_dpns_name"); + // Refuse decides a contested label before any network call or write. + const ContestPolicy refuse{ContestPolicyKind::Refuse, 0}; + check(client->register_dpns_name("alice", id, 1, refuse, signer(false)).status.kind == StatusKind::Contested, + "a contested label was not refused"); + check(unproved(client->register_dpns_name("alice-2000", id, 1, refuse, signer(false)).status), + "register_dpns_name"); + const ContestPolicy cap{ContestPolicyKind::Cap, 0}; + check(unproved(client->register_dpns_name("alice", id, 1, cap, signer(false)).status), "register_dpns_name cap"); + const ContestPolicy unknown{static_cast(9), 0}; + check(client->register_dpns_name("alice", id, 1, unknown, signer(false)).status.kind == StatusKind::Internal, + "unknown contest policy accepted"); ContactRequestInput request{}; request.sender = id; request.recipient = id; @@ -215,31 +249,54 @@ int main() } check(threw, "an undecryptable xpub was accepted"); - // The callback types contain exceptions and check sizes. + std::array txid{}; + for (size_t i = 0; i < txid.size(); ++i) txid[i] = static_cast(i); + const auto derived = identity_id_from_asset_lock_outpoint(txid, 1); + check(derived.status.kind == StatusKind::Ok && derived.id[0] == 0x52 && derived.id[31] == 0xfd, + "identity_id_from_asset_lock_outpoint"); + + // The callback types contain exceptions and check sizes. A quorum answer + // the embedder gets wrong is Unavailable, never Invalid. { const std::array hash{}; std::array out{}; - const auto answer = [](size_t size) { + const auto answer = [](QuorumLookup lookup, size_t size) { return CoreContext( - [size](uint8_t, const std::array&, uint32_t, std::vector& key) { + [lookup, size](uint8_t, const std::array&, uint32_t, std::vector& key) { key.assign(size, 0x05); - return true; + return lookup; }, [](uint32_t&) -> bool { throw std::runtime_error("x"); }); }; - check(!answer(47).QuorumPublicKey(106, hash, 1, out), "a 47-byte quorum key accepted"); - check(!answer(49).QuorumPublicKey(106, hash, 1, out), "a 49-byte quorum key accepted"); - check(out == std::array{}, "a refused quorum key was copied"); - check(answer(48).QuorumPublicKey(106, hash, 1, out) && out[47] == 0x05, "a 48-byte quorum key refused"); + check(answer(QuorumLookup::Found, 47).QuorumPublicKey(106, hash, 1, out) == QuorumLookup::Unavailable, + "a 47-byte quorum key accepted"); + check(answer(QuorumLookup::Found, 49).QuorumPublicKey(106, hash, 1, out) == QuorumLookup::Unavailable, + "a 49-byte quorum key accepted"); + check(answer(QuorumLookup::Invalid, 48).QuorumPublicKey(106, hash, 1, out) == QuorumLookup::Invalid, + "an invalid quorum not reported"); + check(answer(QuorumLookup::Unavailable, 48).QuorumPublicKey(106, hash, 1, out) == QuorumLookup::Unavailable, + "an undecided quorum not reported"); + check(answer(static_cast(7), 48).QuorumPublicKey(106, hash, 1, out) == + QuorumLookup::Unavailable, + "an unknown quorum answer was not Unavailable"); + check(out == std::array{}, "a quorum key was copied without Found"); + check(answer(QuorumLookup::Found, 48).QuorumPublicKey(106, hash, 1, out) == QuorumLookup::Found && + out[47] == 0x05, + "a 48-byte quorum key refused"); uint32_t height = 0; - check(!answer(48).PlatformActivationHeight(height), "a throwing callback was not a refusal"); + check(!answer(QuorumLookup::Found, 48).PlatformActivationHeight(height), + "a throwing callback was not a refusal"); const CoreContext throwing( - [](uint8_t, const std::array&, uint32_t, std::vector&) -> bool { + [](uint8_t, const std::array&, uint32_t, std::vector&) -> QuorumLookup { throw std::runtime_error("x"); }, nullptr); - check(!throwing.QuorumPublicKey(106, hash, 1, out) && !throwing.PlatformActivationHeight(height), - "a throwing or empty CoreContext was not a refusal"); + check(throwing.QuorumPublicKey(106, hash, 1, out) == QuorumLookup::Unavailable && + !throwing.PlatformActivationHeight(height), + "a throwing CoreContext was not Unavailable and a refusal"); + const CoreContext no_callbacks(nullptr, nullptr); + check(no_callbacks.QuorumPublicKey(106, hash, 1, out) == QuorumLookup::Unavailable, + "an empty CoreContext was not Unavailable"); std::array sig{}; const WalletSigner empty(nullptr, nullptr); check(!empty.SignAssetLock(1, hash, sig), "an empty WalletSigner signed"); diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 899bccfa103..adef60bec4c 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -131,6 +131,43 @@ pub enum Error { #[error("Operation cancelled: {0}")] Cancelled(String), + /// A DPNS registration under + /// [`DpnsContestPolicy::Refuse`](crate::platform::dpns_usernames::DpnsContestPolicy::Refuse) + /// was refused, before anything was written, because the name would join a contest. + /// `index` is the contested index of the DPNS contract the registration built its domain + /// with, or `None` when the label's spelling alone put it in a contest. + #[error("{label} would join a DPNS contest, and only ordinary names are registered")] + DpnsNameContested { + /// The label as the caller gave it. + label: String, + /// The contested index the domain falls under. + index: Option, + }, + + /// An identity funding submission was not made: its `user_fee_increase` floor is above + /// the cap, so no fee step is left for its proof + /// ([`FundingFailureKind::FeeCapReached`](crate::platform::transition::asset_lock_submission::FundingFailureKind::FeeCapReached)). + #[error("the user_fee_increase floor {floor} is above the cap {cap}: nothing was submitted")] + FeeIncreaseFloorAboveCap { + /// The floor the caller asked for. + floor: u16, + /// The most a funding transition states. + cap: u16, + }, + + /// A top-up was submitted and its wait answered, but Platform's proved record of its + /// asset lock does not show the lock used up, or could not be read. + #[error( + "the top-up was submitted, but asset lock {out_point} is not proved used up: {record}" + )] + AssetLockNotUsedUp { + /// The asset lock the top-up spent. + out_point: dpp::dashcore::OutPoint, + /// What its record says, or why it was not read. + #[source] + record: crate::platform::transition::asset_lock_submission::UnsettledLockRecord, + }, + /// Remote node is stale; try another server #[error(transparent)] StaleNode(#[from] StaleNodeError), diff --git a/packages/rs-sdk/src/platform/dashpay/invitation.rs b/packages/rs-sdk/src/platform/dashpay/invitation.rs index 38b4d61a6ea..e133eb08af0 100644 --- a/packages/rs-sdk/src/platform/dashpay/invitation.rs +++ b/packages/rs-sdk/src/platform/dashpay/invitation.rs @@ -745,13 +745,12 @@ impl Sdk { /// identity exists at this id, yet a claim still fails deterministically /// because the asset-lock output is already spent. /// - /// Platform exposes no client query for spent asset locks (drive tracks - /// them under `SpentAssetLockTransactions`, but no DAPI endpoint surfaces - /// it), so consumption cannot be checked from here. Callers must therefore - /// treat this as a fast-fail for the common case only: reject the - /// invitation when an identity exists, and otherwise proceed WITHOUT - /// concluding the voucher is usable. It narrows when the late failure - /// happens; it does not remove it. + /// This id does not read the spent-asset-lock record that settles + /// consumption; [`Sdk::asset_lock_outpoint_state`] does. Used alone, it is + /// a fast-fail for the common case only: reject the invitation when an + /// identity exists, and otherwise proceed WITHOUT concluding the voucher + /// is usable. It narrows when the late failure happens; it does not remove + /// it. /// /// Costs one funding-tx fetch: the outpoint is not in the link (the credit /// output is selected by pk↔script match, not by index), so the tx has to diff --git a/packages/rs-sdk/src/platform/documents/contest_fund.rs b/packages/rs-sdk/src/platform/documents/contest_fund.rs index c3e4a6ca644..abdc722aa50 100644 --- a/packages/rs-sdk/src/platform/documents/contest_fund.rs +++ b/packages/rs-sdk/src/platform/documents/contest_fund.rs @@ -14,6 +14,7 @@ use dpp::document::DocumentV0Getters; use dpp::fee::Credits; use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dpp::voting::contender_structs::ContenderWithSerializedDocument; +use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; use drive::config::DEFAULT_QUERY_LIMIT; use drive::query::vote_poll_vote_state_query::{ ContestedDocumentVotePollDriveQuery, ContestedDocumentVotePollDriveQueryResultType, @@ -51,13 +52,7 @@ impl Sdk { document_type: DocumentTypeRef<'_>, document: &Document, ) -> Result, Error> { - // The contest is resolved on the document the transition builder sends, with every - // `generatedFrom` property generated from its params as the platform generates it - let mut document = document.clone(); - document_type.regenerate_generated_properties(document.properties_mut(), self.version())?; - let Some(vote_poll) = - document_type.contested_vote_poll_for_document(&document, self.version())? - else { + let Some(vote_poll) = self.contested_vote_poll(document_type, document)? else { return Ok(None); }; @@ -95,6 +90,19 @@ impl Sdk { vote_poll.required_vote_resolution_fund_to_join(contenders, self.version()); Ok(Some((vote_poll.index_name, contest_fund))) } + + /// The contest a create of `document` joins, or `None`: resolved on the document the + /// transition builder sends, with every `generatedFrom` property generated from its params + /// as the platform generates it. + pub(crate) fn contested_vote_poll( + &self, + document_type: DocumentTypeRef<'_>, + document: &Document, + ) -> Result, Error> { + let mut document = document.clone(); + document_type.regenerate_generated_properties(document.properties_mut(), self.version())?; + Ok(document_type.contested_vote_poll_for_document(&document, self.version())?) + } } /// `options` naming the most a create of `document` pays into the contest it joins: the fund diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index e8cfb64a811..020f151479d 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -218,9 +218,52 @@ pub struct RegisterDpnsNameInput> { /// From protocol version 14 the fund to join doubles once a contest holds 250 contenders /// and again for every 50 more, and a registration is charged it. The identity must hold /// what it states. `None` states the fund to join read just before the domain is submitted. + /// Must be `None` for [`Sdk::register_dpns_name_under_policy`], whose policy states it. pub contest_fund: Option, } +/// What [`Sdk::register_dpns_name_under_policy`] does with a name that would join a contest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpnsContestPolicy { + /// Ordinary names only. A name that would join a contest is refused with + /// [`Error::DpnsNameContested`] before anything is written, so the refusal costs no fee. + /// An ordinary name states a contest fund of 0. + Refuse, + /// Join a contest when the name is contested, stating at most these credits as the fund + /// to join, 0 included. Platform refuses the domain, and charges its fees, when joining + /// costs more. + Cap(Credits), +} + +impl DpnsContestPolicy { + /// The refusal `label` gets by its spelling alone, before anything is read: under + /// [`Self::Refuse`], a label [`is_contested_username`] names contested. A registration + /// under the policy checks this first, then the domain it builds against the DPNS + /// contract it builds it with. + pub fn check_label(&self, label: &str) -> Result<(), Error> { + match self { + Self::Refuse if is_contested_username(label) => Err(Error::DpnsNameContested { + label: label.to_string(), + index: None, + }), + _ => Ok(()), + } + } + + /// The contest fund a domain for `label` states, `contest` being the contested index the + /// domain falls under, if any. + fn contest_fund(&self, label: &str, contest: Option) -> Result { + match (self, contest) { + (Self::Refuse, Some(index)) => Err(Error::DpnsNameContested { + label: label.to_string(), + index: Some(index), + }), + (Self::Refuse, None) => Ok(0), + (Self::Cap(cap), _) => Ok(*cap), + } + } +} + /// Result of a DPNS name registration #[derive(Debug)] pub struct RegisterDpnsNameResult { @@ -488,6 +531,22 @@ impl Sdk { .map(|(result, _owner_balance, _metadata)| result) } + /// Register a DPNS username like [`Sdk::register_dpns_name`], with `policy` deciding what + /// a name that would join a contest does. The policy is applied to the domain this + /// registration builds, against the DPNS contract it builds it with, before a pending + /// preorder is looked for, deleted or replaced, so a refused name costs no fee. The domain + /// always states the contest fund the policy gives, so the SDK never prices it: + /// `input.contest_fund` must be `None`. + pub async fn register_dpns_name_under_policy>( + &self, + input: RegisterDpnsNameInput, + policy: DpnsContestPolicy, + ) -> Result { + self.register_dpns_name_with(input, Some(policy)) + .await + .map(|(result, _owner_balance, _metadata)| result) + } + /// Register a DPNS username like [`Sdk::register_dpns_name`], and also return /// what the domain document's proof carries next to the document: the credit /// balance of the registering identity after the registration (`None` for a @@ -502,6 +561,24 @@ impl Sdk { &self, input: RegisterDpnsNameInput, ) -> Result<(RegisterDpnsNameResult, Option, ResponseMetadata), Error> { + self.register_dpns_name_with(input, None).await + } + + async fn register_dpns_name_with>( + &self, + input: RegisterDpnsNameInput, + policy: Option, + ) -> Result<(RegisterDpnsNameResult, Option, ResponseMetadata), Error> { + if let Some(policy) = policy { + if input.contest_fund.is_some() { + return Err(Error::InvalidRequest( + "a registration under a contest policy states the fund the policy gives: \ + contest_fund must be None" + .to_string(), + )); + } + policy.check_label(&input.label)?; + } let identity_id = input.identity.id().to_owned(); let DpnsRegistrationDocuments { dpns_contract, @@ -513,6 +590,15 @@ impl Sdk { } = self .dpns_registration_documents(&input.label, identity_id) .await?; + let contest_fund = match policy { + Some(policy) => { + let contest = self + .contested_vote_poll(domain_document_type.as_ref(), &domain_document)? + .map(|vote_poll| vote_poll.index_name); + Some(policy.contest_fund(&input.label, contest)?) + } + None => input.contest_fund, + }; // From DPNS v3 an identity holds one pending preorder of a name: one an earlier // registration left behind, whose domain create failed, is revealed again rather @@ -571,7 +657,7 @@ impl Sdk { // Submit domain document after preorder debug!(%identity_id, stage = "domain", "DPNS registration: submitting document"); - let domain_settings = input.contest_fund.map(|contest_fund| PutSettings { + let domain_settings = contest_fund.map(|contest_fund| PutSettings { state_transition_creation_options: Some(StateTransitionCreationOptions { contest_fund: Some(contest_fund), ..Default::default() @@ -1091,6 +1177,217 @@ mod tests { assert!(declares_the_preorder_hash(&resolved)); } + /// Refuse states 0 for an ordinary name and refuses a contested one; Cap states its cap, + /// 0 included, whether the name is contested or not. + #[test] + fn should_give_each_contest_policy_its_contest_fund() { + let joins = || Some("parentNameAndLabel".to_string()); + assert_eq!( + DpnsContestPolicy::Refuse + .contest_fund("bob2000", None) + .unwrap(), + 0 + ); + assert!(matches!( + DpnsContestPolicy::Refuse.contest_fund("Bob", joins()), + Err(Error::DpnsNameContested { index: Some(index), .. }) if index == "parentNameAndLabel" + )); + for cap in [0, 5_000_000] { + for contest in [None, joins()] { + assert_eq!( + DpnsContestPolicy::Cap(cap) + .contest_fund("Bob", contest) + .unwrap(), + cap + ); + } + } + + assert!(matches!( + DpnsContestPolicy::Refuse.check_label("Alice"), + Err(Error::DpnsNameContested { index: None, .. }) + )); + assert!(DpnsContestPolicy::Refuse.check_label("bob2000").is_ok()); + assert!(DpnsContestPolicy::Cap(0).check_label("Alice").is_ok()); + } + + /// `owner`'s registration of `label`, which stops before it signs. + fn registration( + label: &str, + owner: Identifier, + contest_fund: Option, + ) -> RegisterDpnsNameInput { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + + RegisterDpnsNameInput { + label: label.to_string(), + identity: Identity::create_basic_identity(owner, PlatformVersion::latest()) + .expect("the identity builds"), + identity_public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![1; 33]), + disabled_at: None, + }), + signer: UnusedSigner, + preorder_callback: None, + contest_fund, + } + } + + /// DPNS v3 with its contested index widened to normalized labels of up to 63 characters: + /// a contract whose contest rule is not the one `is_contested_username` spells out. + fn dpns_v3_contesting_long_labels() -> DataContract { + let version = PlatformVersion::latest(); + let mut source = SystemDataContract::DPNS + .source(version) + .expect("the DPNS source loads"); + *source + .document_schemas + .pointer_mut("/domain/indices/0/contested/fieldMatches/0/regexPattern") + .expect("DPNS v3 contests labels by a pattern") = "^[a-zA-Z01-]{3,63}$".into(); + dpp::data_contract::DataContractFactory::new(version.protocol_version) + .expect("a contract factory") + .create_with_id( + Identifier::from(source.id_bytes), + Identifier::from(source.owner_id_bytes), + 0, + source.document_schemas.into(), + None, + source.definitions.map(Into::into), + ) + .expect("the widened contract builds") + .data_contract_owned() + } + + /// A mock SDK on a network at the latest protocol version that stores `stored` as DPNS. + /// Answers set on it are encoded at that version. + async fn sdk_storing(stored: &DataContract) -> Sdk { + let mut sdk = SdkBuilder::new_mock() + .with_initial_version(PlatformVersion::latest()) + .with_context_provider(DpnsV2ContextProvider::default()) + .build() + .expect("the mock SDK builds"); + expect_epoch_refresh(&mut sdk).await; + sdk.mock() + .expect_fetch(stored.id(), Some(stored.clone())) + .await + .expect("the DPNS fetch is expected"); + sdk + } + + /// Refuse turns away a label its spelling puts in a contest before anything is read: the + /// mock answers no request. A policy also refuses an input that states its own fund. + #[tokio::test] + async fn should_refuse_a_contested_label_before_reading_anything() { + let sdk = mock_sdk(); + let owner = Identifier::new([1u8; 32]); + + let refused = sdk + .register_dpns_name_under_policy( + registration("Bob", owner, None), + DpnsContestPolicy::Refuse, + ) + .await; + assert!( + matches!(&refused, Err(Error::DpnsNameContested { label, index: None }) if label == "Bob"), + "{refused:?}" + ); + + let refused = sdk + .register_dpns_name_under_policy( + registration("bob2000", owner, Some(0)), + DpnsContestPolicy::Cap(0), + ) + .await; + assert!( + matches!(&refused, Err(Error::InvalidRequest(_))), + "{refused:?}" + ); + } + + /// Refuse judges the domain the registration builds against the contract it builds it + /// with. A label of 20 characters is ordinary by its spelling: the DPNS v3 the network + /// stores lets it through to the pending-preorder query (left unanswered), while a + /// contract contesting such labels refuses it before that query, a delete or a preorder. + #[tokio::test] + async fn should_refuse_a_name_the_registrations_own_contract_contests() { + let owner = Identifier::new([1u8; 32]); + let label = "bobbobbobbobbobbobbo"; + assert!(!is_contested_username(label)); + + let sdk = sdk_storing(&dpns_v3_contesting_long_labels()).await; + let refused = sdk + .register_dpns_name_under_policy( + registration(label, owner, None), + DpnsContestPolicy::Refuse, + ) + .await; + assert!( + matches!(&refused, Err(Error::DpnsNameContested { index: Some(index), .. }) if index == "parentNameAndLabel"), + "{refused:?}" + ); + + let sdk = sdk_storing(&dpns_v3()).await; + let error = sdk + .register_dpns_name_under_policy( + registration(label, owner, None), + DpnsContestPolicy::Refuse, + ) + .await + .expect_err("the unanswered pending-preorder query stops the registration"); + assert!( + error.to_string().contains("GetDocumentsRequest"), + "an ordinary name reaches the pending-preorder query, got {error}" + ); + } + + /// Cap states its fund on the domain, 0 included, so a contested name is never priced by + /// the SDK: with the pending preorder revealable, the domain create asks for its nonce + /// (left unanswered), never for the contest's vote state. + #[tokio::test] + async fn should_state_the_cap_on_a_contested_domain() { + let owner = Identifier::new([1u8; 32]); + for cap in [0, 5_000_000] { + let mut sdk = sdk_storing(&dpns_v3()).await; + let pending = bob_v3_preorder(owner); + sdk.mock() + .expect_fetch_many::( + pending_preorder_query( + Arc::new(dpns_v3()), + owner, + pending.properties()[NAME_HASH].clone(), + ), + Some( + [(pending.id(), Some(pending.clone()))] + .into_iter() + .collect(), + ), + ) + .await + .expect("the expectation is set"); + + let error = sdk + .register_dpns_name_under_policy( + registration("Bob", owner, None), + DpnsContestPolicy::Cap(cap), + ) + .await + .expect_err("the unanswered nonce query stops the registration"); + let message = error.to_string(); + assert!( + message.contains("GetIdentityContractNonceRequest") + && !message.contains("ContestedResourceVoteState"), + "cap {cap}: the domain must state its fund, got {message}" + ); + } + } + /// A signer the tests never reach: each registration below stops before it signs. #[derive(Debug)] struct UnusedSigner; diff --git a/packages/rs-sdk/src/platform/transition/asset_lock_submission.rs b/packages/rs-sdk/src/platform/transition/asset_lock_submission.rs index e7e3b2651de..38f74dc82f9 100644 --- a/packages/rs-sdk/src/platform/transition/asset_lock_submission.rs +++ b/packages/rs-sdk/src/platform/transition/asset_lock_submission.rs @@ -14,28 +14,71 @@ //! with a ChainLock proof for the same outpoint: a stale islock, or an //! InstantSend proof that made the transition too large. //! - [`submit_with_cl_height_retry`] — retry-on-10506 wrapper that bumps -//! `user_fee_increase` between attempts so Tenderdash's invalid-tx hash -//! cache (24h on mainnet/testnet) can't silently drop resubmits. +//! `user_fee_increase` between attempts, so no attempt repeats the bytes +//! of one Platform refused (see "Refused transitions" below). +//! - [`submit_funding_with_fee_floor`] — the same retry for an identity +//! funding transition, from the caller's floor and never above +//! [`MAX_FUNDING_FEE_INCREASE`], telling the caller each attempt's fee +//! before it is built and what to do next with the lock +//! ([`FundingFailureKind`]). +//! - [`next_chain_lock_proof`] — the fresh ChainLock proof for a lock whose +//! fee steps are used up. +//! - [`Sdk::asset_lock_outpoint_state`] — Platform's record of an outpoint, +//! read with a proof: whether a transition used the lock, and what is left. +//! +//! # Refused transitions +//! +//! Tenderdash keeps the hash of every transition its CheckTx refused +//! (`keep-invalid-txs-in-cache = true` in dashmate's template) with no time +//! limit. The hash leaves a node's cache only when the cache (15000 entries +//! as dashmate configures it) evicts it as the least recently used, and a +//! resubmission refreshes it, or when the node restarts. The mempool's +//! `ttl-duration` (24 h on mainnet and testnet) covers admitted transactions +//! only. A refused transition is never gossiped, so only the node that +//! checked it holds its hash, and that node never admits the same bytes +//! again: Tenderdash 1.8 drops them before CheckTx (DAPI answers that the +//! transition must be created anew), and later releases answer the cached +//! rejection again. +//! +//! So a submission is the pair (proof, `user_fee_increase`), and no pair is +//! sent twice for one lock. Within one proof the fee only rises. Once its +//! steps are used up ([`FundingFailureKind::FeeCapReached`]), the next proof +//! is a ChainLock proof for the same outpoint at a height none carried +//! before ([`next_chain_lock_proof`]). The height is part of the signed +//! bytes, the identity a lock creates derives from the outpoint alone, and +//! Platform accepts any height from the transaction's own up to the Core +//! height it has seen. +use std::future::Future; use std::time::Duration; +use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; +use dpp::asset_lock::StoredAssetLockInfo; use dpp::consensus::basic::identity::InvalidAssetLockProofCoreChainHeightError; use dpp::consensus::basic::state_transition::StateTransitionMaxSizeExceededError; use dpp::consensus::basic::BasicError; use dpp::consensus::ConsensusError; use dpp::dashcore::OutPoint; +use dpp::fee::Credits; use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; -use dpp::prelude::AssetLockProof; +use dpp::prelude::{AssetLockProof, CoreBlockHeight, UserFeeIncrease}; +use dpp::serialization::PlatformDeserializableUntrusted; +use dpp::ProtocolError; +use drive::drive::RootTree; +use drive::grovedb::Element; +use drive_proof_verifier::types::{Elements, KeysInPath}; use super::put_settings::PutSettings; -use crate::Error; +use crate::platform::FetchMany; +use crate::{Error, Sdk}; /// Delay between retries when Platform rejected with CL-height-too-low. /// Each retry bumps `PutSettings::user_fee_increase` so the ST hash -/// changes (Tenderdash caches rejected ST hashes for ~24h on -/// mainnet/testnet — `keep-invalid-txs-in-cache = true` in dashmate's -/// tenderdash template, hardcoded at -/// `packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot:355`). +/// changes: the node that refused the transition keeps its hash +/// (`keep-invalid-txs-in-cache = true` in dashmate's tenderdash template, +/// hardcoded at +/// `packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot:355`; +/// see the module doc). const CL_HEIGHT_RETRY_DELAY: Duration = Duration::from_secs(15); /// Total time we'll keep retrying before surfacing the error. Sized to @@ -87,6 +130,59 @@ pub fn is_asset_lock_already_consumed(error: &Error, out_point: &OutPoint) -> bo ) } +impl Sdk { + /// Platform's record of the asset lock `out_point` spends, read with a + /// proof: [`StoredAssetLockInfo::FullyConsumed`] once a transition used + /// it up, `PartiallyConsumed` with what is left after a transition that + /// failed past its asset-lock signature check, `NotPresent` before any. + /// + /// Drive keeps these records under `SpentAssetLockTransactions` and never + /// removes one, so `FullyConsumed` is final: no transition can spend the + /// lock again. It does not say which transition used the lock, or which + /// identity its credits went to. `NotPresent` holds only as of the + /// proof's block: a transition still in flight may use the lock later. + /// + /// A top-up's own wait cannot tell either: it proves a snapshot of the + /// identity's balance, which no particular top-up is bound to. + pub async fn asset_lock_outpoint_state( + &self, + out_point: &OutPoint, + ) -> Result { + let key = <[u8; 36]>::from(*out_point).to_vec(); + let query = KeysInPath { + path: vec![vec![RootTree::SpentAssetLockTransactions as u8]], + keys: vec![key.clone()], + }; + let elements = Element::fetch_many(self, query).await?; + stored_asset_lock_info(elements, &key) + } +} + +/// Reads a spent-asset-lock record through Drive's reader of it, decoding +/// the remaining [`AssetLockValue`] as untrusted and exact. +fn stored_asset_lock_info( + mut elements: Elements, + key: &[u8], +) -> Result { + let element = elements.swap_remove(key).flatten(); + let item = element + .as_ref() + .map(Element::as_item_bytes) + .transpose() + .map_err(|e| { + Error::InvalidProvedResponse(format!( + "a spent asset lock record that is not an item: {e}" + )) + })?; + drive::util::common::asset_lock::stored_asset_lock_info( + item, + AssetLockValue::deserialize_from_bytes_untrusted_exact, + ) + .map_err(|e| { + Error::InvalidProvedResponse(format!("an undecodable spent asset lock record: {e}")) + }) +} + /// Extract the `InvalidAssetLockProofCoreChainHeightError` (DPP /// consensus code 10506) from an SDK error if Platform rejected a /// ChainLock asset-lock proof because Platform's @@ -103,11 +199,10 @@ pub fn is_asset_lock_already_consumed(error: &Error, out_point: &OutPoint) -> bo /// /// Returns `None` for everything else. The check is stateless and /// re-evaluated on every CheckTx, so a resubmit after Platform -/// catches up will pass — but Tenderdash's mempool caches rejected-tx -/// hashes for ~24h on mainnet/testnet (`keep-invalid-txs-in-cache = -/// true` in dashmate's tenderdash template), so the resubmit must -/// carry a *different* signable-bytes hash to bypass the cache. The -/// submission layer handles that by bumping +/// catches up passes, but only with *different* bytes: the node that +/// refused a transition keeps its hash with no time limit +/// (`keep-invalid-txs-in-cache = true` in dashmate's tenderdash template; +/// see the module doc). The submission layer handles that by bumping /// `PutSettings::user_fee_increase` before re-issuing. /// /// Companion to [`is_instant_lock_proof_invalid`]; both feed the @@ -211,10 +306,11 @@ fn encoded_len(proof: &AssetLockProof) -> Option { /// `InvalidAssetLockProofCoreChainHeightError` (consensus code 10506). /// /// Each retry bumps `settings.user_fee_increase` so the resubmitted ST -/// hashes differently — Tenderdash caches rejected ST hashes for ~24h -/// on mainnet/testnet (`keep-invalid-txs-in-cache = true`), so an -/// identical-bytes resubmit would be silently dropped before reaching -/// Platform's CheckTx. +/// hashes differently: the node that refused a transition keeps its hash +/// with no time limit (`keep-invalid-txs-in-cache = true`; see the module +/// doc), so an identical-bytes resubmit would be dropped before reaching +/// Platform's CheckTx. Retries stop once `user_fee_increase` reaches +/// `u16::MAX`, where a bump would repeat it. /// /// **Retry scope.** This wrapper retries ONLY consensus code 10506 /// (CL-height-too-low). Every other `Error` — including @@ -270,25 +366,59 @@ fn encoded_len(proof: &AssetLockProof) -> Option { /// should be prepared to either retry (settings reset to original) /// or accept that Platform may still execute the dropped attempt. pub async fn submit_with_cl_height_retry( - mut settings: Option, + settings: Option, submit: F, ) -> Result where F: Fn(Option) -> Fut, - Fut: std::future::Future>, + Fut: Future>, +{ + retry_cl_height_too_low(settings, UserFeeIncrease::MAX, submit) + .await + .map_err(|(error, _)| error) +} + +/// The retry behind [`submit_with_cl_height_retry`] and +/// [`submit_funding_with_fee_floor`]: no retry after an attempt whose +/// `user_fee_increase` reached `max_fee`. A failure comes with its attempt's +/// `user_fee_increase`. +async fn retry_cl_height_too_low( + mut settings: Option, + max_fee: UserFeeIncrease, + submit: F, +) -> Result +where + F: Fn(Option) -> Fut, + Fut: Future>, { let elapsed_since_start = retry_clock(); let mut attempt: u32 = 0; loop { attempt += 1; + let fee = settings + .and_then(|s| s.user_fee_increase) + .unwrap_or_default(); match submit(settings).await { Ok(r) => return Ok(r), Err(e) => { let Some(detail) = as_asset_lock_proof_cl_height_too_low(&e) else { - return Err(e); + return Err((e, fee)); }; let elapsed = elapsed_since_start(); let remaining = CL_HEIGHT_RETRY_BUDGET.saturating_sub(elapsed); + if fee >= max_fee { + tracing::error!( + "Platform rejected ChainLock proof: CL height too low \ + (proof claimed height={}, Platform tip={}, attempt {}) at \ + user_fee_increase {}, the most this submission states; \ + surfacing error", + detail.proof_core_chain_locked_height(), + detail.current_core_chain_locked_height(), + attempt, + fee, + ); + return Err((e, fee)); + } if remaining.is_zero() || attempt >= CL_HEIGHT_RETRY_MAX_ATTEMPTS { tracing::error!( "Platform rejected ChainLock proof: CL height too low \ @@ -302,7 +432,7 @@ where elapsed.as_secs(), CL_HEIGHT_RETRY_BUDGET.as_secs(), ); - return Err(e); + return Err((e, fee)); } let sleep_for = remaining.min(CL_HEIGHT_RETRY_DELAY); tracing::warn!( @@ -344,6 +474,238 @@ fn bump_user_fee_increase(mut settings: PutSettings) -> PutSettings { settings } +/// The most `user_fee_increase` an identity funding transition is built with +/// by [`submit_funding_with_fee_floor`]: a percentage added to the processing +/// fee, so at most double it, paid from the asset lock's own credits. +pub const MAX_FUNDING_FEE_INCREASE: UserFeeIncrease = 100; + +/// The most transitions one [`submit_funding_with_fee_floor`] call builds: +/// the first attempt and one per retry delay in the retry budget. +pub const FUNDING_ATTEMPTS: u32 = CL_HEIGHT_RETRY_MAX_ATTEMPTS; + +/// What the caller does next with the asset lock a funding call submitted. +/// Nothing here proves the lock unconsumed: the caller keeps the lock until +/// a proof settles it, and never assumes that a call it dropped sent nothing. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FundingFailureKind { + /// Platform could not use the InstantSend proof (a stale islock, or one + /// that made the transition too large, see [`needs_chain_lock_proof`]): + /// submit a ChainLock proof for the same outpoint once the lock is + /// ChainLocked. + NeedsChainLockProof, + /// Platform's view of Core stayed below the ChainLock proof's height + /// (consensus 10506) through every attempt the retry budget allowed, all + /// below [`MAX_FUNDING_FEE_INCREASE`]. Retry later, one above the last + /// attempt's fee. + PlatformBehindCore, + /// No fee step is left for this proof: the floor is above + /// [`MAX_FUNDING_FEE_INCREASE`], so nothing was submitted, or the attempt + /// at it still met 10506. Any pair of this proof and a fee up to the cap + /// may be held by a node that refused it (see the module doc), so do not + /// resend this proof at any step: send a ChainLock proof for the same + /// outpoint at a height it has not carried ([`next_chain_lock_proof`]), + /// from floor 0. The lock is not settled. + FeeCapReached, + /// A node reported this exact outpoint already consumed + /// ([`is_asset_lock_already_consumed`]). Not authenticated: read + /// [`Sdk::asset_lock_outpoint_state`], or for a registration the identity + /// the outpoint derives. + ReportedConsumed, + /// Platform refused the transition with another consensus error. + Rejected, + /// Nothing settled: a transport failure, a timeout, a response that + /// failed verification, or bad input. A transition that was sent may + /// still execute. + Unavailable, + /// A top-up whose wait returned a verified snapshot of the identity's + /// balance, while its lock is not proved used up + /// ([`Error::AssetLockNotUsedUp`]). The snapshot binds neither the top-up + /// nor the lock. + Submitted, +} + +/// A funding call that did not settle its lock. +#[derive(Debug, thiserror::Error)] +#[error("{error}")] +pub struct FundingFailure { + /// What the caller does next with the lock. + pub kind: FundingFailureKind, + /// The error the call ended with. + #[source] + pub error: Error, +} + +/// Why a top-up's asset lock is not proved used up ([`Error::AssetLockNotUsedUp`]). +#[derive(Debug)] +pub enum UnsettledLockRecord { + /// No transition has used the lock yet; one in flight still may. + NotPresent, + /// A transition that failed after its asset-lock signature checked out + /// used part of it; these credits can still fund one. + PartiallyConsumed(Credits), + /// The record could not be read. The read's error is the source. + Unread(Box), +} + +impl std::fmt::Display for UnsettledLockRecord { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotPresent => f.write_str("Platform has no record of its use"), + Self::PartiallyConsumed(credits) => write!(f, "{credits} of its credits are unspent"), + Self::Unread(error) => write!(f, "its record could not be read: {error}"), + } + } +} + +// Written out because a derived `#[source]` would give the `Box`, which does +// not downcast to `Error`. +impl std::error::Error for UnsettledLockRecord { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Unread(error) => Some(&**error), + Self::NotPresent | Self::PartiallyConsumed(_) => None, + } + } +} + +/// What the caller does next with the lock `proof` spends, after `error`. A +/// 10506 reads as [`FundingFailureKind::PlatformBehindCore`] here: only +/// [`submit_funding_with_fee_floor`], which knows the attempt's fee, reads +/// one at the cap as [`FundingFailureKind::FeeCapReached`]. +pub fn funding_failure_kind(error: &Error, proof: &AssetLockProof) -> FundingFailureKind { + if matches!(error, Error::FeeIncreaseFloorAboveCap { .. }) { + FundingFailureKind::FeeCapReached + } else if matches!(error, Error::AssetLockNotUsedUp { .. }) { + FundingFailureKind::Submitted + } else if as_asset_lock_proof_cl_height_too_low(error).is_some() { + FundingFailureKind::PlatformBehindCore + } else if needs_chain_lock_proof(error, proof) { + FundingFailureKind::NeedsChainLockProof + } else if proof + .out_point() + .is_some_and(|out_point| is_asset_lock_already_consumed(error, &out_point)) + { + FundingFailureKind::ReportedConsumed + } else if is_consensus_refusal(error) { + FundingFailureKind::Rejected + } else { + FundingFailureKind::Unavailable + } +} + +/// Whether Platform refused with a consensus error, CheckTx's or the wait's, +/// decoded or not, looking through the exhausted-retry envelope. +fn is_consensus_refusal(error: &Error) -> bool { + match error { + Error::NoAvailableAddressesToRetry(last) => is_consensus_refusal(last), + Error::Protocol(ProtocolError::ConsensusError(_)) + | Error::StateTransitionBroadcastError(_) => true, + _ => false, + } +} + +/// Submits the identity funding transition `submit` builds, retrying a 10506 +/// as [`submit_with_cl_height_retry`] does: the first attempt at `floor`, +/// each retry one higher, none above [`MAX_FUNDING_FEE_INCREASE`], at most +/// [`FUNDING_ATTEMPTS`]. A floor above the cap submits nothing. +/// +/// `on_attempt` gets each attempt's `user_fee_increase` before the attempt is +/// built, so a caller that drops this future still knows the highest one it +/// may have sent. The next call for the same proof starts one above it; once +/// none is left, the next proof is [`next_chain_lock_proof`]'s, from floor 0. +pub async fn submit_funding_with_fee_floor( + proof: &AssetLockProof, + floor: UserFeeIncrease, + on_attempt: impl Fn(UserFeeIncrease), + submit: F, +) -> Result +where + F: Fn(PutSettings) -> Fut, + Fut: Future>, +{ + if floor > MAX_FUNDING_FEE_INCREASE { + return Err(FundingFailure { + kind: FundingFailureKind::FeeCapReached, + error: Error::FeeIncreaseFloorAboveCap { + floor, + cap: MAX_FUNDING_FEE_INCREASE, + }, + }); + } + let first = PutSettings { + user_fee_increase: Some(floor), + ..Default::default() + }; + retry_cl_height_too_low(Some(first), MAX_FUNDING_FEE_INCREASE, |settings| { + let settings = settings.unwrap_or(first); + on_attempt(settings.user_fee_increase.unwrap_or(floor)); + submit(settings) + }) + .await + .map_err(|(error, fee)| { + let kind = match funding_failure_kind(&error, proof) { + FundingFailureKind::PlatformBehindCore if fee >= MAX_FUNDING_FEE_INCREASE => { + FundingFailureKind::FeeCapReached + } + kind => kind, + }; + FundingFailure { kind, error } + }) +} + +/// The proof a lock whose fee steps are used up +/// ([`FundingFailureKind::FeeCapReached`]) is submitted with next, from floor +/// 0: a ChainLock proof for `out_point` at the transaction's `mined_height` +/// while no ChainLock proof was sent for it (`last_chain_height` `None`), +/// else one block above the last one sent. Its bytes are then new to every +/// node, and Platform has to reach only one block more than the height it +/// refused. `None` while that height is above `best_chain_locked_height`, +/// the caller's best locally verified ChainLock: wait for a later one. +/// +/// The caller has verified that the transaction is mined at `mined_height` on +/// its ChainLocked chain, as for any ChainLock proof, and records the height +/// it sends before sending, so that no height is used twice. +pub fn next_chain_lock_proof( + out_point: OutPoint, + mined_height: CoreBlockHeight, + last_chain_height: Option, + best_chain_locked_height: CoreBlockHeight, +) -> Option { + let height = match last_chain_height { + None => mined_height, + Some(last) => last.checked_add(1)?.max(mined_height), + }; + (height <= best_chain_locked_height).then_some(ChainAssetLockProof { + core_chain_locked_height: height, + out_point, + }) +} + +/// A top-up whose wait answered `balance` is settled only by `record`, the +/// proved record of its lock `out_point`, showing the lock used up. +#[cfg(any(test, feature = "core_key_wallet"))] +pub(crate) fn settle_top_up( + out_point: OutPoint, + balance: Credits, + record: Result, +) -> Result { + use dpp::asset_lock::reduced_asset_lock_value::AssetLockValueGettersV0; + + let record = match record { + Ok(StoredAssetLockInfo::FullyConsumed) => return Ok(balance), + Ok(StoredAssetLockInfo::NotPresent) => UnsettledLockRecord::NotPresent, + Ok(StoredAssetLockInfo::PartiallyConsumed(value)) => { + UnsettledLockRecord::PartiallyConsumed(value.remaining_credit_value()) + } + Err(error) => UnsettledLockRecord::Unread(Box::new(error)), + }; + Err(FundingFailure { + kind: FundingFailureKind::Submitted, + error: Error::AssetLockNotUsedUp { out_point, record }, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -370,7 +732,7 @@ mod tests { /// 1. Every retry under repeated `InvalidAssetLockProofCoreChainHeightError` /// (consensus 10506) receives a `PutSettings::user_fee_increase` /// strictly greater than the previous attempt. The retry's purpose - /// is to bypass Tenderdash's 24h invalid-tx hash cache by changing + /// is to get past Tenderdash's cache of refused hashes by changing /// the ST signable bytes; if `user_fee_increase` weren't bumped, /// every resubmit would hash identically and be silently dropped. /// This invariant regressed silently once in the earlier @@ -457,7 +819,7 @@ mod tests { // starting from Some(1) and bumping by 1 each retry. The exact // values are load-bearing: Tenderdash hashes the full ST bytes // including this field, so consecutive identical values would - // hit the 24h invalid-tx cache. + // hit its cache of refused hashes. for (i, val) in captured.iter().enumerate().skip(1) { let expected = Some(i as u16); assert_eq!( @@ -500,6 +862,58 @@ mod tests { "the second attempt must carry a bumped user_fee_increase" ); } + + /// The legacy retry has no funding cap: it raises the fee past + /// [`MAX_FUNDING_FEE_INCREASE`], and it stops at `u16::MAX` instead of + /// resending the bytes it signed there. + #[tokio::test(start_paused = true)] + async fn should_retry_past_the_funding_cap_and_stop_at_the_largest_fee() { + async fn run( + start: UserFeeIncrease, + refusals: usize, + ) -> (Result<(), Error>, Vec>) { + use std::sync::{Arc, Mutex}; + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_in_submit = seen.clone(); + let settings = PutSettings { + user_fee_increase: Some(start), + ..Default::default() + }; + let result = submit_with_cl_height_retry(Some(settings), move |settings| { + let seen = seen_in_submit.clone(); + async move { + let mut seen = seen.lock().unwrap(); + seen.push(settings.and_then(|s| s.user_fee_increase)); + if seen.len() <= refusals { + Err(fabricate_cl_height_too_low_error()) + } else { + Ok(()) + } + } + }) + .await; + let seen = seen.lock().unwrap().clone(); + (result, seen) + } + + let above_cap = MAX_FUNDING_FEE_INCREASE + 50; + let (result, seen) = run(above_cap, 1).await; + assert!( + result.is_ok(), + "the funding cap must not bound the legacy retry" + ); + assert_eq!(seen, vec![Some(above_cap), Some(above_cap + 1)]); + + let (result, seen) = run(UserFeeIncrease::MAX - 1, usize::MAX).await; + let error = result.expect_err("no fee is left above u16::MAX"); + assert!(as_asset_lock_proof_cl_height_too_low(&error).is_some()); + assert_eq!( + seen, + vec![Some(UserFeeIncrease::MAX - 1), Some(UserFeeIncrease::MAX)], + "the transition signed at u16::MAX must not be sent twice" + ); + } } #[cfg(test)] @@ -727,3 +1141,692 @@ mod chain_lock_fallback_tests { assert!(!needs_chain_lock_proof(&error, &instant_proof(80))); } } + +#[cfg(test)] +mod asset_lock_outpoint_state_tests { + use super::*; + + /// A spent-asset-lock record reads as Drive writes it: no record before + /// any use, an empty item once used up, the remaining value after a + /// partial use. An element Drive never writes there is refused. + #[test] + fn should_read_a_spent_asset_lock_record_as_drive_writes_it() { + use dpp::serialization::PlatformSerializable; + use dpp::version::PlatformVersion; + + let key = vec![7u8; 36]; + let read = |records: Vec<(Vec, Option)>| { + stored_asset_lock_info(records.into_iter().collect(), &key) + }; + let left = AssetLockValue::new(2_000, vec![0x76], 1_500, vec![], PlatformVersion::latest()) + .expect("an asset lock value"); + + assert_eq!(read(vec![]).unwrap(), StoredAssetLockInfo::NotPresent); + assert_eq!( + read(vec![(key.clone(), None)]).unwrap(), + StoredAssetLockInfo::NotPresent + ); + assert_eq!( + read(vec![(vec![8u8; 36], Some(Element::new_item(vec![])))]).unwrap(), + StoredAssetLockInfo::NotPresent, + "another outpoint's record says nothing about this one" + ); + assert_eq!( + read(vec![(key.clone(), Some(Element::new_item(vec![])))]).unwrap(), + StoredAssetLockInfo::FullyConsumed + ); + assert_eq!( + read(vec![( + key.clone(), + Some(Element::new_item(left.serialize_to_bytes().unwrap())) + )]) + .unwrap(), + StoredAssetLockInfo::PartiallyConsumed(left) + ); + assert!(matches!( + read(vec![(key.clone(), Some(Element::empty_tree()))]), + Err(Error::InvalidProvedResponse(_)) + )); + assert!(matches!( + read(vec![(key.clone(), Some(Element::new_item(vec![0xff; 3])))]), + Err(Error::InvalidProvedResponse(_)) + )); + } +} + +// The fakes below answer with the SDK's own `Error`, which is large. +#[cfg(test)] +#[allow(clippy::result_large_err)] +mod funding_tests { + use std::future::ready; + use std::sync::Mutex; + + use dpp::consensus::basic::identity::{ + IdentityAssetLockTransactionOutPointAlreadyConsumedError, + InvalidInstantAssetLockProofSignatureError, + }; + use dpp::dashcore::{InstantLock, Transaction, Txid}; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::version::PlatformVersion; + use rs_dapi_client::DapiClientError; + use tokio::time::Instant; + + use super::*; + use crate::error::StateTransitionBroadcastError; + + fn consensus(error: ConsensusError) -> Error { + Error::Protocol(ProtocolError::ConsensusError(Box::new(error))) + } + + fn cl_height_too_low() -> Error { + consensus(InvalidAssetLockProofCoreChainHeightError::new(101, 100).into()) + } + + fn chain_proof(vout: u32) -> AssetLockProof { + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 100, + out_point: OutPoint::new(Txid::from_byte_array([4; 32]), vout), + }) + } + + fn instant_proof() -> AssetLockProof { + AssetLockProof::Instant(InstantAssetLockProof::new( + InstantLock::default(), + Transaction { + version: 3, + lock_time: 0, + input: Vec::new(), + output: Vec::new(), + special_transaction_payload: None, + }, + 0, + )) + } + + /// Runs [`submit_funding_with_fee_floor`] over `proof` from `floor`, each + /// attempt answered by `answer` (given its fee): the fee each attempt was + /// built with, the fees `on_attempt` was told, and the result. + async fn funded( + proof: &AssetLockProof, + floor: UserFeeIncrease, + answer: impl Fn(UserFeeIncrease) -> Result, + ) -> ( + Vec, + Vec, + Result, + ) { + let built = Mutex::new(Vec::new()); + let told = Mutex::new(Vec::new()); + let result = submit_funding_with_fee_floor( + proof, + floor, + |fee| told.lock().unwrap().push(fee), + |settings| { + let fee = settings.user_fee_increase.expect("every attempt states it"); + built.lock().unwrap().push(fee); + ready(answer(fee)) + }, + ) + .await; + ( + built.into_inner().unwrap(), + told.into_inner().unwrap(), + result, + ) + } + + /// Each attempt starts at the floor and is one higher than the last, so + /// none repeats a transition a node refused; the caller is told each fee + /// before it is built. + #[tokio::test(start_paused = true)] + async fn should_raise_the_fee_increase_from_the_floor_on_each_retry() { + let (built, told, result) = funded(&chain_proof(0), 7, |_| Err(cl_height_too_low())).await; + assert_eq!(built.len() as u32, FUNDING_ATTEMPTS); + assert_eq!(built.first(), Some(&7)); + assert!( + built.windows(2).all(|pair| pair[1] == pair[0] + 1), + "{built:?}" + ); + assert_eq!(told, built); + let failure = result.unwrap_err(); + assert_eq!(failure.kind, FundingFailureKind::PlatformBehindCore); + assert!(as_asset_lock_proof_cl_height_too_low(&failure.error).is_some()); + + // The next call, from one above, never repeats an attempt. + let last = *built.last().unwrap(); + let (next, _, _) = funded(&chain_proof(0), last + 1, |_| Ok(5)).await; + assert_eq!(next, vec![last + 1]); + } + + /// Retries stop at the cap, and a call that leaves no fee step says so + /// rather than ask for the next floor, which would submit nothing. + #[tokio::test(start_paused = true)] + async fn should_stop_funding_retries_at_the_fee_cap() { + let started = Instant::now(); + let (built, told, result) = funded(&chain_proof(0), MAX_FUNDING_FEE_INCREASE - 3, |_| { + Err(cl_height_too_low()) + }) + .await; + assert_eq!(built, vec![97, 98, 99, 100]); + assert_eq!(told, built); + let failure = result.unwrap_err(); + assert_eq!(failure.kind, FundingFailureKind::FeeCapReached); + assert!(as_asset_lock_proof_cl_height_too_low(&failure.error).is_some()); + // Three waits between four attempts, none after the last. + assert_eq!(started.elapsed(), Duration::from_secs(45)); + + // The floor after it submits nothing. + let (built, told, result) = + funded(&chain_proof(0), MAX_FUNDING_FEE_INCREASE + 1, |_| Ok(5)).await; + assert!(built.is_empty() && told.is_empty()); + let failure = result.unwrap_err(); + assert_eq!(failure.kind, FundingFailureKind::FeeCapReached); + assert!(matches!( + failure.error, + Error::FeeIncreaseFloorAboveCap { + floor: 101, + cap: 100 + } + )); + + // Any other answer at the cap is no claim about the fee steps. + let (built, _, result) = funded(&chain_proof(0), MAX_FUNDING_FEE_INCREASE, |_| { + Err(Error::TimeoutReached(Duration::from_secs(1), "t".into())) + }) + .await; + assert_eq!(built, vec![100]); + assert_eq!(result.unwrap_err().kind, FundingFailureKind::Unavailable); + } + + /// `?` carries a funding failure whole into a boxed standard error: the + /// kind comes back by downcast, the SDK error is its source, and an + /// unread lock record's read error is reached by walking the sources. + #[tokio::test] + async fn should_propagate_a_funding_failure_as_a_standard_error() { + type BoxedError = Box; + async fn fund() -> Result { + let (_, _, result) = + funded(&chain_proof(0), MAX_FUNDING_FEE_INCREASE + 1, |_| Ok(5)).await; + Ok(result?) + } + + let error = fund().await.unwrap_err(); + let failure = error + .downcast_ref::() + .expect("the funding failure itself"); + assert_eq!(failure.kind, FundingFailureKind::FeeCapReached); + assert_eq!(error.to_string(), failure.error.to_string()); + assert!(matches!( + std::error::Error::source(error.as_ref()).and_then(|e| e.downcast_ref::()), + Some(Error::FeeIncreaseFloorAboveCap { + floor: 101, + cap: 100 + }) + )); + + fn settle() -> Result { + let out_point = OutPoint::new(Txid::from_byte_array([4; 32]), 1); + let read = Err(Error::TimeoutReached(Duration::from_secs(1), "down".into())); + Ok(settle_top_up(out_point, 5, read)?) + } + + let error = settle().unwrap_err(); + let first: &(dyn std::error::Error + 'static) = error.as_ref(); + let chain: Vec<_> = std::iter::successors(Some(first), |e| e.source()).collect(); + assert!(matches!( + chain[0].downcast_ref::(), + Some(FundingFailure { + kind: FundingFailureKind::Submitted, + .. + }) + )); + assert!(matches!( + chain[1].downcast_ref::(), + Some(Error::AssetLockNotUsedUp { .. }) + )); + assert!(matches!( + chain[2].downcast_ref::(), + Some(UnsettledLockRecord::Unread(_)) + )); + assert!(matches!( + chain[3].downcast_ref::(), + Some(Error::TimeoutReached(_, message)) if message == "down" + )); + } + + #[tokio::test(start_paused = true)] + async fn should_report_a_funding_retry_that_lands() { + let (built, told, result) = funded(&chain_proof(0), 0, |fee| match fee { + 0 => Err(cl_height_too_low()), + _ => Ok(5), + }) + .await; + assert_eq!(built, vec![0, 1]); + assert_eq!(told, built); + assert_eq!(result.unwrap(), 5); + } + + /// The caller learns an attempt's fee before the attempt is built, so a + /// call it drops while the attempt is in flight still reports it. + #[tokio::test] + async fn should_tell_each_attempt_before_it_is_built() { + let told = Mutex::new(Vec::new()); + let proof = chain_proof(0); + let call = submit_funding_with_fee_floor( + &proof, + 4, + |fee| told.lock().unwrap().push(fee), + |_| std::future::pending::>(), + ); + assert!( + tokio::time::timeout(Duration::from_millis(10), call) + .await + .is_err(), + "the attempt never answers" + ); + assert_eq!(told.into_inner().unwrap(), vec![4]); + } + + /// Only the outpoint-matched report reads as consumed, and nothing but a + /// proof as done. + #[test] + fn should_map_funding_errors_to_what_the_caller_does_next() { + let consumed = |vout| -> ConsensusError { + IdentityAssetLockTransactionOutPointAlreadyConsumedError::new( + Txid::from_byte_array([4; 32]), + vout, + ) + .into() + }; + let broadcast = |cause| { + Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 10504, + message: "asset lock already consumed".to_string(), + cause, + }) + }; + let stale_islock = || consensus(InvalidInstantAssetLockProofSignatureError::new().into()); + let out_point = OutPoint::new(Txid::from_byte_array([4; 32]), 1); + for (error, proof, kind) in [ + ( + cl_height_too_low(), + chain_proof(1), + FundingFailureKind::PlatformBehindCore, + ), + ( + stale_islock(), + instant_proof(), + FundingFailureKind::NeedsChainLockProof, + ), + // A ChainLock proof has no smaller proof to fall back to. + (stale_islock(), chain_proof(1), FundingFailureKind::Rejected), + ( + consensus(consumed(1)), + chain_proof(1), + FundingFailureKind::ReportedConsumed, + ), + ( + broadcast(Some(consumed(1))), + chain_proof(1), + FundingFailureKind::ReportedConsumed, + ), + // Another outpoint, or a bare code, is no report about this lock. + ( + consensus(consumed(2)), + chain_proof(1), + FundingFailureKind::Rejected, + ), + ( + broadcast(None), + chain_proof(1), + FundingFailureKind::Rejected, + ), + ( + Error::NoAvailableAddressesToRetry(Box::new(broadcast(None))), + chain_proof(1), + FundingFailureKind::Rejected, + ), + ( + Error::DapiClientError(DapiClientError::NoAvailableAddresses), + chain_proof(1), + FundingFailureKind::Unavailable, + ), + ( + Error::TimeoutReached(Duration::from_secs(1), "t".into()), + chain_proof(1), + FundingFailureKind::Unavailable, + ), + ( + Error::InvalidProvedResponse("x".into()), + chain_proof(1), + FundingFailureKind::Unavailable, + ), + ( + Error::Generic("x".into()), + chain_proof(1), + FundingFailureKind::Unavailable, + ), + ( + Error::FeeIncreaseFloorAboveCap { + floor: 101, + cap: 100, + }, + chain_proof(1), + FundingFailureKind::FeeCapReached, + ), + ( + Error::AssetLockNotUsedUp { + out_point, + record: UnsettledLockRecord::NotPresent, + }, + chain_proof(1), + FundingFailureKind::Submitted, + ), + ] { + assert_eq!(funding_failure_kind(&error, &proof), kind, "{error}"); + } + } + + /// A top-up settles only on a record showing its lock used up. Any other + /// record, or one that could not be read, leaves it submitted, with the + /// read's own error kept. + #[test] + fn should_settle_a_top_up_only_on_a_record_of_its_lock_used_up() { + let out_point = OutPoint::new(Txid::from_byte_array([4; 32]), 1); + let unsettled = |record| { + let failure = settle_top_up(out_point, 5, record).expect_err("not settled"); + assert_eq!(failure.kind, FundingFailureKind::Submitted); + failure.error + }; + + assert_eq!( + settle_top_up(out_point, 5, Ok(StoredAssetLockInfo::FullyConsumed)).unwrap(), + 5 + ); + let error = unsettled(Ok(StoredAssetLockInfo::NotPresent)); + assert!( + error.to_string().ends_with(&format!( + "asset lock {out_point} is not proved used up: Platform has no record of its use" + )), + "{error}" + ); + let left = AssetLockValue::new(2_000, vec![0x76], 1_500, vec![], PlatformVersion::latest()) + .expect("an asset lock value"); + let error = unsettled(Ok(StoredAssetLockInfo::PartiallyConsumed(left))); + assert!( + matches!( + error, + Error::AssetLockNotUsedUp { + record: UnsettledLockRecord::PartiallyConsumed(1_500), + .. + } + ), + "{error}" + ); + assert!(error + .to_string() + .contains("1500 of its credits are unspent")); + let error = unsettled(Err(Error::TimeoutReached( + Duration::from_secs(1), + "down".into(), + ))); + assert!( + matches!( + &error, + Error::AssetLockNotUsedUp { + record: UnsettledLockRecord::Unread(read), + .. + } if matches!(**read, Error::TimeoutReached(..)) + ), + "{error}" + ); + assert!(error.to_string().contains("its record could not be read")); + } + + /// The next proof is at the mined height until a ChainLock proof was + /// sent, then one block above the last, never below the mined height, + /// and only once a ChainLock the caller verified covers it. + #[test] + fn should_give_the_next_chain_lock_proof_one_block_above_the_last() { + let out_point = OutPoint::new(Txid::from_byte_array([4; 32]), 1); + let height = |last, best| { + next_chain_lock_proof(out_point, 1_000, last, best).map(|proof| { + assert_eq!(proof.out_point, out_point); + proof.core_chain_locked_height + }) + }; + assert_eq!(height(None, 1_000), Some(1_000)); + assert_eq!(height(None, 999), None); + assert_eq!(height(Some(1_000), 1_005), Some(1_001)); + assert_eq!(height(Some(1_000), 1_000), None); + assert_eq!(height(Some(1_003), 1_005), Some(1_004)); + assert_eq!(height(Some(900), 1_005), Some(1_000)); + assert_eq!(height(Some(u32::MAX), u32::MAX), None); + } +} + +#[cfg(test)] +#[allow(clippy::result_large_err)] +mod refused_transition_tests { + //! The recovery contract against nodes that keep every transition their + //! CheckTx refused, keyed as Tenderdash keys its cache: by the SHA-256 of + //! the transition's bytes, here those of a real IdentityTopUp signed by + //! the deterministic signer the SDK uses. + + use std::collections::HashSet; + use std::sync::Mutex; + + use dpp::dashcore::hashes::sha256; + use dpp::dashcore::Txid; + use dpp::identity::Identity; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::identity_topup_transition::methods::IdentityTopUpTransitionMethodsV0; + use dpp::state_transition::identity_topup_transition::IdentityTopUpTransition; + use dpp::version::PlatformVersion; + use dpp::ProtocolError; + use rs_dapi_client::transport::TransportError; + use rs_dapi_client::DapiClientError; + + use super::*; + + /// What a node answers to bytes whose hash it holds. + #[derive(Clone, Copy, Debug)] + enum Hit { + /// Tenderdash 1.8 with rs-dapi: the duplicate's CheckTx passes, and + /// DAPI answers Internal, which every node holding the hash repeats. + Internal, + /// Tenderdash after #1530: the cached rejection again. + CachedRejection, + } + + /// Platform's nodes, all holding the hashes of the transitions their + /// CheckTx refused, with Platform's view of Core at `core_height`. + struct Network { + hit: Hit, + core_height: Mutex, + refused: Mutex>, + /// The (proof height, fee) pairs that reached CheckTx. + checked: Mutex>, + } + + impl Network { + fn new(hit: Hit, core_height: CoreBlockHeight) -> Self { + Self { + hit, + core_height: Mutex::new(core_height), + refused: Mutex::new(HashSet::new()), + checked: Mutex::new(Vec::new()), + } + } + + /// The top-up of one identity from `proof` at `fee`, as it is sent. + fn top_up(proof: &ChainAssetLockProof, fee: UserFeeIncrease) -> Vec { + let version = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity([3; 32].into(), version).expect("an identity"); + IdentityTopUpTransition::try_from_identity_with_private_key( + &identity, + AssetLockProof::Chain(proof.clone()), + &[0x42; 32], + fee, + version, + None, + ) + .expect("the top-up is signed") + .serialize_to_bytes() + .expect("the top-up serializes") + } + + fn submit(&self, proof: &ChainAssetLockProof, fee: UserFeeIncrease) -> Result<(), Error> { + let key = sha256::Hash::hash(&Self::top_up(proof, fee)).to_byte_array(); + let height = proof.core_chain_locked_height; + if self.refused.lock().unwrap().contains(&key) { + return Err(match self.hit { + Hit::Internal => Error::from(DapiClientError::NoAvailableAddressesToRetry( + Box::new(TransportError::Grpc(dapi_grpc::tonic::Status::internal( + "State Transition processing error: the state transition is \ + valid, create a new state transition with different hash", + ))), + )), + Hit::CachedRejection => cl_height_too_low(height, height - 1), + }); + } + self.checked.lock().unwrap().push((height, fee)); + let core_height = *self.core_height.lock().unwrap(); + if height > core_height { + self.refused.lock().unwrap().insert(key); + return Err(cl_height_too_low(height, core_height)); + } + Ok(()) + } + + /// One funding call from `floor`: the fees it built, and its result. + async fn fund( + &self, + proof: &ChainAssetLockProof, + floor: UserFeeIncrease, + ) -> (Vec, Result<(), FundingFailure>) { + let built = Mutex::new(Vec::new()); + let result = submit_funding_with_fee_floor( + &AssetLockProof::Chain(proof.clone()), + floor, + |fee| built.lock().unwrap().push(fee), + |settings| { + let fee = settings.user_fee_increase.expect("every attempt states it"); + std::future::ready(self.submit(proof, fee)) + }, + ) + .await; + (built.into_inner().unwrap(), result) + } + + /// Calls from floor 0 up, each from one above the last fee the one + /// before built, until one leaves no fee step or lands: the kinds of + /// the failures on the way, and the last call's result. + async fn fund_until_capped( + &self, + proof: &ChainAssetLockProof, + ) -> (Vec, Result<(), FundingFailure>) { + let (mut kinds, mut floor) = (Vec::new(), 0); + loop { + let (built, result) = self.fund(proof, floor).await; + match &result { + Err(failure) if failure.kind != FundingFailureKind::FeeCapReached => { + kinds.push(failure.kind); + floor = built.last().expect("a call below the cap builds") + 1; + } + _ => return (kinds, result), + } + } + } + } + + fn cl_height_too_low(proof: CoreBlockHeight, platform: CoreBlockHeight) -> Error { + Error::Protocol(ProtocolError::ConsensusError(Box::new( + InvalidAssetLockProofCoreChainHeightError::new(proof, platform).into(), + ))) + } + + /// A lock capped at proof height h while Platform was behind is never + /// funded by that proof again, at any fee, after Platform catches up: the + /// nodes hold every pair, and none reaches CheckTx. The proof at h+1, from + /// floor 0, lands at its first attempt. + #[tokio::test(start_paused = true)] + async fn should_fund_a_capped_lock_only_with_a_fresh_chain_lock_proof() { + let out_point = OutPoint::new(Txid::from_byte_array([4; 32]), 1); + let mined = 1_000; + for hit in [Hit::Internal, Hit::CachedRejection] { + let network = Network::new(hit, mined - 1); + let proof = next_chain_lock_proof(out_point, mined, None, mined) + .expect("the mined height is ChainLocked"); + assert_eq!(proof.core_chain_locked_height, mined); + + // Platform is behind: every fee step of the proof is refused. + let (kinds, capped) = network.fund_until_capped(&proof).await; + assert!( + kinds + .iter() + .all(|kind| *kind == FundingFailureKind::PlatformBehindCore), + "{hit:?}: {kinds:?}" + ); + assert_eq!( + capped.unwrap_err().kind, + FundingFailureKind::FeeCapReached, + "{hit:?}" + ); + let checked = network.checked.lock().unwrap().clone(); + assert_eq!( + checked, + (0..=MAX_FUNDING_FEE_INCREASE) + .map(|fee| (mined, fee)) + .collect::>(), + "{hit:?}: each pair reaches CheckTx once" + ); + + // Platform catches up; the same proof from floor 0 never lands. + *network.core_height.lock().unwrap() = mined + 5; + let (_, again) = network.fund_until_capped(&proof).await; + assert!(again.is_err(), "{hit:?}: a refused pair was admitted"); + assert_eq!( + network.checked.lock().unwrap().len(), + checked.len(), + "{hit:?}: a held pair reached CheckTx" + ); + + // One block above it, from floor 0, lands at the first attempt. + let next = next_chain_lock_proof( + out_point, + mined, + Some(proof.core_chain_locked_height), + mined + 5, + ) + .expect("a ChainLock covers the next height"); + assert_eq!(next.core_chain_locked_height, mined + 1); + let (built, landed) = network.fund(&next, 0).await; + assert!(landed.is_ok(), "{hit:?}"); + assert_eq!(built, vec![0], "{hit:?}"); + } + } + + /// The pair (proof, fee) is the transition's identity: the same pair + /// gives the same bytes, and the next height gives new ones at fee 0. + #[test] + fn should_sign_new_bytes_only_for_a_new_proof_or_fee() { + let proof = |height| ChainAssetLockProof { + core_chain_locked_height: height, + out_point: OutPoint::new(Txid::from_byte_array([4; 32]), 1), + }; + assert_eq!( + Network::top_up(&proof(1_000), 0), + Network::top_up(&proof(1_000), 0) + ); + assert_ne!( + Network::top_up(&proof(1_000), 0), + Network::top_up(&proof(1_000), 1) + ); + assert_ne!( + Network::top_up(&proof(1_000), 0), + Network::top_up(&proof(1_001), 0) + ); + } +} diff --git a/packages/rs-sdk/src/platform/transition/put_identity.rs b/packages/rs-sdk/src/platform/transition/put_identity.rs index 0a27297e9d6..0c926466f78 100644 --- a/packages/rs-sdk/src/platform/transition/put_identity.rs +++ b/packages/rs-sdk/src/platform/transition/put_identity.rs @@ -346,8 +346,8 @@ async fn identity_create_with_asset_lock_and_private_key( &self, @@ -47,12 +65,67 @@ pub trait TopUpIdentity: Waitable { ) -> Result where AS: dpp::key_wallet::signer::Signer + Send + Sync; + + /// Tops up an existing identity like [`Self::top_up_identity_with_signer`], + /// through [`submit_funding_with_fee_floor`] from `user_fee_increase_floor` + /// (`on_attempt` gets each attempt's fee before it is built), and settles + /// the lock. The wait proves only a snapshot of the identity's balance, + /// which binds neither this top-up nor this lock, so the lock is settled + /// only when its proved record ([`Sdk::asset_lock_outpoint_state`]) shows + /// it used up. Otherwise the failure is [`FundingFailureKind::Submitted`], + /// with [`Error::AssetLockNotUsedUp`] naming the record, or the error that + /// kept it unread. + /// + /// `Ok` carries the wait's snapshot, which need not include this top-up. + /// It settles the lock only: the record names no transition, so it does + /// not prove this identity was credited. + #[cfg(feature = "core_key_wallet")] + async fn top_up_identity_settled( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + user_fee_increase_floor: UserFeeIncrease, + on_attempt: A, + ) -> Result + where + AS: dpp::key_wallet::signer::Signer + Send + Sync, + A: Fn(UserFeeIncrease) + Send + Sync, + { + let out_point = asset_lock_proof.out_point().ok_or_else(|| FundingFailure { + kind: FundingFailureKind::Unavailable, + error: Error::InvalidRequest("the asset lock proof names no outpoint".to_string()), + })?; + let balance = submit_funding_with_fee_floor( + &asset_lock_proof, + user_fee_increase_floor, + on_attempt, + |settings| { + self.top_up_identity_with_signer( + sdk, + asset_lock_proof.clone(), + asset_lock_proof_path, + asset_lock_signer, + Some(settings), + ) + }, + ) + .await?; + settle_top_up( + out_point, + balance, + sdk.asset_lock_outpoint_state(&out_point).await, + ) + } } /// Balance operations that also expose the committed proof metadata. #[async_trait::async_trait] pub trait TopUpIdentityWithMetadata: Waitable { - /// Returns the confirmed balance result and its proof metadata. + /// Returns the balance in the wait's proof and that proof's metadata. + /// The balance is a snapshot, which a node may answer from a block + /// before this top-up: it does not prove this top-up executed. #[cfg(feature = "core_key_wallet")] async fn top_up_identity_with_signer_with_metadata( &self, diff --git a/packages/wasm-sdk/src/error.rs b/packages/wasm-sdk/src/error.rs index a93fd6e9a1c..1323efffbf9 100644 --- a/packages/wasm-sdk/src/error.rs +++ b/packages/wasm-sdk/src/error.rs @@ -289,6 +289,15 @@ impl From for WasmSdkError { retriable, ), EncryptedFor(e) => e.into(), + // Refusals of SDK operations this binding does not expose. + error @ (DpnsNameContested { .. } + | FeeIncreaseFloorAboveCap { .. } + | AssetLockNotUsedUp { .. }) => Self::new( + WasmSdkErrorKind::Generic, + error.to_string(), + None, + retriable, + ), error @ ProtocolVersionNotActive { .. } => { // The SDK's message names what is missing; a failed query for the network's // version is its source, and JS sees only the message. The refusal holds