diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 29986168bc0..ce763889348 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -148,6 +148,23 @@ sealed class DashSdkError( cause, ) + /** + * `ErrorMasternodeWithdrawalUnconfirmed` (native code 42). A + * masternode (evonode) identity credit withdrawal was broadcast and + * accepted, but its execution result couldn't be confirmed — it may + * already have executed, and the identity nonce was consumed for it, + * so a blind retry could submit a SECOND withdrawal. Do NOT retry; + * re-read the identity's claimable balance and reconcile first. The + * Android analog of Swift's + * `PlatformWalletError.masternodeWithdrawalUnconfirmed`. + */ + class MasternodeWithdrawalUnconfirmed(message: String, cause: Throwable? = null) : + PlatformWallet( + "$message (do NOT retry: the withdrawal may already have executed; " + + "re-read the claimable balance first)", + cause, + ) + /** * `ErrorShieldedBroadcastFailed` (native code 16). A DEFINITIVE * non-execution outcome — relay/CheckTx rejected the transaction @@ -481,6 +498,7 @@ sealed class DashSdkError( 18 -> PlatformWallet.ShieldedSpendUnconfirmed(message, cause) // ErrorShieldedSpendUnconfirmed 19 -> PlatformWallet.ShieldedNoRecordedAnchor(message, cause) // ErrorShieldedNoRecordedAnchor 20 -> PlatformWallet.TransactionBroadcastUnconfirmed(message, cause) // ErrorTransactionBroadcastUnconfirmed + 42 -> PlatformWallet.MasternodeWithdrawalUnconfirmed(message, cause) // ErrorMasternodeWithdrawalUnconfirmed 22 -> PlatformWallet.CoreInsufficientFunds(message, cause) // ErrorCoreInsufficientFunds 23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 444573c5dbc..af417366e7b 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -368,6 +368,17 @@ pub enum PlatformWalletFFIResultCode { /// language-surface compatibility; it is a Platform Payment-account /// shortfall, not a shielded-note shortfall. ErrorShieldedInsufficientBalance = 41, + /// Maps `PlatformWalletError::MasternodeWithdrawalUnconfirmed`. A + /// masternode (evonode) identity credit withdrawal was broadcast and + /// accepted, but its execution result could not be confirmed — it may + /// already have executed, and the identity nonce was consumed for it, so + /// a blind retry could submit a SECOND withdrawal. The host must NOT + /// retry; it must re-read the identity's claimable balance (and the + /// payout) and let the user decide from the reconciled state. Definitive + /// rejections (consensus errors, transport verdicts) keep their ordinary + /// codes and stay retryable. Siblings: [`Self::ErrorShieldedSpendUnconfirmed`], + /// [`Self::ErrorTransactionBroadcastUnconfirmed`]. + ErrorMasternodeWithdrawalUnconfirmed = 42, /// The named thing does not exist. /// @@ -592,6 +603,11 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TransactionBroadcastUnconfirmed(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed } + // Evonode claim sibling: the ambiguous "may have executed, nonce + // consumed" outcome must reach the host as a typed do-not-retry code. + PlatformWalletError::MasternodeWithdrawalUnconfirmed { .. } => { + PlatformWalletFFIResultCode::ErrorMasternodeWithdrawalUnconfirmed + } PlatformWalletError::TransactionBroadcast(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastRejected } diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 19aa69700c2..10d89af2722 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -57,6 +57,7 @@ pub mod logging; pub mod managed_identity; pub mod manager; pub mod manager_diagnostics; +pub mod masternode_withdrawal; pub mod memory_explorer; pub mod mnemonic_words; pub mod persistence; diff --git a/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs b/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs new file mode 100644 index 00000000000..45947086bdc --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs @@ -0,0 +1,371 @@ +//! FFI bindings for claiming (withdrawing) a masternode identity's Platform +//! credits — `platform_wallet::wallet::masternode_withdrawal`. +//! +//! Two entry points, both keyed by `(manager, wallet_id, pro_tx_hash)`: +//! +//! - [`platform_wallet_manager_masternode_withdrawal_keys`]: which signing +//! keys this wallet holds (owner / transfer) and the registered payout +//! address — the UI gates the Withdraw button and the destination field +//! on this, and it is the same resolution the withdraw path signs with. +//! - [`platform_wallet_manager_masternode_withdraw`]: the claim itself, +//! signed through the host's mnemonic resolver like every other +//! wallet-key signing path (`core_wallet_sign_message`, +//! `core_wallet_tx_builder_finalize`). +//! +//! The masternode itself is resolved from the same provider-transaction +//! aggregation `platform_wallet_manager_list_masternodes` renders, so the +//! owner key hash / payout script the claim uses are exactly the ones the +//! list shows. + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::str::FromStr; +use std::sync::Arc; + +use dashcore::Address as DashAddress; +use platform_wallet::{ + MasternodeWithdrawalKey, MasternodeWithdrawalKeys, MasternodeWithdrawalRequest, PlatformWallet, +}; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; + +use crate::core_wallet_types::{aggregate_masternodes, ListMembership, MasternodeAggregate}; +use crate::error::*; +use crate::handle::*; +use crate::runtime::block_on_worker; +use crate::{check_ptr, unwrap_result_or_return}; + +/// Preflight result of [`platform_wallet_manager_masternode_withdrawal_keys`]. +/// +/// `payout_address` is a heap C string (or null when the node has no +/// encodable payout script) — free it with `platform_wallet_string_free`. +#[repr(C)] +pub struct MasternodeWithdrawalKeysFFI { + /// This wallet holds the masternode's owner key (`ProviderOwnerKeys`). + pub owner_key_in_wallet: bool, + /// `ProviderOwnerKeys` index of the owner key; valid only when + /// `owner_key_in_wallet`. + pub owner_key_index: u32, + /// This wallet holds the payout-script (identity `TRANSFER`) key, so a + /// destination other than the payout address may be chosen. + pub transfer_key_in_wallet: bool, + /// Registered payout address (base58), or null. + pub payout_address: *mut c_char, +} + +impl MasternodeWithdrawalKeysFFI { + fn empty() -> Self { + Self { + owner_key_in_wallet: false, + owner_key_index: 0, + transfer_key_in_wallet: false, + payout_address: std::ptr::null_mut(), + } + } +} + +/// Resolve `(wallet, masternode aggregate)` for a `pro_tx_hash` (wire +/// order) from the manager — the same aggregation the masternode list +/// renders. Clones the `Arc` out so callers can do network +/// work after the handle-storage guard is released. +unsafe fn resolve_masternode( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, +) -> Result<(Arc, MasternodeAggregate), PlatformWalletFFIResult> { + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); + + let resolved = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + let wallet = manager.get_wallet_blocking(&wid)?; + let (_network, txs, dml, _operator_index, _platform_index) = + manager.provider_masternode_txs_blocking(&wid)?; + let membership = |pro_tx_hash: &[u8; 32]| -> ListMembership { + match &dml { + None => ListMembership::ListUnavailable, + Some(map) => match map.get(pro_tx_hash) { + Some(true) => ListMembership::ValidEntry, + Some(false) => ListMembership::InvalidEntry, + None => ListMembership::Absent, + }, + } + }; + let aggregate = + aggregate_masternodes(txs.iter().map(|(h, p, tx)| (*h, *p, tx)), membership) + .into_iter() + .find(|mn| mn.pro_tx_hash == target); + Some((wallet, aggregate)) + }); + + match resolved { + None => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "invalid platform wallet manager handle", + )), + Some(None) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "wallet not found in the manager", + )), + Some(Some((_, None))) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "no masternode with this proTxHash in the wallet's provider transactions", + )), + Some(Some((wallet, Some(mn)))) => Ok((wallet, mn)), + } +} + +/// Which masternode-withdrawal signing keys this wallet holds for the +/// masternode `pro_tx_hash` (32 bytes, wire order), plus its registered +/// payout address. Seedless — no resolver needed. +/// +/// `owner_key_index_hint` (honoured when `has_owner_key_index_hint`) is a +/// durable `ProviderOwnerKeys` index the host already knows for this owner +/// key — the persisted ownership row or its own address join. Rust VERIFIES +/// it (derives the key at that index and compares hash160s) before falling +/// back to the pool-depth scan, so restored wallets whose in-memory pool has +/// no watermark still resolve an owner key above the default window. +/// +/// # Safety +/// `wallet_id` / `pro_tx_hash` must point at 32 readable bytes; `out` must +/// be writable. Free `out.payout_address` with `platform_wallet_string_free`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_masternode_withdrawal_keys( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + has_owner_key_index_hint: bool, + owner_key_index_hint: u32, + out: *mut MasternodeWithdrawalKeysFFI, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(out); + *out = MasternodeWithdrawalKeysFFI::empty(); + + let (wallet, mn) = + unwrap_result_or_return!(resolve_masternode(manager_handle, wallet_id, pro_tx_hash)); + let keys = unwrap_result_or_return!(wallet.masternode_withdrawal_keys( + mn.owner_key_hash.as_ref(), + mn.payout_script.as_deref(), + has_owner_key_index_hint.then_some(owner_key_index_hint), + )); + + let payout_address = match keys.payout_address { + Some(address) => unwrap_result_or_return!(CString::new(address)).into_raw(), + None => std::ptr::null_mut(), + }; + *out = MasternodeWithdrawalKeysFFI { + owner_key_in_wallet: keys.owner_key.is_some(), + owner_key_index: keys + .owner_key + .as_ref() + .map(|(index, _)| *index) + .unwrap_or(0), + transfer_key_in_wallet: keys.transfer_key.is_some(), + payout_address, + }; + PlatformWalletFFIResult::ok() +} + +/// Claim (withdraw) `amount_credits` from the masternode identity of +/// `pro_tx_hash` (32 bytes, wire order) to L1 via an Identity Credit +/// Withdrawal, signed with a wallet-held key derived through the host's +/// mnemonic resolver. Writes the identity's remaining balance to +/// `out_new_balance`. +/// +/// - `use_owner_key == true`: sign with the `ProviderOwnerKeys` key. +/// Platform pays the registered payout address; `dest_address` MUST be +/// null. +/// - `use_owner_key == false`: sign with the payout-script (`TRANSFER`) +/// key. `dest_address` (a base58 address for the wallet's network) is the +/// destination; null ⇒ the registered payout address. +/// +/// `owner_key_index_hint` / `has_owner_key_index_hint`: as on +/// `platform_wallet_manager_masternode_withdrawal_keys` — a verified-not- +/// trusted durable index for the owner key. +/// +/// Fails WITHOUT broadcasting when the wallet doesn't hold the requested +/// key, the identity doesn't carry the matching key, or the destination is +/// invalid. The resolver is invoked exactly once, at signing time; the +/// handle-storage guard is not held across it. +/// +/// Outcome codes after the transition leaves the wallet: a definitive +/// rejection returns an ordinary error (retryable); an AMBIGUOUS outcome — +/// broadcast accepted (or its ACK lost) and the result wait failed — returns +/// `ErrorMasternodeWithdrawalUnconfirmed` (42): the claim may have executed +/// and the identity nonce was consumed, so the host must NOT retry until it +/// has re-read the claimable balance. +/// +/// # Safety +/// `wallet_id` / `pro_tx_hash` must point at 32 readable bytes; +/// `dest_address` is null or a NUL-terminated UTF-8 string; +/// `mnemonic_resolver_handle` must come from +/// `dash_sdk_mnemonic_resolver_create` and outlive this call; +/// `out_new_balance` must be writable. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_masternode_withdraw( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + amount_credits: u64, + use_owner_key: bool, + dest_address: *const c_char, + has_owner_key_index_hint: bool, + owner_key_index_hint: u32, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + out_new_balance: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(out_new_balance); + *out_new_balance = 0; + + if use_owner_key && !dest_address.is_null() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "an owner-key withdrawal pays the registered payout address; dest_address must be null", + ); + } + + let (wallet, mn) = + unwrap_result_or_return!(resolve_masternode(manager_handle, wallet_id, pro_tx_hash)); + let network = wallet.network(); + + let destination = if dest_address.is_null() { + None + } else { + let text = unwrap_result_or_return!(CStr::from_ptr(dest_address).to_str()).to_string(); + let unchecked = unwrap_result_or_return!(DashAddress::from_str(&text)); + Some(unwrap_result_or_return!(unchecked.require_network(network))) + }; + + // Key resolution is blocking (wallet-manager read lock + seedless + // derive-and-compare) — runs here on the caller thread, before the + // async claim below. + let keys: MasternodeWithdrawalKeys = unwrap_result_or_return!(wallet + .masternode_withdrawal_keys( + mn.owner_key_hash.as_ref(), + mn.payout_script.as_deref(), + has_owner_key_index_hint.then_some(owner_key_index_hint), + )); + + let request = MasternodeWithdrawalRequest { + pro_tx_hash: mn.pro_tx_hash, + owner_key_hash: mn.owner_key_hash, + amount_credits, + signing_key: if use_owner_key { + MasternodeWithdrawalKey::Owner + } else { + MasternodeWithdrawalKey::Transfer + }, + destination, + }; + + // SAFETY: `signer_addr` came from `mnemonic_resolver_handle`, which the + // caller keeps alive for this call; the calling thread blocks until the + // future completes, so the signer is dropped before this frame returns. + let signer_addr = mnemonic_resolver_handle as usize; + let wallet_id_bytes = wallet.wallet_id(); + let new_balance = unwrap_result_or_return!(block_on_worker(async move { + let signer = MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id_bytes, + network, + ); + wallet.masternode_withdraw(request, &keys, &signer).await + })); + + *out_new_balance = new_balance; + PlatformWalletFFIResult::ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use rs_sdk_ffi::{dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy}; + use std::os::raw::c_void; + + unsafe extern "C" fn never_resolve( + _ctx: *const c_void, + _wallet_id_bytes: *const u8, + _out_buf: *mut c_char, + _out_capacity: usize, + _out_len: *mut usize, + ) -> i32 { + unreachable!("rejected before any mnemonic is resolved"); + } + + unsafe extern "C" fn noop_destroy(_ctx: *mut c_void) {} + + #[test] + fn owner_key_withdrawal_rejects_a_destination_before_touching_handles() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), never_resolve, noop_destroy) + }; + let wallet_id = [0u8; 32]; + let pro_tx_hash = [1u8; 32]; + let dest = CString::new("yRd4FhXfVGHXpsuZXPNkMrfD9GVj46pnjt").unwrap(); + let mut out_balance = u64::MAX; + let result = unsafe { + platform_wallet_manager_masternode_withdraw( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + 1_000_000, + true, + dest.as_ptr(), + false, + 0, + resolver, + &mut out_balance, + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + assert_eq!(out_balance, 0, "out parameter is initialised on every path"); + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + #[test] + fn unknown_manager_handle_is_an_invalid_handle() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), never_resolve, noop_destroy) + }; + let wallet_id = [0u8; 32]; + let pro_tx_hash = [1u8; 32]; + let mut out_balance = 0u64; + let result = unsafe { + platform_wallet_manager_masternode_withdraw( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + 1_000_000, + false, + std::ptr::null(), + false, + 0, + resolver, + &mut out_balance, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + + let mut out = MasternodeWithdrawalKeysFFI::empty(); + let result = unsafe { + platform_wallet_manager_masternode_withdrawal_keys( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + false, + 0, + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert!(out.payout_address.is_null()); + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } +} diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index bf53c57171f..7bc83a750f7 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -309,84 +309,6 @@ pub unsafe extern "C" fn platform_wallet_manager_free_masternodes( let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(entries, count)); } -/// Claim (withdraw) credits from a masternode's Platform identity to L1 -/// via an Identity Credit Withdrawal, signed with the wallet-held OWNER -/// key. Writes the remaining balance to `out_new_balance`. -/// -/// - `pro_tx_hash`: 32 bytes in WIRE order (as stored). The masternode -/// identity id is the **display-order** (reversed) form, so this fn -/// reverses before fetching — same orientation as the balance fetch. -/// - `owner_key_index`: the ProviderOwnerKeys derivation index the app -/// resolved from the persisted address join (in-memory pools may be -/// empty for imported wallets, so the index is passed in rather than -/// re-derived here). -/// - `dest_address` MUST be null for the owner-key path: Platform routes -/// an owner-key withdrawal to the registered payout address; a -/// destination can't be chosen. (`use_owner_key == false` / a TRANSFER -/// destination is a documented follow-up.) -/// -/// Orchestration (all in Rust, per `swift-sdk/CLAUDE.md`): -/// 1. Resolve the wallet + masternode by `pro_tx_hash`; read its -/// `owner_key_hash`. -/// 2. `Identity::fetch_by_identifier(reversed(pro_tx_hash))`. -/// 3. GUARD: `select_owner_withdrawal_key(identity.public_keys(), -/// owner_key_hash)` — if `None`, return `InvalidIdentityData` WITHOUT -/// broadcasting (signing with an unrecognised key wastes the attempt). -/// 4. Derive the ECDSA owner private key at `owner_key_index` on the -/// ProviderOwnerKeys account; build a `Signer` -/// over it; `withdraw_credits_with_signer(identity, None, amount, -/// Some(matched_owner_key), signer, None)`. -/// -/// NOTE: steps 1-3 are wired below; step 4 (the internal owner-key -/// `Signer` + ECDSA owner-key derivation + DPP -/// signature encoding for `ECDSA_HASH160`) is a NEW money-signing -/// component with no existing production analogue (identity ops sign via -/// an external Swift signer). It is gated behind a distinct error until it -/// can be built and verified against a real testnet claim, so the -/// end-to-end plumbing (UI → wrapper → FFI → fetch → guard → error) is -/// exercisable without risking a malformed money transition. -#[no_mangle] -pub unsafe extern "C" fn platform_wallet_manager_masternode_withdraw( - manager_handle: Handle, - wallet_id: *const u8, - pro_tx_hash: *const u8, - amount: u64, - owner_key_index: u32, - dest_address: *const std::os::raw::c_char, - use_owner_key: bool, - out_new_balance: *mut u64, -) -> PlatformWalletFFIResult { - check_ptr!(wallet_id); - check_ptr!(pro_tx_hash); - check_ptr!(out_new_balance); - - use crate::error::PlatformWalletFFIResultCode; - - // Owner-key path only, for now. - if !use_owner_key { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - "TRANSFER-key masternode withdrawal is not yet supported; use the owner key", - ); - } - if !dest_address.is_null() { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - "owner-key withdrawal pays the registered payout address; dest_address must be null", - ); - } - - // See the doc comment: steps 1-3 (resolve → fetch → guard) plus the - // owner-key `Signer` derivation + sign are the - // remaining verified-implementation work. Surface a distinct, non-fatal - // error rather than broadcasting an unverified money transition. - let _ = (manager_handle, amount, owner_key_index); - PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - "masternode owner-key withdrawal is not yet enabled (pending verified signer)", - ) -} - /// Destroy a PlatformWallet handle. #[no_mangle] pub unsafe extern "C" fn platform_wallet_destroy(handle: Handle) -> PlatformWalletFFIResult { diff --git a/packages/rs-platform-wallet/src/broadcast_outcome.rs b/packages/rs-platform-wallet/src/broadcast_outcome.rs new file mode 100644 index 00000000000..82648b66799 --- /dev/null +++ b/packages/rs-platform-wallet/src/broadcast_outcome.rs @@ -0,0 +1,107 @@ +//! Classifying the outcome of a state-transition broadcast / result wait. +//! +//! Shared by every Platform money-moving path that must keep "definitely +//! rejected, safe to retry" apart from "ambiguous, may have executed, do +//! not retry" (shielded spends, masternode credit withdrawals). The shapes +//! are SDK-transport facts, not operation-specific, so they live here +//! rather than behind the `shielded` feature. + +/// Whether an SDK error carries Platform's own consensus verdict on the +/// transition. Two shapes qualify: +/// +/// - `Error::Protocol(ProtocolError::ConsensusError(_))` — DAPI attached the +/// serialized consensus error as gRPC metadata +/// (`dash-serialized-consensus-error-bin`), which the dapi-client decodes +/// on any failed request. This is how a CheckTx rejection of the +/// transition surfaces from `broadcast()` (rs-dapi's +/// `map_broadcast_error` decodes the consensus error from Tenderdash's +/// `info` field and `TenderdashStatus` re-attaches it as metadata); +/// - a `StateTransitionBroadcastError` whose `cause` deserialized from +/// non-empty consensus `data` — the wait-stream error envelope for a +/// transition Platform executed and rejected on its merits. +/// +/// Recurses through a `NoAvailableAddressesToRetry` envelope, mirroring +/// [`crate::error::as_address_invalid_nonce`]. +/// +/// Only these prove the transition was evaluated and REJECTED. Everything +/// else — transport errors, timeouts, `AlreadyExists` (which proves the +/// opposite: the transition is already in the mempool or on chain), +/// DAPI-internal failures, cause-less broadcast envelopes (the shape DAPI +/// uses for its own wait-side timeouts) — leaves the outcome unknown. +pub(crate) fn carries_consensus_rejection(err: &dash_sdk::Error) -> bool { + match err { + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(_)) => true, + dash_sdk::Error::StateTransitionBroadcastError(e) => e.cause.is_some(), + dash_sdk::Error::NoAvailableAddressesToRetry(inner) => carries_consensus_rejection(inner), + _ => false, + } +} + +/// Whether a failed `broadcast()` call DEFINITIVELY left the transition +/// out of every mempool, so any note reservations may be released and the +/// caller may rebuild and retry: +/// +/// - a consensus verdict ([`carries_consensus_rejection`]): CheckTx +/// evaluated the transition and refused it; +/// - a gRPC response whose status code is a server-side rejection or a +/// connection-establishment failure. `Unavailable` is the common shape +/// of a connect-refused/offline attempt — classifying it as definitive +/// keeps the no-network failure's notes immediately re-spendable +/// instead of stranding them until the next restart — and rejection +/// codes (`InvalidArgument`, `ResourceExhausted` = mempool full, …) are +/// verdicts that the tx was refused admission; +/// - no usable DAPI addresses at all (nothing was ever sent). +/// +/// `Unavailable` is NOT an absolute never-delivered guarantee: HTTP/2 +/// stream resets after the request bytes left can surface the same code, +/// and the dapi-client's cross-address retry only retains the LAST +/// transport error, so an earlier-attempt delivery can hide behind a +/// later attempt's `Unavailable`. Releasing the notes in that residual +/// window is still fund-safe — the authoritative no-reuse guarantee is +/// the on-chain nullifier set, so a re-selected note at worst wastes a +/// ~30 s proof on a nullifier-already-used rejection (see the +/// `finalize_pending` downgrade rationale in `unshield`); never fund +/// loss. The trade is deliberate: UX for the dominant offline case over +/// strict conservatism in a rare race. +/// +/// Everything else leaves the outcome unknown and the caller must fall +/// through to the result wait instead of failing: `AlreadyExists` proves +/// the tx IS in the mempool or on chain (a lost-ACK attempt was re-sent +/// by the dapi-client retry and hit tenderdash's dedupe), and +/// timeout/cancellation/no-response shapes (`TimeoutReached`, +/// `Cancelled`, gRPC `DeadlineExceeded`/`Cancelled`, plus +/// `Internal`/`Unknown`/`Aborted`/`DataLoss`, which DAPI also uses for +/// its own tenderdash-side failures that can postdate delivery) allow +/// the request to have outlived its lost ACK. +pub(crate) fn broadcast_definitely_failed(e: &dash_sdk::Error) -> bool { + use dash_sdk::dapi_client::transport::TransportError; + use dash_sdk::dapi_client::DapiClientError; + use dash_sdk::dapi_grpc::tonic::Code; + + fn status_is_verdict(t: &TransportError) -> bool { + let TransportError::Grpc(status) = t; + !matches!( + status.code(), + Code::DeadlineExceeded + | Code::Cancelled + | Code::Unknown + | Code::Internal + | Code::Aborted + | Code::DataLoss + ) + } + + if carries_consensus_rejection(e) { + return true; + } + match e { + dash_sdk::Error::AlreadyExists(_) => false, + dash_sdk::Error::DapiClientError(DapiClientError::Transport(t)) => status_is_verdict(t), + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) => true, + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(t)) => { + status_is_verdict(t) + } + dash_sdk::Error::NoAvailableAddressesToRetry(inner) => broadcast_definitely_failed(inner), + _ => false, + } +} diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8349eb1df21..4f5f6a424bd 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -539,6 +539,29 @@ pub enum PlatformWalletError { reason: String, }, + /// A masternode (evonode) identity credit withdrawal was **broadcast and + /// accepted**, but its execution result could not be confirmed (the + /// result-proof fetch/verify failed — transient DAPI/proof error or + /// timeout, not a Platform rejection). The claim may already have + /// executed, and the SDK's identity-nonce cache was bumped for it, so + /// re-submitting could execute a SECOND withdrawal with the next nonce. + /// Callers must NOT retry until they have re-read the identity's + /// claimable balance (and the payout) and reconciled the outcome. + /// `reason` carries the underlying SDK error for diagnostics. + /// + /// Shielded sibling: [`Self::ShieldedSpendUnconfirmed`]; core sibling: + /// [`Self::TransactionBroadcastUnconfirmed`]. + #[error( + "Masternode withdrawal of {amount_credits} credits from identity {identity_id} was \ + broadcast but its result could not be confirmed; it may already have executed — do \ + not re-submit until the claimable balance has been re-read: {reason}" + )] + MasternodeWithdrawalUnconfirmed { + identity_id: Identifier, + amount_credits: u64, + reason: String, + }, + #[error("Shielded sync failed: {0}")] ShieldedSyncFailed(String), diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index da934354fd0..b78ec9838a9 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -13,6 +13,7 @@ #![allow(clippy::doc_overindented_list_items)] pub mod address_paths; +pub(crate) mod broadcast_outcome; pub mod broadcaster; pub mod changeset; pub mod error; @@ -80,6 +81,9 @@ pub use wallet::identity::{ IdentityManager, IdentityStatus, KeyStorage, ManagedIdentity, PrivateKeyData, ProfileUpdate, RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, }; +pub use wallet::masternode_withdrawal::{ + MasternodeWithdrawalKey, MasternodeWithdrawalKeys, MasternodeWithdrawalRequest, +}; pub use wallet::platform_wallet::PlatformWalletInfo; #[cfg(feature = "shielded")] pub use wallet::platform_wallet::ShieldedShieldPreflight; diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 7b9c6422826..b7f71284e9b 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -372,6 +372,13 @@ impl PlatformWalletManager

{ wallets.get(wallet_id).cloned() } + /// Blocking twin of [`Self::get_wallet`] for synchronous FFI entry + /// points that need to clone the `Arc` out before doing + /// network work outside the handle-storage guard. + pub fn get_wallet_blocking(&self, wallet_id: &WalletId) -> Option> { + self.wallets.blocking_read().get(wallet_id).cloned() + } + /// List all wallet IDs. pub async fn wallet_ids(&self) -> Vec { let wallets = self.wallets.read().await; diff --git a/packages/rs-platform-wallet/src/wallet/core/mod.rs b/packages/rs-platform-wallet/src/wallet/core/mod.rs index 36fe92318d6..ec4cbd9b8e4 100644 --- a/packages/rs-platform-wallet/src/wallet/core/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/core/mod.rs @@ -4,6 +4,7 @@ mod broadcast; pub mod generation; // Inherent `CoreWallet::sign_message` only — no types to re-export. mod sign_message; +pub(crate) use sign_message::is_signable_funding_account; mod transaction; pub mod wallet; diff --git a/packages/rs-platform-wallet/src/wallet/core/sign_message.rs b/packages/rs-platform-wallet/src/wallet/core/sign_message.rs index 592dacb2ec2..dc9bc1d2b12 100644 --- a/packages/rs-platform-wallet/src/wallet/core/sign_message.rs +++ b/packages/rs-platform-wallet/src/wallet/core/sign_message.rs @@ -79,7 +79,10 @@ const RECOVERY_IDS: [i32; 4] = [0, 1, 2, 3]; /// module does not exist on this branch. Keeping the predicate here — same /// name, same body — means the two converge to a single call site by deleting /// this function when that module lands, with no behavior change to review. -fn is_signable_funding_account(managed_type: &ManagedAccountType) -> bool { +/// Funding accounts whose keys this wallet can sign for: everything except +/// the DashPay external (watch-only, contact-owned) accounts. Shared with the +/// masternode payout-key lookup. +pub(crate) fn is_signable_funding_account(managed_type: &ManagedAccountType) -> bool { !matches!( managed_type, ManagedAccountType::DashpayExternalAccount { .. } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 752fee202ce..9efa3dd1723 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -35,6 +35,7 @@ mod transfer; mod transfer_to_addresses; mod update; mod withdrawal; +pub(crate) use withdrawal::{select_owner_withdrawal_key, select_transfer_withdrawal_key}; // DashPay-contract operations, namespaced behind `IdentityWallet::dashpay()`. mod contact_info; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs index 59f496b1aad..939c3797cbd 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs @@ -6,6 +6,7 @@ use dpp::address_funds::AddressWitness; use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; +use dpp::identity::Purpose; use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; use dpp::ProtocolError; @@ -184,33 +185,58 @@ impl IdentityWallet { /// produces an invalid transition (rejected, but still a wasted attempt), /// so a distinct error is surfaced instead. /// -/// Pure — no derivation or network. Unit-tested below; the derive-and-sign -/// orchestration feeds it the wallet-derived owner key's hash160. -// `allow(dead_code)`: currently exercised only by the unit tests — the -// masternode-withdraw FFI orchestration that calls it in production is -// gated pending the verified owner-key signer (see -// `platform_wallet_manager_masternode_withdraw`). Remove the allow when -// that path is wired. -#[allow(dead_code)] +/// Pure — no derivation or network. Unit-tested below; the +/// derive-and-sign orchestration in +/// `PlatformWallet::masternode_withdraw` feeds it the wallet-derived owner +/// key's hash160. pub fn select_owner_withdrawal_key<'a, I>( identity_keys: I, owner_key_hash160: &[u8; 20], ) -> Option<&'a IdentityPublicKey> +where + I: IntoIterator, +{ + select_hash160_key(identity_keys, Purpose::OWNER, owner_key_hash160) +} + +/// Select the TRANSFER-purpose `IdentityPublicKey` on a masternode identity +/// whose key material matches `payout_key_hash160` — the pubkey hash of the +/// node's registered P2PKH payout script. Platform registers that script's +/// pubkey hash as the identity's transfer key, so this is the key that can +/// withdraw to a chosen destination. +/// +/// Same contract as [`select_owner_withdrawal_key`]: pure, and `None` means +/// "do not broadcast". +pub fn select_transfer_withdrawal_key<'a, I>( + identity_keys: I, + payout_key_hash160: &[u8; 20], +) -> Option<&'a IdentityPublicKey> +where + I: IntoIterator, +{ + select_hash160_key(identity_keys, Purpose::TRANSFER, payout_key_hash160) +} + +fn select_hash160_key<'a, I>( + identity_keys: I, + purpose: Purpose, + key_hash160: &[u8; 20], +) -> Option<&'a IdentityPublicKey> where I: IntoIterator, { use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; - use dpp::identity::{KeyType, Purpose}; + use dpp::identity::KeyType; identity_keys.into_iter().find(|key| { - key.purpose() == Purpose::OWNER + key.purpose() == purpose && key.key_type() == KeyType::ECDSA_HASH160 - && key.data().as_slice() == owner_key_hash160.as_slice() + && key.data().as_slice() == key_hash160.as_slice() }) } #[cfg(test)] mod masternode_withdrawal_tests { - use super::select_owner_withdrawal_key; + use super::{select_owner_withdrawal_key, select_transfer_withdrawal_key}; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; @@ -290,4 +316,40 @@ mod masternode_withdrawal_tests { "no OWNER key with this hash ⇒ None (caller must not broadcast)" ); } + + #[test] + fn transfer_selector_only_matches_transfer_hash160_keys() { + let payout_hash = [0x33u8; 20]; + let keys = [ + // OWNER key with the payout hash — wrong purpose. + make_key( + 0, + Purpose::OWNER, + KeyType::ECDSA_HASH160, + payout_hash.to_vec(), + ), + // TRANSFER but a full pubkey, not a hash160. + make_key( + 1, + Purpose::TRANSFER, + KeyType::ECDSA_SECP256K1, + payout_hash.to_vec(), + ), + // The match. + make_key( + 2, + Purpose::TRANSFER, + KeyType::ECDSA_HASH160, + payout_hash.to_vec(), + ), + ]; + let selected = select_transfer_withdrawal_key(keys.iter(), &payout_hash); + assert_eq!(selected.map(|k| k.id()), Some(2)); + assert!(select_transfer_withdrawal_key(keys.iter(), &[0x44u8; 20]).is_none()); + // The owner selector never returns the transfer key and vice versa. + assert_eq!( + select_owner_withdrawal_key(keys.iter(), &payout_hash).map(|k| k.id()), + Some(0) + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/masternode_withdrawal.rs b/packages/rs-platform-wallet/src/wallet/masternode_withdrawal.rs new file mode 100644 index 00000000000..19fa4871f90 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/masternode_withdrawal.rs @@ -0,0 +1,795 @@ +//! Claim (withdraw) Platform credits from a masternode's owner identity. +//! +//! A masternode's Platform rewards accrue on the identity whose id is the +//! masternode's proTxHash. Two wallet-derived keys can move them to L1 via +//! an Identity Credit Withdrawal, and Platform treats them differently +//! (`rs-drive-abci` `signature_purpose_matches_requirements`): +//! +//! - the **owner key** (`ProviderOwnerKeys`, registered in the ProRegTx as +//! a hash160 → identity key with purpose `OWNER`) may withdraw, but +//! only to the node's **registered payout address** — a withdrawal +//! signed with it must not carry an output script; +//! - the **transfer key** — the pubkey behind the registered P2PKH +//! payout script (identity key with purpose `TRANSFER`) — may withdraw +//! to **any** destination. +//! +//! Everything that decides *which* key, *which* derivation path and +//! *which* output script lives here, so the FFI and the apps only marshal: +//! +//! 1. [`PlatformWallet::masternode_withdrawal_keys`] (sync, no network) +//! answers which of the two keys this wallet holds — seedless, from +//! the account xpubs / address pools — and the payout address. +//! 2. [`PlatformWallet::masternode_withdraw`] (async) fetches the +//! identity, picks the matching identity key, refuses to broadcast if +//! the identity doesn't carry it, and signs through a +//! [`key_wallet::signer::Signer`] (on iOS the mnemonic-resolver-backed +//! signer — the seed never becomes resident). + +use std::fmt; + +use async_trait::async_trait; +use dashcore::hashes::{hash160, sha256d, Hash}; +use dashcore::secp256k1::ecdsa::{RecoverableSignature, RecoveryId}; +use dashcore::secp256k1::{Message, Secp256k1}; +use dashcore::signer::CompactSignature; +use dashcore::{Address as DashAddress, AddressType, Network, ScriptBuf}; +use dpp::address_funds::AddressWitness; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::signer::Signer as IdentitySigner; +use dpp::identity::{Identity, IdentityPublicKey, KeyType}; +use dpp::platform_value::BinaryData; +use dpp::prelude::Identifier; +use dpp::ProtocolError; +use key_wallet::account::AccountType; +use key_wallet::bip32::{ChildNumber, DerivationPath}; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::signer::{Signer as CoreSigner, SignerMethod}; + +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::Fetch; +use dpp::identity::core_script::CoreScript; +use dpp::state_transition::identity_credit_withdrawal_transition::methods::{ + IdentityCreditWithdrawalTransitionMethodsV0, PreferredKeyPurposeForSigningWithdrawal, +}; +use dpp::state_transition::identity_credit_withdrawal_transition::{ + IdentityCreditWithdrawalTransition, MIN_CORE_FEE_PER_BYTE, +}; +use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::withdrawal::Pooling; + +use crate::broadcast_outcome::{broadcast_definitely_failed, carries_consensus_rejection}; +use crate::error::{PlatformWalletError, SIGNER_KEY_UNAVAILABLE_PREFIX}; +use crate::wallet::core::is_signable_funding_account; +use crate::wallet::identity::network::{ + select_owner_withdrawal_key, select_transfer_withdrawal_key, +}; +use crate::wallet::platform_wallet::PlatformWallet; +use crate::wallet::provider_key_at_index::ProviderKeyKind; + +/// Which wallet-held key signs a masternode withdrawal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MasternodeWithdrawalKey { + /// The `ProviderOwnerKeys` key. Platform routes the payout to the + /// registered payout address; no destination may be chosen. + Owner, + /// The payout-script key (identity purpose `TRANSFER`). Any + /// destination is allowed. + Transfer, +} + +/// Which masternode-withdrawal signing keys this wallet holds, plus the +/// node's registered payout address. Produced by +/// [`PlatformWallet::masternode_withdrawal_keys`]; consumed by the UI (to +/// decide whether the destination is editable) and by +/// [`PlatformWallet::masternode_withdraw`] (to derive-sign). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MasternodeWithdrawalKeys { + /// `Some((index, path))` when the masternode's owner key is one of this + /// wallet's `ProviderOwnerKeys` (`m/9'/coin'/3'/2'/index`). + pub owner_key: Option<(u32, DerivationPath)>, + /// `Some(path)` when the registered payout script is a P2PKH to one of + /// this wallet's funding-account addresses — the identity's `TRANSFER` + /// key is then wallet-derivable at that path. + pub transfer_key: Option, + /// The registered payout address (base58 for this wallet's network), + /// or `None` when the node has no payout script on record or it isn't + /// encodable as an address. Owner-key withdrawals always pay here. + pub payout_address: Option, + /// hash160 of the registered payout script's pubkey when it is P2PKH + /// (the `TRANSFER` identity key's data); `None` for P2SH / unknown. + pub payout_key_hash160: Option<[u8; 20]>, +} + +impl MasternodeWithdrawalKeys { + /// True when at least one wallet-held key can sign a withdrawal. + pub fn can_withdraw(&self) -> bool { + self.owner_key.is_some() || self.transfer_key.is_some() + } + + /// True when the destination may differ from the payout address — + /// i.e. the transfer key is in this wallet. + pub fn can_choose_destination(&self) -> bool { + self.transfer_key.is_some() + } +} + +/// One masternode withdrawal to submit. +#[derive(Debug, Clone)] +pub struct MasternodeWithdrawalRequest { + /// proTxHash in stored WIRE order (as the aggregation returns it); the + /// owner identity id is the display-order (reversed) form. + pub pro_tx_hash: [u8; 32], + /// Owner key hash160 from the ProRegTx (None ⇒ owner path unavailable). + pub owner_key_hash: Option<[u8; 20]>, + /// Amount to withdraw, in credits. Must be ≥ Platform's minimum + /// withdrawal and a whole number of duffs (multiple of 1000 credits). + pub amount_credits: u64, + /// Which wallet key signs. + pub signing_key: MasternodeWithdrawalKey, + /// Destination for a transfer-key withdrawal. `None` ⇒ the registered + /// payout address. MUST be `None` for [`MasternodeWithdrawalKey::Owner`] + /// — Platform rejects an output script signed with the owner key. + pub destination: Option, +} + +/// Default provider-key scan window when the owner pool has no deeper +/// watermark — the same floor the operator derive-and-compare uses. +const PROVIDER_KEY_WINDOW: u32 = 20; + +impl PlatformWallet { + /// Which masternode-withdrawal keys this wallet holds for the node + /// described by `owner_key_hash` / `payout_script`, plus its payout + /// address. Seedless: the owner key is found by deriving the + /// `ProviderOwnerKeys` public keys from the account xpub and comparing + /// hash160s; the transfer key by looking the payout address up in the + /// funding accounts' address pools. + /// + /// `owner_key_index_hint` is a durable index the caller already knows + /// for this owner key (the persisted ownership row, or the host's own + /// address join). It is VERIFIED, never trusted: the key at that index is + /// derived and compared first, so a restored wallet whose in-memory pool + /// has no watermark (and whose owner key sits above the default scan + /// window) still resolves. Without a hint — or if the hint doesn't match — + /// the pool depth (at least [`PROVIDER_KEY_WINDOW`]) is scanned. + /// + /// Blocking (takes the wallet-manager read lock) — call from a plain + /// thread, not from inside the async runtime. + pub fn masternode_withdrawal_keys( + &self, + owner_key_hash: Option<&[u8; 20]>, + payout_script: Option<&[u8]>, + owner_key_index_hint: Option, + ) -> Result { + let network = self.network(); + + // Payout script → address (+ the P2PKH pubkey hash that is the + // identity's TRANSFER key data). + let payout_address = payout_script.and_then(|script| { + DashAddress::from_script(&ScriptBuf::from_bytes(script.to_vec()), network).ok() + }); + let payout_key_hash160 = payout_address.as_ref().and_then(|address| { + if address.address_type() != Some(AddressType::P2pkh) { + return None; + } + let script = address.script_pubkey(); + // P2PKH: OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG + let bytes = script.as_bytes(); + (bytes.len() == 25) + .then(|| <[u8; 20]>::try_from(&bytes[3..23]).ok()) + .flatten() + }); + + // Under the wallet-manager read lock: the owner pool's depth and the + // payout address's derivation path. Dropped before the derive loop + // below (which takes the wallet's own state lock). + let (owner_scan_max, transfer_key) = { + let wm = self.wallet_manager().blocking_read(); + let info = wm.get_wallet_info(&self.wallet_id()).ok_or_else(|| { + PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id())) + })?; + + let owner_scan_max = info + .core_wallet + .accounts + .provider_owner_keys + .as_ref() + .and_then(|acct| { + acct.managed_account_type() + .address_pools() + .iter() + .filter_map(|p| p.highest_generated) + .max() + }) + .map(|h| h.saturating_add(1)) + .unwrap_or(PROVIDER_KEY_WINDOW) + .max(PROVIDER_KEY_WINDOW); + + let transfer_key = match (&payout_address, payout_key_hash160) { + (Some(address), Some(_)) => info + .core_wallet + .accounts + .all_funding_accounts() + .into_iter() + .filter(|acc| is_signable_funding_account(acc.managed_account_type())) + .find_map(|acc| acc.address_derivation_path(address)), + _ => None, + }; + + (owner_scan_max, transfer_key) + }; + + // Owner key: verify the caller's hint first, then derive-and-compare + // over the pool depth. Seedless — `derive_provider_key_at_index` + // reads the account xpub. + let owner_key = match owner_key_hash { + Some(target) => { + self.find_owner_key(target, owner_scan_max, owner_key_index_hint, network)? + } + None => None, + }; + + Ok(MasternodeWithdrawalKeys { + owner_key, + transfer_key, + payout_address: payout_address.map(|a| a.to_string()), + payout_key_hash160, + }) + } + + fn find_owner_key( + &self, + owner_key_hash: &[u8; 20], + scan_max: u32, + index_hint: Option, + network: Network, + ) -> Result, PlatformWalletError> { + if let Some(hint) = index_hint { + match self.owner_key_matches_at(hint, owner_key_hash)? { + // No provider-owner-keys account at all ⇒ nothing to match. + None => return Ok(None), + Some(true) => return Ok(Some((hint, provider_owner_key_path(network, hint)?))), + Some(false) => {} // stale hint — fall back to the scan + } + } + for index in 0..scan_max { + match self.owner_key_matches_at(index, owner_key_hash)? { + None => return Ok(None), + Some(true) => return Ok(Some((index, provider_owner_key_path(network, index)?))), + Some(false) => {} + } + } + Ok(None) + } + + /// `Some(matches)` for the owner key derived at `index`; `None` when the + /// wallet has no provider-owner-keys account to derive from. + fn owner_key_matches_at( + &self, + index: u32, + owner_key_hash: &[u8; 20], + ) -> Result, PlatformWalletError> { + let derived = + match self.derive_provider_key_at_index(ProviderKeyKind::Owner, index, None, false) { + Ok(derived) => derived, + Err(PlatformWalletError::AddressNotFound(_)) => return Ok(None), + Err(e) => return Err(e), + }; + let hash: [u8; 20] = hash160::Hash::hash(&derived.public_key_bytes).to_byte_array(); + Ok(Some(&hash == owner_key_hash)) + } + + /// Submit an Identity Credit Withdrawal from the masternode's owner + /// identity, signed with the wallet key chosen in `request.signing_key` + /// and derived through `signer` at the path resolved by + /// [`Self::masternode_withdrawal_keys`] (`keys`). Returns the identity's + /// remaining credit balance as proven by Platform. + /// + /// Guards (all fail WITHOUT broadcasting): + /// - owner path with a destination, or a key the wallet doesn't hold; + /// - the identity is missing, or doesn't carry the expected + /// `OWNER` / `TRANSFER` key for the wallet's hash160. + /// + /// Outcomes after the transition leaves this wallet are kept distinct: + /// a definitive rejection (consensus error, or a transport verdict that + /// rules out delivery) is an ordinary error and may be retried; anything + /// ambiguous — broadcast accepted (or its ACK lost) and the result wait + /// failed — is [`PlatformWalletError::MasternodeWithdrawalUnconfirmed`] + /// and MUST NOT be retried until the claimable balance is re-read: the + /// identity nonce was already consumed for this attempt, so a blind retry + /// would submit a second withdrawal. + pub async fn masternode_withdraw( + &self, + request: MasternodeWithdrawalRequest, + keys: &MasternodeWithdrawalKeys, + signer: &S, + ) -> Result { + if request.amount_credits == 0 { + return Err(PlatformWalletError::InvalidParameter( + "masternode withdrawal amount must be greater than zero".to_string(), + )); + } + if !signer.supports(SignerMethod::Digest) { + return Err(PlatformWalletError::InvalidParameter(format!( + "signer backend cannot sign digests: it advertises {:?}, but an identity \ + credit withdrawal requires {:?}", + signer.supported_methods(), + SignerMethod::Digest, + ))); + } + + // Resolve (path, expected hash160, destination) per signing key. + let (path, expected_hash160, destination) = match request.signing_key { + MasternodeWithdrawalKey::Owner => { + if request.destination.is_some() { + return Err(PlatformWalletError::InvalidParameter( + "an owner-key withdrawal pays the registered payout address; a \ + destination cannot be chosen" + .to_string(), + )); + } + let owner_hash = request.owner_key_hash.ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "masternode has no owner key hash on record".to_string(), + ) + })?; + let (_, path) = keys.owner_key.clone().ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "this wallet does not hold the masternode's owner key".to_string(), + ) + })?; + (path, owner_hash, None) + } + MasternodeWithdrawalKey::Transfer => { + let path = keys.transfer_key.clone().ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "this wallet does not hold the masternode's payout (transfer) key" + .to_string(), + ) + })?; + let payout_hash = keys.payout_key_hash160.ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "masternode payout script is not a P2PKH address".to_string(), + ) + })?; + // Default destination = the registered payout address; an + // explicit one must be for this wallet's network. + let destination = match request.destination { + Some(address) => address, + None => { + let payout = keys.payout_address.as_deref().ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "masternode has no payout address on record".to_string(), + ) + })?; + payout + .parse::>() + .map_err(|e| { + PlatformWalletError::InvalidParameter(format!( + "payout address is not a valid Dash address: {e}" + )) + })? + .require_network(self.network()) + .map_err(|e| { + PlatformWalletError::InvalidParameter(format!( + "payout address is for another network: {e}" + )) + })? + } + }; + (path, payout_hash, Some(destination)) + } + }; + + // Owner identity id = display-order proTxHash. + let mut id_bytes = request.pro_tx_hash; + id_bytes.reverse(); + let identity_id = Identifier::from(id_bytes); + + let identity = Identity::fetch(self.sdk(), identity_id) + .await? + .ok_or(PlatformWalletError::IdentityNotFound(identity_id))?; + + let identity_key = match request.signing_key { + MasternodeWithdrawalKey::Owner => { + select_owner_withdrawal_key(identity.public_keys().values(), &expected_hash160) + } + MasternodeWithdrawalKey::Transfer => { + select_transfer_withdrawal_key(identity.public_keys().values(), &expected_hash160) + } + } + .cloned() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "the masternode identity {identity_id} has no {} key matching this wallet's key \ + (hash160 {}); not broadcasting", + match request.signing_key { + MasternodeWithdrawalKey::Owner => "OWNER", + MasternodeWithdrawalKey::Transfer => "TRANSFER", + }, + hex::encode(expected_hash160), + )) + })?; + + let identity_signer = DerivedKeyIdentitySigner { + signer, + path, + expected_key_hash160: expected_hash160, + }; + + let sdk = self.sdk(); + let definitive = |e: dash_sdk::Error| { + crate::error::preserve_signer_key_unavailable_or(e, |e| { + PlatformWalletError::InvalidIdentityData(format!( + "masternode withdrawal failed: {e}" + )) + }) + }; + + // Build + sign locally. Nothing has left the wallet yet, so every + // error here is definitive and retryable. + let nonce = sdk + .get_identity_nonce(identity_id, true, None) + .await + .map_err(definitive)?; + let output_script = destination.map(|address| CoreScript::new(address.script_pubkey())); + let state_transition = IdentityCreditWithdrawalTransition::try_from_identity( + &identity, + output_script, + request.amount_credits, + Pooling::Never, + MIN_CORE_FEE_PER_BYTE, + 0, + identity_signer, + Some(&identity_key), + PreferredKeyPurposeForSigningWithdrawal::TransferPreferred, + nonce, + sdk.version(), + None, + ) + .await + .map_err(|e| definitive(dash_sdk::Error::Protocol(e)))?; + + // Broadcast, then wait — split so an ambiguous outcome stays typed. + let unconfirmed = |reason: String| PlatformWalletError::MasternodeWithdrawalUnconfirmed { + identity_id, + amount_credits: request.amount_credits, + reason, + }; + match state_transition.broadcast(sdk, None).await { + Ok(()) => {} + Err(e) if broadcast_definitely_failed(&e) => return Err(definitive(e)), + Err(e) => { + tracing::warn!( + identity = %identity_id, + error = %e, + "masternode withdrawal broadcast returned no verdict; the transition may \ + have been admitted — falling through to the result wait" + ); + } + } + + match state_transition + .wait_for_affected_state::(sdk, None) + .await + { + Ok(StateTransitionProofResult::VerifiedPartialIdentity(partial)) => { + partial.balance.ok_or_else(|| { + unconfirmed("the result proof carried no identity balance".to_string()) + }) + } + // Proved, but not the shape a withdrawal produces — the transition + // landed; only the balance read-back is missing. + Ok(_) => Err(unconfirmed( + "the result proof did not carry the identity's balance".to_string(), + )), + Err(e) if carries_consensus_rejection(&e) => Err(definitive(e)), + Err(e) => Err(unconfirmed(e.to_string())), + } + } +} + +/// `m/9'/coin'/3'/2'/index` — the `ProviderOwnerKeys` account base path plus +/// the (non-hardened) key index, exactly as the account's address pool +/// derives it. +fn provider_owner_key_path( + network: Network, + index: u32, +) -> Result { + let base = AccountType::ProviderOwnerKeys + .derivation_path(network) + .map_err(|e| PlatformWalletError::KeyDerivation(format!("owner key base path: {e}")))?; + let child = ChildNumber::from_normal_idx(index) + .map_err(|e| PlatformWalletError::KeyDerivation(format!("owner key index {index}: {e}")))?; + Ok(base.child(child)) +} + +// --------------------------------------------------------------------------- +// Signer adapter: key-wallet `Signer` at a fixed path ⇒ dpp identity signer +// --------------------------------------------------------------------------- + +/// Signs identity state transitions with ONE wallet-derived secp256k1 key: +/// the key at `path`, whose compressed pubkey must hash160 to +/// `expected_key_hash160` (the masternode identity key's data). Any other +/// key is refused, so a mismatched identity/wallet pairing can never +/// produce a signature. +/// +/// Signature encoding follows `dashcore::signer::sign`: double-SHA256 the +/// signable bytes, ECDSA-sign, and emit the 65-byte compact *recoverable* +/// form (`27 + 4 + recid` prefix for a compressed key). The key-wallet +/// signer returns a plain signature + pubkey, so the recovery id is found +/// by trial — the same trick `CoreWallet::sign_message` uses. +struct DerivedKeyIdentitySigner<'a, S: ?Sized> { + signer: &'a S, + path: DerivationPath, + expected_key_hash160: [u8; 20], +} + +impl<'a, S: ?Sized> fmt::Debug for DerivedKeyIdentitySigner<'a, S> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DerivedKeyIdentitySigner") + .field("path", &self.path.to_string()) + .field( + "expected_key_hash160", + &hex::encode(self.expected_key_hash160), + ) + .finish() + } +} + +impl<'a, S: ?Sized> DerivedKeyIdentitySigner<'a, S> { + fn matches(&self, key: &IdentityPublicKey) -> bool { + match key.key_type() { + KeyType::ECDSA_HASH160 => key.data().as_slice() == self.expected_key_hash160.as_slice(), + KeyType::ECDSA_SECP256K1 => { + hash160::Hash::hash(key.data().as_slice()).to_byte_array() + == self.expected_key_hash160 + } + _ => false, + } + } +} + +#[async_trait] +impl<'a, S> IdentitySigner for DerivedKeyIdentitySigner<'a, S> +where + S: CoreSigner + ?Sized + Sync, +{ + async fn sign( + &self, + identity_public_key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + if !self.matches(identity_public_key) { + return Err(ProtocolError::Generic(format!( + "masternode withdrawal signer holds only the key with hash160 {}, not {:?}", + hex::encode(self.expected_key_hash160), + identity_public_key.id() + ))); + } + + let digest: [u8; 32] = sha256d::Hash::hash(data).to_byte_array(); + let (signature, public_key) = + self.signer + .sign_ecdsa(&self.path, digest) + .await + .map_err(|e| { + let rendered = e.to_string(); + // Keep the typed key-unavailable marker at position 0 so + // the FFI still maps it to `ErrorSigningKeyUnavailable`. + if rendered.starts_with(SIGNER_KEY_UNAVAILABLE_PREFIX) { + ProtocolError::Generic(rendered) + } else { + ProtocolError::Generic(format!( + "signer rejected the withdrawal digest at {}: {rendered}", + self.path + )) + } + })?; + + // Bind the derived key to the identity key BEFORE emitting anything: + // a wrong index / wrong seed yields a different pubkey. + let derived_hash: [u8; 20] = hash160::Hash::hash(&public_key.serialize()).to_byte_array(); + if derived_hash != self.expected_key_hash160 { + return Err(ProtocolError::Generic(format!( + "the key derived at {} does not match the masternode identity key (hash160 {} \ + vs {}); refusing to sign", + self.path, + hex::encode(derived_hash), + hex::encode(self.expected_key_hash160) + ))); + } + + let secp = Secp256k1::verification_only(); + let msg = Message::from_digest(digest); + let compact = signature.serialize_compact(); + let recoverable = (0..4i32) + .filter_map(|id| RecoveryId::try_from(id).ok()) + .filter_map(|recid| RecoverableSignature::from_compact(&compact, recid).ok()) + .find(|candidate| { + secp.recover_ecdsa(&msg, candidate) + .is_ok_and(|recovered| recovered == public_key) + }) + .ok_or_else(|| { + ProtocolError::Generic( + "no recovery id in 0..=3 recovers the withdrawal signing key".to_string(), + ) + })?; + + Ok(recoverable.to_compact_signature(true).to_vec().into()) + } + + async fn sign_create_witness( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Err(ProtocolError::Generic( + "masternode withdrawal signer does not produce address witnesses".to_string(), + )) + } + + fn can_sign_with(&self, identity_public_key: &IdentityPublicKey) -> bool { + self.matches(identity_public_key) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::secp256k1::{PublicKey, SecretKey}; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{Purpose, SecurityLevel}; + use std::str::FromStr; + + /// Test signer: one fixed secp256k1 key regardless of path. + struct FixedKeySigner { + secret: SecretKey, + } + + #[async_trait] + impl CoreSigner for FixedKeySigner { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + _path: &DerivationPath, + sighash: [u8; 32], + ) -> Result<(dashcore::secp256k1::ecdsa::Signature, PublicKey), Self::Error> { + let secp = Secp256k1::new(); + let msg = Message::from_digest(sighash); + Ok(( + secp.sign_ecdsa(&msg, &self.secret), + PublicKey::from_secret_key(&secp, &self.secret), + )) + } + + async fn public_key(&self, _path: &DerivationPath) -> Result { + Ok(PublicKey::from_secret_key(&Secp256k1::new(), &self.secret)) + } + } + + fn fixture() -> (FixedKeySigner, [u8; 20], IdentityPublicKey) { + let secret = SecretKey::from_slice(&[0x42u8; 32]).expect("valid scalar"); + let pubkey = PublicKey::from_secret_key(&Secp256k1::new(), &secret); + let hash: [u8; 20] = hash160::Hash::hash(&pubkey.serialize()).to_byte_array(); + let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::OWNER, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: BinaryData::new(hash.to_vec()), + disabled_at: None, + }); + (FixedKeySigner { secret }, hash, key) + } + + #[tokio::test] + async fn signature_verifies_against_the_identity_key_hash() { + let (signer, hash, key) = fixture(); + let adapter = DerivedKeyIdentitySigner { + signer: &signer, + path: DerivationPath::from_str("m/9'/1'/3'/2'/0").unwrap(), + expected_key_hash160: hash, + }; + assert!(adapter.can_sign_with(&key)); + + let data = b"identity credit withdrawal signable bytes"; + let signature = adapter.sign(&key, data).await.expect("signs"); + assert_eq!(signature.len(), 65); + // Same contract as `dashcore::signer::verify_hash_signature`: recover + // from double-SHA256(data) and compare hash160s. + let digest = sha256d::Hash::hash(data).to_byte_array(); + dashcore::signer::verify_hash_signature(&digest, signature.as_slice(), &hash) + .expect("recoverable signature matches the identity key hash"); + } + + #[tokio::test] + async fn refuses_keys_it_does_not_hold() { + let (signer, hash, _) = fixture(); + let adapter = DerivedKeyIdentitySigner { + signer: &signer, + path: DerivationPath::from_str("m/9'/1'/3'/2'/0").unwrap(), + expected_key_hash160: hash, + }; + let other = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 7, + purpose: Purpose::TRANSFER, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: BinaryData::new(vec![0x11u8; 20]), + disabled_at: None, + }); + assert!(!adapter.can_sign_with(&other)); + assert!(adapter.sign(&other, b"x").await.is_err()); + } + + #[tokio::test] + async fn refuses_to_sign_when_the_derived_key_differs() { + let (signer, _, key) = fixture(); + // Expect a different hash than the signer's key actually derives. + let adapter = DerivedKeyIdentitySigner { + signer: &signer, + path: DerivationPath::from_str("m/9'/1'/3'/2'/0").unwrap(), + expected_key_hash160: [0x99u8; 20], + }; + // `key` carries the signer's real hash, so `matches` is false and + // nothing is signed; rebuild a key that claims the wrong hash to + // reach the post-derivation binding check. + let claimed = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::OWNER, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: BinaryData::new(vec![0x99u8; 20]), + disabled_at: None, + }); + let err = adapter.sign(&claimed, b"x").await.unwrap_err().to_string(); + assert!(err.contains("does not match"), "{err}"); + let _ = key; + } + + #[test] + fn owner_key_path_is_dip3_owner_base_plus_normal_index() { + let mainnet = provider_owner_key_path(Network::Mainnet, 4).unwrap(); + assert_eq!(mainnet.to_string(), "m/9'/5'/3'/2'/4"); + let testnet = provider_owner_key_path(Network::Testnet, 0).unwrap(); + assert_eq!(testnet.to_string(), "m/9'/1'/3'/2'/0"); + } + + #[test] + fn keys_flags() { + let none = MasternodeWithdrawalKeys { + owner_key: None, + transfer_key: None, + payout_address: None, + payout_key_hash160: None, + }; + assert!(!none.can_withdraw()); + assert!(!none.can_choose_destination()); + + let owner_only = MasternodeWithdrawalKeys { + owner_key: Some((0, DerivationPath::from_str("m/9'/1'/3'/2'/0").unwrap())), + ..none.clone() + }; + assert!(owner_only.can_withdraw()); + assert!(!owner_only.can_choose_destination()); + + let transfer = MasternodeWithdrawalKeys { + transfer_key: Some(DerivationPath::from_str("m/44'/1'/0'/0/3").unwrap()), + ..none + }; + assert!(transfer.can_withdraw()); + assert!(transfer.can_choose_destination()); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index 223ab8afb64..29ce8338a4f 100644 --- a/packages/rs-platform-wallet/src/wallet/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -3,6 +3,7 @@ pub mod asset_lock; pub mod core; pub mod core_address_key; pub mod identity; +pub mod masternode_withdrawal; pub mod persister; pub mod platform_addresses; pub mod platform_wallet; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 4783ce75f9a..2b75fbc4609 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -28,6 +28,7 @@ use super::note_selection::{ select_notes_for_denomination, select_notes_with_fee, ShieldedFeeKind, }; use super::store::{PendingRedrive, ShieldedNote, ShieldedStore, SubwalletId}; +use crate::broadcast_outcome::{broadcast_definitely_failed, carries_consensus_rejection}; use crate::changeset::{PlatformWalletChangeSet, ShieldedChangeSet}; use crate::error::PlatformWalletError; use crate::wallet::persister::WalletPersister; @@ -1460,7 +1461,6 @@ where } }; - // Pull the verified `Identity` out of the proof result. The expected variant is // `VerifiedIdentityWithShieldedNullifiers`; if drive-abci ever returns a different one the // broadcast still SUCCEEDED, so we don't turn it into an error — we synthesize the identity @@ -2325,37 +2325,6 @@ pub(super) async fn redrive_pending_spends( } } -/// Whether an SDK error carries Platform's own consensus verdict on the -/// transition. Two shapes qualify: -/// -/// - `Error::Protocol(ProtocolError::ConsensusError(_))` — DAPI attached the -/// serialized consensus error as gRPC metadata -/// (`dash-serialized-consensus-error-bin`), which the dapi-client decodes -/// on any failed request. This is how a CheckTx rejection of the -/// transition surfaces from `broadcast()` (rs-dapi's -/// `map_broadcast_error` decodes the consensus error from Tenderdash's -/// `info` field and `TenderdashStatus` re-attaches it as metadata); -/// - a `StateTransitionBroadcastError` whose `cause` deserialized from -/// non-empty consensus `data` — the wait-stream error envelope for a -/// transition Platform executed and rejected on its merits. -/// -/// Recurses through a `NoAvailableAddressesToRetry` envelope, mirroring -/// [`crate::error::as_address_invalid_nonce`]. -/// -/// Only these prove the transition was evaluated and REJECTED. Everything -/// else — transport errors, timeouts, `AlreadyExists` (which proves the -/// opposite: the transition is already in the mempool or on chain), -/// DAPI-internal failures, cause-less broadcast envelopes (the shape DAPI -/// uses for its own wait-side timeouts) — leaves the outcome unknown. -fn carries_consensus_rejection(err: &dash_sdk::Error) -> bool { - match err { - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(_)) => true, - dash_sdk::Error::StateTransitionBroadcastError(e) => e.cause.is_some(), - dash_sdk::Error::NoAvailableAddressesToRetry(inner) => carries_consensus_rejection(inner), - _ => false, - } -} - /// Broadcast a built shielded spend transition (unshield / transfer / /// withdraw) and wait for proven execution, staging the two SDK calls /// separately so the caller's reservation rollback only runs when the @@ -2412,75 +2381,6 @@ async fn broadcast_shielded_spend( .map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) } -/// Whether a failed `broadcast()` call DEFINITIVELY left the transition -/// out of every mempool, so any note reservations may be released and the -/// caller may rebuild and retry: -/// -/// - a consensus verdict ([`carries_consensus_rejection`]): CheckTx -/// evaluated the transition and refused it; -/// - a gRPC response whose status code is a server-side rejection or a -/// connection-establishment failure. `Unavailable` is the common shape -/// of a connect-refused/offline attempt — classifying it as definitive -/// keeps the no-network failure's notes immediately re-spendable -/// instead of stranding them until the next restart — and rejection -/// codes (`InvalidArgument`, `ResourceExhausted` = mempool full, …) are -/// verdicts that the tx was refused admission; -/// - no usable DAPI addresses at all (nothing was ever sent). -/// -/// `Unavailable` is NOT an absolute never-delivered guarantee: HTTP/2 -/// stream resets after the request bytes left can surface the same code, -/// and the dapi-client's cross-address retry only retains the LAST -/// transport error, so an earlier-attempt delivery can hide behind a -/// later attempt's `Unavailable`. Releasing the notes in that residual -/// window is still fund-safe — the authoritative no-reuse guarantee is -/// the on-chain nullifier set, so a re-selected note at worst wastes a -/// ~30 s proof on a nullifier-already-used rejection (see the -/// `finalize_pending` downgrade rationale in `unshield`); never fund -/// loss. The trade is deliberate: UX for the dominant offline case over -/// strict conservatism in a rare race. -/// -/// Everything else leaves the outcome unknown and the caller must fall -/// through to the result wait instead of failing: `AlreadyExists` proves -/// the tx IS in the mempool or on chain (a lost-ACK attempt was re-sent -/// by the dapi-client retry and hit tenderdash's dedupe), and -/// timeout/cancellation/no-response shapes (`TimeoutReached`, -/// `Cancelled`, gRPC `DeadlineExceeded`/`Cancelled`, plus -/// `Internal`/`Unknown`/`Aborted`/`DataLoss`, which DAPI also uses for -/// its own tenderdash-side failures that can postdate delivery) allow -/// the request to have outlived its lost ACK. -fn broadcast_definitely_failed(e: &dash_sdk::Error) -> bool { - use dash_sdk::dapi_client::transport::TransportError; - use dash_sdk::dapi_client::DapiClientError; - use dash_sdk::dapi_grpc::tonic::Code; - - fn status_is_verdict(t: &TransportError) -> bool { - let TransportError::Grpc(status) = t; - !matches!( - status.code(), - Code::DeadlineExceeded - | Code::Cancelled - | Code::Unknown - | Code::Internal - | Code::Aborted - | Code::DataLoss - ) - } - - if carries_consensus_rejection(e) { - return true; - } - match e { - dash_sdk::Error::AlreadyExists(_) => false, - dash_sdk::Error::DapiClientError(DapiClientError::Transport(t)) => status_is_verdict(t), - dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) => true, - dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(t)) => { - status_is_verdict(t) - } - dash_sdk::Error::NoAvailableAddressesToRetry(inner) => broadcast_definitely_failed(inner), - _ => false, - } -} - /// Classify a `wait_for_response` failure for an already-broadcast /// shielded spend (see [`broadcast_shielded_spend`]). /// diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift index 516f27961b9..03fd66de562 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -154,22 +154,24 @@ extension PlatformWalletManager { } } - /// Claim (withdraw) `amountCredits` from a masternode's Platform - /// identity using the wallet-held OWNER key. Owner-key withdrawals pay - /// the node's registered payout address (no chosen destination). Returns - /// the new balance; throws on failure (surfaced to the confirmation UI). + /// Which masternode-withdrawal signing keys this wallet holds for the + /// masternode `proTxHash` (stored WIRE order), plus its registered payout + /// address. Seedless and local — no resolver, no network. The same + /// resolution `masternodeWithdraw` signs with, so a UI that gates its + /// Withdraw button / destination field on this can never enable a path + /// the claim then refuses. /// - /// Pure bridge — the whole orchestration (identity fetch, OWNER-key - /// guard, owner-key derivation + sign, withdraw broadcast) lives in - /// `platform-wallet` behind this one FFI call, per CLAUDE.md. - /// `proTxHash` is passed in stored WIRE order; Rust reverses it to the - /// display-order identity id. - public func masternodeWithdraw( + /// `ownerKeyIndexHint`: a durable `ProviderOwnerKeys` index the host + /// already knows for this owner key (the persisted + /// `PersistentMasternode.ownerKeyIndex`, or its own address join). Rust + /// verifies it by derivation before falling back to the pool-depth scan, + /// so restored wallets whose in-memory pool has no watermark still + /// resolve an owner key above the default window. Pass `nil` when unknown. + public func masternodeWithdrawalKeys( walletId: Data, proTxHash: Data, - amountCredits: UInt64, - ownerKeyIndex: UInt32 - ) throws -> UInt64 { + ownerKeyIndexHint: UInt32? = nil + ) throws -> MasternodeWithdrawalKeys { guard isConfigured, handle != NULL_HANDLE, walletId.count == 32, proTxHash.count == 32 else { @@ -177,26 +179,163 @@ extension PlatformWalletManager { "Manager not configured, or wallet id / proTxHash not 32 bytes") } - var outBalance: UInt64 = 0 + var out = MasternodeWithdrawalKeysFFI() let ffiResult = walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in - platform_wallet_manager_masternode_withdraw( + platform_wallet_manager_masternode_withdrawal_keys( handle, widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - amountCredits, - ownerKeyIndex, - nil, // dest_address: owner-key path pays the registered payout address - true, // use_owner_key - &outBalance + ownerKeyIndexHint != nil, + ownerKeyIndexHint ?? 0, + &out ) } } - let result = PlatformWalletResult(ffiResult) guard result.isSuccess else { throw PlatformWalletError(result: result) } - return outBalance + defer { + if let payout = out.payout_address { + platform_wallet_string_free(payout) + } + } + return MasternodeWithdrawalKeys( + ownerKeyIndex: out.owner_key_in_wallet ? out.owner_key_index : nil, + transferKeyInWallet: out.transfer_key_in_wallet, + payoutAddress: out.payout_address.map { String(cString: $0) } + ) + } + + /// Claim (withdraw) `amountCredits` from a masternode's Platform + /// identity to L1, signed with the wallet key `signingKey` derived + /// through the Keychain-backed mnemonic resolver (the seed never becomes + /// resident — same path as core sends). Returns the identity's remaining + /// balance; throws on failure (surfaced to the confirmation UI). + /// + /// - `.owner`: Platform pays the registered payout address; + /// `destinationAddress` must be nil. + /// - `.transfer`: `destinationAddress` (base58, this wallet's network) + /// is the destination; nil ⇒ the registered payout address. + /// + /// `ownerKeyIndexHint`: as on `masternodeWithdrawalKeys` — verified, not + /// trusted. + /// + /// Outcomes: a definitive rejection throws an ordinary error and may be + /// retried. An AMBIGUOUS outcome — broadcast accepted (or its ACK lost) + /// and the result wait failed — throws + /// `PlatformWalletError.masternodeWithdrawalUnconfirmed`: the claim may + /// have executed and the identity nonce was consumed, so callers must + /// NOT retry until they have re-read the claimable balance. + /// + /// Pure bridge — the whole orchestration (masternode lookup, identity + /// fetch, key selection + guards, derivation, sign, broadcast, result + /// wait) lives in `platform-wallet` behind this one FFI call, per + /// CLAUDE.md. The FFI blocks on the network round-trip, so it runs on a + /// detached task. `proTxHash` is passed in stored WIRE order; Rust + /// reverses it to the display-order identity id. + public func masternodeWithdraw( + walletId: Data, + proTxHash: Data, + amountCredits: UInt64, + signingKey: MasternodeWithdrawalSigningKey, + destinationAddress: String? = nil, + ownerKeyIndexHint: UInt32? = nil + ) async throws -> UInt64 { + guard isConfigured, handle != NULL_HANDLE, + walletId.count == 32, proTxHash.count == 32 + else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, or wallet id / proTxHash not 32 bytes") + } + if signingKey == .owner, destinationAddress != nil { + throw PlatformWalletError.invalidParameter( + "an owner-key withdrawal pays the registered payout address; no destination can be chosen") + } + + let handle = self.handle + let useOwnerKey = signingKey == .owner + return try await Task.detached(priority: .userInitiated) { () -> UInt64 in + // Resolver-backed signer: the mnemonic is fetched from the + // Keychain inside the resolver vtable Rust-side. Kept alive + // across the synchronous FFI call, whose callback fires during it. + let resolver = MnemonicResolver() + var outBalance: UInt64 = 0 + let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in + walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + func call(_ destPtr: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_masternode_withdraw( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + amountCredits, + useOwnerKey, + destPtr, + ownerKeyIndexHint != nil, + ownerKeyIndexHint ?? 0, + resolver.handle, + &outBalance + ) + } + if let destinationAddress { + return destinationAddress.withCString { call($0) } + } + return call(nil) + } + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return outBalance + }.value + } +} + +/// Which wallet key signs a masternode (evonode) credit withdrawal. +public enum MasternodeWithdrawalSigningKey: Sendable, Equatable { + /// The `ProviderOwnerKeys` key — pays the registered payout address only. + case owner + /// The payout-script (identity `TRANSFER`) key — any destination. + case transfer +} + +/// Preflight for a masternode credit withdrawal: which signing keys this +/// wallet holds and where an owner-key claim is paid. See +/// `PlatformWalletManager.masternodeWithdrawalKeys(walletId:proTxHash:)`. +public struct MasternodeWithdrawalKeys: Sendable, Equatable { + /// `ProviderOwnerKeys` index of the masternode's owner key when this + /// wallet holds it; `nil` otherwise. + public let ownerKeyIndex: UInt32? + /// This wallet holds the payout-script key, so the destination may be + /// changed. + public let transferKeyInWallet: Bool + /// Registered payout address, or `nil` when the node has no encodable + /// payout script. + public let payoutAddress: String? + + public init(ownerKeyIndex: UInt32?, transferKeyInWallet: Bool, payoutAddress: String?) { + self.ownerKeyIndex = ownerKeyIndex + self.transferKeyInWallet = transferKeyInWallet + self.payoutAddress = payoutAddress + } + + public var ownerKeyInWallet: Bool { ownerKeyIndex != nil } + + /// At least one wallet-held key can sign a withdrawal. + public var canWithdraw: Bool { ownerKeyInWallet || transferKeyInWallet } + + /// The destination may differ from the payout address. + public var canChooseDestination: Bool { transferKeyInWallet } + + /// The key a claim should sign with: the transfer key when available + /// (it permits any destination), else the owner key. + public var preferredSigningKey: MasternodeWithdrawalSigningKey? { + if transferKeyInWallet { return .transfer } + if ownerKeyInWallet { return .owner } + return nil } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 8528fe091dd..8e0036c3e5e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -53,6 +53,13 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// of double-spending; the reservation TTL or a sync reconciles the /// outcome. Do NOT auto-retry. case errorTransactionBroadcastUnconfirmed = 20 + /// A masternode (evonode) identity credit withdrawal was broadcast and + /// accepted, but its execution result could not be confirmed — it may + /// already have executed, and the identity nonce was consumed for it, so + /// a blind retry could submit a SECOND withdrawal. Do NOT retry; re-read + /// the claimable balance and reconcile first. Definitive rejections keep + /// their ordinary codes and stay retryable. + case errorMasternodeWithdrawalUnconfirmed = 42 /// Definitively-failed address-nonce race: Platform rejected an /// address-funds transition (shield, or identity top-up-from-addresses) /// because the submitted address nonce raced Platform's expected value @@ -206,6 +213,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShieldedNoRecordedAnchor case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_TRANSACTION_BROADCAST_UNCONFIRMED: self = .errorTransactionBroadcastUnconfirmed + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_MASTERNODE_WITHDRAWAL_UNCONFIRMED: + self = .errorMasternodeWithdrawalUnconfirmed case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ADDRESS_NONCE_MISMATCH: self = .errorAddressNonceMismatch case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_CORE_INSUFFICIENT_FUNDS: @@ -358,6 +367,11 @@ public enum PlatformWalletError: LocalizedError { /// reservation TTL or a later sync reconciles the outcome. Do NOT /// auto-retry. Core sibling of `shieldedSpendUnconfirmed`. case transactionBroadcastUnconfirmed(String) + /// A masternode (evonode) credit withdrawal was broadcast and accepted + /// but its result could not be confirmed. It may already have executed + /// and the identity nonce was consumed — do NOT retry; re-read the + /// claimable balance first (`.errorMasternodeWithdrawalUnconfirmed`). + case masternodeWithdrawalUnconfirmed(String) /// Core definitively rejected the transaction and its input reservation /// was released. Unlike `transactionBroadcastUnconfirmed`, retry is safe. case transactionBroadcastRejected(String) @@ -445,6 +459,7 @@ public enum PlatformWalletError: LocalizedError { .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), .transactionBroadcastUnconfirmed(let m), + .masternodeWithdrawalUnconfirmed(let m), .transactionBroadcastRejected(let m), .addressNonceMismatch(let m), .shutdownIncomplete(let m), @@ -513,6 +528,8 @@ public enum PlatformWalletError: LocalizedError { case .errorShieldedInsufficientBalance: self = .shieldedInsufficientBalance(detail) case .errorTransactionBroadcastUnconfirmed: self = .transactionBroadcastUnconfirmed(detail) + case .errorMasternodeWithdrawalUnconfirmed: + self = .masternodeWithdrawalUnconfirmed(detail) case .errorTransactionBroadcastRejected: self = .transactionBroadcastRejected(detail) case .errorAddressNonceMismatch: diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift index ffa2bb71027..e1cf8cdbf17 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift @@ -17,11 +17,22 @@ struct MasternodeDetailView: View { /// Drives the owner-key claim (withdrawal) FFI. @EnvironmentObject var walletManager: PlatformWalletManager - // Claim (owner-key withdrawal) UI state. + // Claim (masternode credit withdrawal) UI state. @State private var showClaimSheet = false @State private var claimAmountText = "" + @State private var claimDestinationText = "" @State private var claiming = false @State private var claimError: String? + /// Set when a claim's outcome is ambiguous (`masternodeWithdrawalUnconfirmed`): + /// it may have executed and the nonce was consumed, so Confirm stays + /// disabled until the balance is refreshed (which clears it). + @State private var claimOutcomeUnconfirmed = false + + /// Which withdrawal signing keys this wallet holds (SDK preflight — + /// the same seedless resolution the claim signs with). `nil` until + /// resolved, or when the preflight failed (`withdrawalKeysError`). + @State private var withdrawalKeys: MasternodeWithdrawalKeys? + @State private var withdrawalKeysError: String? /// ~0.005 DASH fee headroom kept back from a max claim, in credits. private static let claimFeeHeadroomCredits: UInt64 = 500_000_000 @@ -219,30 +230,29 @@ struct MasternodeDetailView: View { .disabled(balanceLoading) .accessibilityIdentifier("masternode.refreshBalance") - // Claim is only offered when there's a balance AND this - // wallet holds the owner key (the owner-key withdrawal - // path signs with it). The withdrawal FFI itself is still - // a deliberate stub (see `masternodeWithdrawEnabled`), so - // the action is gated off until the verified-signer pass - // lands — the confirmation sheet + claim plumbing below - // stay in place so re-enabling is a one-line flip. + // Claim is offered when there's a balance AND the SDK + // preflight says this wallet holds a key that can sign + // it: the payout (transfer) key — any destination — or + // the owner key — registered payout address only. if canClaim { - if Self.masternodeWithdrawEnabled { - Button { - prepareClaim() - } label: { - Label("Claim", systemImage: "arrow.down.circle") - } - .accessibilityIdentifier("masternode.claimButton") - } else { - Label( - "Claim not yet available (pending verified signer)", - systemImage: "clock.badge.exclamationmark" - ) + Button { + prepareClaim() + } label: { + Label("Claim", systemImage: "arrow.down.circle") + } + .accessibilityIdentifier("masternode.claimButton") + } + if let keys = withdrawalKeys { + Text(keys.canChooseDestination + ? "Payout address key in this wallet — withdraw to any address." + : keys.ownerKeyInWallet + ? "Owner key in this wallet — withdrawals pay the registered payout address." + : "Neither the owner key nor the payout address key is in this wallet.") .font(.caption) .foregroundColor(.secondary) - .accessibilityIdentifier("masternode.claimUnavailable") - } + .accessibilityIdentifier("masternode.withdrawalKeysNote") + } else if let err = withdrawalKeysError { + Text(err).font(.caption).foregroundColor(.secondary) } } } @@ -251,6 +261,7 @@ struct MasternodeDetailView: View { .navigationBarTitleDisplayMode(.inline) .task { if masternode.isEvonode { + loadWithdrawalKeys() await fetchClaimableBalance() } } @@ -259,17 +270,29 @@ struct MasternodeDetailView: View { } } - /// Owner-key masternode withdrawal is not yet enabled: the Rust FFI - /// `platform_wallet_masternode_withdraw` is a deliberate stub returning - /// `ErrorWalletOperation` pending the verified-signer implementation, so - /// every confirmed claim would fail. Gate the Claim action off until - /// that lands; flip this to re-enable the existing confirmation flow. - private static let masternodeWithdrawEnabled = false - - /// Claim is enabled only with a positive balance and this wallet's - /// owner key (the FFI's owner-key path requires it). + /// Claim is enabled only with a positive balance and a wallet-held + /// signing key per the SDK preflight (never the persisted + /// `ownerInWallet`, which can be stale). private var canClaim: Bool { - (claimableCredits ?? 0) > 0 && masternode.ownerInWallet + (claimableCredits ?? 0) > 0 && (withdrawalKeys?.canWithdraw ?? false) + } + + /// SDK preflight: which keys this wallet holds for this masternode. + /// Local + seedless. The persisted owner index is passed as a VERIFIED + /// hint so a restored wallet whose in-memory pool has no watermark still + /// resolves an owner key above the default scan window. + private func loadWithdrawalKeys() { + do { + withdrawalKeys = try walletManager.masternodeWithdrawalKeys( + walletId: masternode.walletId, + proTxHash: masternode.proTxHash, + ownerKeyIndexHint: masternode.ownerInWallet ? masternode.ownerKeyIndex : nil + ) + withdrawalKeysError = nil + } catch { + withdrawalKeys = nil + withdrawalKeysError = "Couldn't resolve withdrawal keys: \(error.localizedDescription)" + } } /// Default claim amount = full balance minus the fee headroom. @@ -282,11 +305,31 @@ struct MasternodeDetailView: View { private func prepareClaim() { claimError = nil + claimOutcomeUnconfirmed = false claimAmountText = Self.creditsAsDash(defaultClaimCredits) .replacingOccurrences(of: " DASH", with: "") + claimDestinationText = withdrawalKeys?.payoutAddress ?? "" showClaimSheet = true } + /// The key the claim signs with: the transfer key when held (it allows + /// any destination), else the owner key. + private var claimSigningKey: MasternodeWithdrawalSigningKey? { + withdrawalKeys?.preferredSigningKey + } + + private var claimSigningKeyLabel: String { + switch claimSigningKey { + case .transfer: return "Payout address key (TRANSFER)" + case .owner: + return "Owner key (" + PersistentMasternode.keyOwnershipLabel( + inWallet: true, + accountType: 9, + index: withdrawalKeys?.ownerKeyIndex ?? masternode.ownerKeyIndex) + ")" + case nil: return "not in this wallet" + } + } + /// Parsed claim amount (DASH text ⇒ credits), or nil if unparseable / 0. private var parsedClaimCredits: UInt64? { guard let dash = Double(claimAmountText.trimmingCharacters(in: .whitespaces)), @@ -315,25 +358,31 @@ struct MasternodeDetailView: View { } Section("Destination") { - MasternodeDetailRow( - label: "Payout Address", - value: masternode.payoutAddress ?? "registered payout address" - ) - Text("Withdrawals with the owner key pay to the registered " - + "payout address — the destination can't be changed.") - .font(.caption) - .foregroundColor(.secondary) + if withdrawalKeys?.canChooseDestination == true { + TextField("Dash address", text: $claimDestinationText) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .accessibilityIdentifier("masternode.claim.destinationField") + Text("This wallet holds the payout address key, so the credits " + + "can be withdrawn to any address (default: the payout address).") + .font(.caption) + .foregroundColor(.secondary) + } else { + MasternodeDetailRow( + label: "Payout Address", + value: withdrawalKeys?.payoutAddress + ?? masternode.payoutAddress + ?? "registered payout address" + ) + Text("Withdrawals with the owner key pay to the registered " + + "payout address — the destination can't be changed.") + .font(.caption) + .foregroundColor(.secondary) + } } Section("Signing Key") { - MasternodeDetailRow( - label: "Owner Key", - value: PersistentMasternode.keyOwnershipLabel( - inWallet: masternode.ownerInWallet, - accountType: masternode.ownerAccountType, - index: masternode.ownerKeyIndex - ) - ) + MasternodeDetailRow(label: "Key", value: claimSigningKeyLabel) } if let err = claimError { @@ -351,7 +400,8 @@ struct MasternodeDetailView: View { } ToolbarItem(placement: .confirmationAction) { Button("Confirm") { Task { await submitClaim() } } - .disabled(claiming || parsedClaimCredits == nil) + .disabled(claiming || claimOutcomeUnconfirmed || parsedClaimCredits == nil + || claimSigningKey == nil) .accessibilityIdentifier("masternode.claim.confirm") } } @@ -360,24 +410,36 @@ struct MasternodeDetailView: View { @MainActor private func submitClaim() async { - guard let credits = parsedClaimCredits else { return } + guard let credits = parsedClaimCredits, let signingKey = claimSigningKey else { return } claiming = true claimError = nil do { - // `PlatformWalletManager` is `@MainActor`; call directly. (When - // the network-signing path lands, the whole orchestration runs - // in Rust behind this one call — see the FFI doc — so blocking - // is bounded to a single FFI round-trip.) - _ = try walletManager.masternodeWithdraw( + // The whole orchestration (masternode lookup, identity fetch, + // owner-key selection, resolver-backed sign, broadcast) runs in + // Rust behind this one call; the wrapper hops off the main actor + // for the network round-trip. + _ = try await walletManager.masternodeWithdraw( walletId: masternode.walletId, proTxHash: masternode.proTxHash, amountCredits: credits, - ownerKeyIndex: masternode.ownerKeyIndex + signingKey: signingKey, + destinationAddress: signingKey == .transfer + ? claimDestinationText.trimmingCharacters(in: .whitespacesAndNewlines) + : nil, + ownerKeyIndexHint: masternode.ownerInWallet ? masternode.ownerKeyIndex : nil ) claiming = false showClaimSheet = false // Reflect the new (reduced) balance. await fetchClaimableBalance() + } catch PlatformWalletError.masternodeWithdrawalUnconfirmed(let detail) { + // Ambiguous: the claim may have executed and the nonce was + // consumed — never re-submit blindly. Confirm stays disabled + // until the balance is refreshed. + claiming = false + claimOutcomeUnconfirmed = true + claimError = "The withdrawal was submitted but its result couldn't be confirmed — " + + "it may have gone through. Refresh the balance before trying again. (\(detail))" } catch { claiming = false claimError = error.localizedDescription @@ -393,6 +455,8 @@ struct MasternodeDetailView: View { /// blocking, so it runs off the main actor (matching `loadPreviewKeys`). @MainActor private func fetchClaimableBalance() async { + // A fresh balance read is the reconciliation an ambiguous claim waits for. + claimOutcomeUnconfirmed = false guard let sdk = platformState.sdk else { balanceError = "Platform SDK not ready" return