diff --git a/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs b/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs index cbd03c502f..f716669be8 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs @@ -27,13 +27,14 @@ use std::sync::Arc; use dashcore::hashes::Hash; use platform_wallet::masternode::{ - execute_masternode_update_service, parse_secret_for_role, LocatorSecret, MasternodeKeyRole, - MasternodeUpdateServiceParams, + execute_masternode_update_service, parse_secret_for_role, prepare_masternode_update_service, + LocatorSecret, MasternodeKeyRole, MasternodeUpdateServiceParams, }; use platform_wallet::{PlatformWallet, ProviderKeyKind}; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; use zeroize::Zeroizing; +use crate::core_wallet::FFICoreSignedTransaction; use crate::error::*; use crate::handle::*; use crate::identity_keys_from_mnemonic::resolve_seed_from_resolver; @@ -147,6 +148,40 @@ unsafe fn wallet_operator_secret( Ok(bytes) } +/// Parse a host-supplied operator key text (64-char hex or 32-byte base64) +/// into its BLS secret, shared by the tracked broadcast and prepare externs. +fn tracked_operator_secret( + key_text: &str, + network: dashcore::Network, +) -> Result, PlatformWalletFFIResult> { + match parse_secret_for_role(key_text, MasternodeKeyRole::Operator, network) { + // Move the existing zeroizing container; dereferencing it would + // place a `Copy` of the secret on the stack. + Ok(LocatorSecret::Bls(secret)) => Ok(secret), + Ok(_) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "the operator key must be a BLS secret (64-char hex or 32-byte base64)", + )), + Err(e) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("operator key is not usable: {e}"), + )), + } +} + +unsafe fn marshal_params( + pro_tx_hash: *const u8, + has_platform_p2p_port: bool, + platform_p2p_port: u16, + operator_payout_address: *const c_char, +) -> Result { + Ok(MasternodeUpdateServiceParams { + pro_tx_hash: std::ptr::read(pro_tx_hash as *const [u8; 32]), + platform_p2p_port: has_platform_p2p_port.then_some(platform_p2p_port), + operator_payout_address: optional_string(operator_payout_address)?, + }) +} + #[allow(clippy::too_many_arguments)] unsafe fn run_update_service( context: ResolvedContext, @@ -158,15 +193,14 @@ unsafe fn run_update_service( mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_txid: *mut [u8; 32], ) -> PlatformWalletFFIResult { - let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); - let operator_payout_address = match optional_string(operator_payout_address) { - Ok(text) => text, - Err(e) => return e, - }; - let params = MasternodeUpdateServiceParams { - pro_tx_hash: target, - platform_p2p_port: has_platform_p2p_port.then_some(platform_p2p_port), + let params = match marshal_params( + pro_tx_hash, + has_platform_p2p_port, + platform_p2p_port, operator_payout_address, + ) { + Ok(params) => params, + Err(e) => return e, }; let ResolvedContext { @@ -191,6 +225,57 @@ unsafe fn run_update_service( PlatformWalletFFIResult::ok() } +/// Prepare-only sibling of [`run_update_service`]: identical up to the +/// broadcast, then registers the signed transaction — holding its input +/// reservation — as a core signed-transaction handle the host later +/// broadcasts (`core_wallet_broadcast_signed_transaction`), abandons +/// (`core_wallet_abandon_signed_transaction`) or frees (which abandons). +#[allow(clippy::too_many_arguments)] +unsafe fn run_prepare_update_service( + context: ResolvedContext, + pro_tx_hash: *const u8, + operator_secret: Zeroizing<[u8; 32]>, + has_platform_p2p_port: bool, + platform_p2p_port: u16, + operator_payout_address: *const c_char, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + out_transaction_handle: *mut Handle, +) -> PlatformWalletFFIResult { + let params = match marshal_params( + pro_tx_hash, + has_platform_p2p_port, + platform_p2p_port, + operator_payout_address, + ) { + Ok(params) => params, + Err(e) => return e, + }; + + let ResolvedContext { + wallet, + spv, + network, + } = context; + let wallet_id_bytes = wallet.wallet_id(); + let signer_addr = mnemonic_resolver_handle as usize; + let (wallet, prepared) = unwrap_result_or_return!(block_on_worker(async move { + let signer = MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id_bytes, + network, + ); + prepare_masternode_update_service(&wallet, &spv, params, operator_secret, &signer) + .await + .map(|prepared| (wallet, prepared)) + })); + + *out_transaction_handle = CORE_SIGNED_TRANSACTION_STORAGE.insert(FFICoreSignedTransaction { + wallet: wallet.core().clone(), + transaction: prepared, + }); + PlatformWalletFFIResult::ok() +} + /// Broadcast a ProUpServTx re-asserting a wallet-owned masternode's current /// service values — which revives it if it is PoSe-banned — signed with the /// wallet's operator key at `operator_key_index` (the index the masternode @@ -304,23 +389,9 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_servi Ok(context) => context, Err(e) => return e, }; - let secret = match parse_secret_for_role(key_text, MasternodeKeyRole::Operator, context.network) - { - // Move the existing zeroizing container; dereferencing it would - // place a `Copy` of the secret on the stack. - Ok(LocatorSecret::Bls(secret)) => secret, - Ok(_) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - "the operator key must be a BLS secret (64-char hex or 32-byte base64)", - ); - } - Err(e) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("operator key is not usable: {e}"), - ); - } + let secret = match tracked_operator_secret(key_text, context.network) { + Ok(secret) => secret, + Err(e) => return e, }; run_update_service( context, @@ -334,6 +405,126 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_servi ) } +/// Prepare — but do NOT broadcast — the ProUpServTx that +/// [`platform_wallet_manager_masternode_update_service`][] would send for a +/// wallet-owned masternode, so the host can show the transaction before the +/// user commits to it. +/// +/// Same parameters and same preflights as the broadcasting entry point. On +/// success `out_transaction_handle` receives a core signed-transaction +/// handle whose inputs are RESERVED. The host must then either broadcast it +/// (`core_wallet_broadcast_signed_transaction`), abandon it +/// (`core_wallet_abandon_signed_transaction`), or free it +/// (`core_wallet_signed_transaction_free`, which abandons) — the fee and the +/// consensus-serialized bytes are readable meanwhile via +/// `core_wallet_signed_transaction_fee` / `core_wallet_signed_transaction_bytes`. +/// Dropping the handle without any of those strands the reservation until +/// the TTL backstop reclaims it. +/// +/// # Safety +/// Pointer args must be valid for the stated sizes; `mnemonic_resolver_handle` +/// must come from `dash_sdk_mnemonic_resolver_create` and remain valid for +/// the duration of the call. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_service( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + operator_key_index: u32, + has_platform_p2p_port: bool, + platform_p2p_port: u16, + operator_payout_address: *const c_char, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + out_transaction_handle: *mut Handle, +) -> PlatformWalletFFIResult { + // `out_transaction_handle` first: the zero-on-every-path contract must + // hold even when a later required pointer is null. + check_ptr!(out_transaction_handle); + *out_transaction_handle = 0; + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(mnemonic_resolver_handle); + + let context = match resolve_context(manager_handle, wallet_id) { + Ok(context) => context, + Err(e) => return e, + }; + let operator_secret = match wallet_operator_secret( + &context.wallet, + operator_key_index, + mnemonic_resolver_handle, + ) { + Ok(secret) => secret, + Err(e) => return e, + }; + run_prepare_update_service( + context, + pro_tx_hash, + operator_secret, + has_platform_p2p_port, + platform_p2p_port, + operator_payout_address, + mnemonic_resolver_handle, + out_transaction_handle, + ) +} + +/// Prepare — but do NOT broadcast — the ProUpServTx that +/// [`platform_wallet_manager_tracked_masternode_update_service`][] would send, +/// signed with the host-vaulted operator key text. +/// +/// Handle ownership and the broadcast / abandon / free contract are exactly +/// those of [`platform_wallet_manager_masternode_prepare_update_service`][]. +/// +/// # Safety +/// Pointer args must be valid for the stated sizes; `operator_key_text` must +/// be a NUL-terminated UTF-8 string; `mnemonic_resolver_handle` must come +/// from `dash_sdk_mnemonic_resolver_create` and remain valid for the +/// duration of the call. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_prepare_update_service( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + operator_key_text: *const c_char, + has_platform_p2p_port: bool, + platform_p2p_port: u16, + operator_payout_address: *const c_char, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + out_transaction_handle: *mut Handle, +) -> PlatformWalletFFIResult { + // `out_transaction_handle` first: the zero-on-every-path contract must + // hold even when a later required pointer is null. + check_ptr!(out_transaction_handle); + *out_transaction_handle = 0; + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(operator_key_text); + check_ptr!(mnemonic_resolver_handle); + + let key_text = unwrap_result_or_return!(std::ffi::CStr::from_ptr(operator_key_text).to_str()); + let context = match resolve_context(manager_handle, wallet_id) { + Ok(context) => context, + Err(e) => return e, + }; + let secret = match tracked_operator_secret(key_text, context.network) { + Ok(secret) => secret, + Err(e) => return e, + }; + run_prepare_update_service( + context, + pro_tx_hash, + secret, + has_platform_p2p_port, + platform_p2p_port, + operator_payout_address, + mnemonic_resolver_handle, + out_transaction_handle, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -387,6 +578,53 @@ mod tests { } } + /// The prepare pair keeps the same contract: an unknown manager handle + /// is an invalid-handle error and the out-param is left at the null + /// handle, so a host can never broadcast a stale handle after a failure. + #[test] + fn prepare_unknown_handles_are_invalid_handles() { + unsafe { + let wallet_id = [0u8; 32]; + let pro_tx_hash = [0u8; 32]; + let resolver = std::ptr::dangling_mut::(); + + let mut transaction_handle: Handle = 7; + let result = platform_wallet_manager_masternode_prepare_update_service( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + 0, + false, + 0, + std::ptr::null(), + resolver, + &mut transaction_handle, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!(transaction_handle, 0, "no handle is handed back on failure"); + let mut result = result; + platform_wallet_ffi_result_free(&mut result); + + let mut transaction_handle: Handle = 7; + let key = std::ffi::CString::new("00").unwrap(); + let result = platform_wallet_manager_tracked_masternode_prepare_update_service( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + key.as_ptr(), + false, + 0, + std::ptr::null(), + resolver, + &mut transaction_handle, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!(transaction_handle, 0, "no handle is handed back on failure"); + let mut result = result; + platform_wallet_ffi_result_free(&mut result); + } + } + /// Null required pointers are rejected before anything else runs — /// and a valid `out_txid` is still zeroed first, per its contract. #[test] diff --git a/packages/rs-platform-wallet/src/masternode/mod.rs b/packages/rs-platform-wallet/src/masternode/mod.rs index 7288279841..6c86b52610 100644 --- a/packages/rs-platform-wallet/src/masternode/mod.rs +++ b/packages/rs-platform-wallet/src/masternode/mod.rs @@ -30,7 +30,10 @@ pub use tracked::{ capabilities_for_roles, snapshot_from_json, snapshot_to_json, MasternodeCapabilities, PlatformKeySnapshot, RegistrationDetails, TrackedMasternode, TrackedMasternodeSnapshot, }; -pub use update_service::{execute_masternode_update_service, MasternodeUpdateServiceParams}; +pub use update_service::{ + execute_masternode_update_service, prepare_masternode_update_service, + MasternodeUpdateServiceParams, +}; use crate::changeset::PlatformWalletPersistence; use crate::manager::PlatformWalletManager; diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs index 86c26bb199..90a66fda51 100644 --- a/packages/rs-platform-wallet/src/masternode/update_service.rs +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -38,7 +38,7 @@ use super::locator::bls_public_keys; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; use crate::spv::SpvRuntime; -use crate::wallet::core::{CoreWallet, SEND_FUNDING_SOURCES}; +use crate::wallet::core::{CoreWallet, SignedCoreTransaction, SEND_FUNDING_SOURCES}; use crate::wallet::platform_wallet::PlatformWallet; /// What an update-service (unban) request lets the caller choose. Everything @@ -83,6 +83,29 @@ pub async fn execute_masternode_update_service, signer: &S, ) -> Result { + let signed = + prepare_masternode_update_service(wallet, spv, params, operator_secret, signer).await?; + wallet.core().broadcast_finalized_transaction(&signed).await +} + +/// Everything [`execute_masternode_update_service`] does except the +/// broadcast: the same preflights, payload, funding, operator-BLS signature +/// and input signatures, stopping at a fully signed transaction whose inputs +/// stay reserved. +/// +/// For hosts that show the ProUpServTx before sending it. The returned +/// transaction is exactly what a broadcast would put on the network, so a +/// preview built from it cannot drift from what is sent. The caller then +/// either broadcasts it (`CoreWallet::broadcast_finalized_transaction`) or +/// abandons it (`CoreWallet::abandon_transaction`) — dropping it without +/// either strands the reservation until the TTL backstop reclaims it. +pub async fn prepare_masternode_update_service( + wallet: &PlatformWallet, + spv: &SpvRuntime, + params: MasternodeUpdateServiceParams, + operator_secret: Zeroizing<[u8; 32]>, + signer: &S, +) -> Result { let summaries = spv .masternode_list_summaries() .await @@ -109,7 +132,7 @@ pub async fn execute_masternode_update_service (u128, u16) { (u128::from_le_bytes(octets), service.port()) } -/// Fund, finalize, and broadcast the ProUpServTx: input selection reserves -/// the funding inputs, the payload finalizer writes `inputs_hash` and the -/// operator-BLS `payload_sig` (basic scheme over `base_payload_hash()`, -/// modern serialization — the exact convention `verify_message_digest` -/// checks real mainnet signatures with), and only then are the inputs -/// ECDSA-signed, since their sighashes cover the finished payload. -pub(crate) async fn build_sign_broadcast_update_service( +/// Fund and finalize the ProUpServTx: input selection reserves the funding +/// inputs, the payload finalizer writes `inputs_hash` and the operator-BLS +/// `payload_sig` (basic scheme over `base_payload_hash()`, modern +/// serialization — the exact convention `verify_message_digest` checks real +/// mainnet signatures with), and only then are the inputs ECDSA-signed, +/// since their sighashes cover the finished payload. +/// +/// Stops at the signed transaction; the caller broadcasts or abandons it. +pub(crate) async fn build_sign_update_service( core: &CoreWallet, placeholder: ProviderUpdateServicePayload, operator_secret: Zeroizing<[u8; 32]>, signer: &S, -) -> Result +) -> Result where B: TransactionBroadcaster + ?Sized, S: TransactionSigner + ?Sized + Sync, @@ -390,10 +415,8 @@ where )) }); - let signed = core - .finalize_transaction(builder, &SEND_FUNDING_SOURCES, 0, signer) - .await?; - core.broadcast_finalized_transaction(&signed).await + core.finalize_transaction(builder, &SEND_FUNDING_SOURCES, 0, signer) + .await } fn display_hex(pro_tx_hash: &[u8; 32]) -> String { @@ -653,14 +676,26 @@ mod tests { let placeholder = prepare_update_service_placeholder(&entry, Some(26656), ScriptBuf::new()) .expect("placeholder"); - let txid = build_sign_broadcast_update_service( - &core, - placeholder, - Zeroizing::new(OPERATOR_SECRET), - &signer, - ) - .await - .expect("update service builds and broadcasts"); + let prepared = + build_sign_update_service(&core, placeholder, Zeroizing::new(OPERATOR_SECRET), &signer) + .await + .expect("update service builds and signs"); + + // Building signs but must not send: the preview flow shows this exact + // transaction before the user decides. + assert!( + broadcaster + .sent + .lock() + .expect("broadcaster lock") + .is_empty(), + "preparing must not broadcast" + ); + + let txid = core + .broadcast_finalized_transaction(&prepared) + .await + .expect("prepared transaction broadcasts"); let sent = broadcaster.sent.lock().expect("broadcaster lock"); assert_eq!(sent.len(), 1, "exactly one transaction broadcast"); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift index 484e99dd04..4c2bc2764b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -436,6 +436,68 @@ extension PlatformWalletManager { return Swift.withUnsafeBytes(of: &txidTuple) { Data($0) } }.value } + + /// Prepare — but do not broadcast — the same ProUpServTx + /// `masternodeUpdateService` would send, so the host can show the + /// transaction before the user commits to it. + /// + /// The returned token owns a signed transaction whose inputs are + /// reserved: broadcast it with + /// `ManagedCoreWallet.broadcastTransactionWithOutcome(_:)`, or let it + /// deinit — which abandons it and releases the reservation. Its `fee` + /// and `serializedData()` describe exactly what a broadcast would send. + public func masternodePrepareUpdateService( + walletId: Data, + proTxHash: Data, + operatorKeyIndex: UInt32, + platformP2PPort: UInt16? = nil, + operatorPayoutAddress: String? = nil + ) async throws -> FinalizedCoreTransaction { + 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") + } + + let handle = self.handle + // Only the raw handle crosses the task boundary; the owning token is + // built here, so a thrown error can't strand it. + let transactionHandle = try await Task.detached(priority: .userInitiated) { () -> Handle in + let resolver = MnemonicResolver() + var outHandle: Handle = NULL_HANDLE + let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in + walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_masternode_prepare_update_service( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + operatorKeyIndex, + platformP2PPort != nil, + platformP2PPort ?? 0, + payoutPtr, + resolver.handle, + &outHandle + ) + } + if let operatorPayoutAddress { + return operatorPayoutAddress.withCString { call($0) } + } + return call(nil) + } + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return outHandle + }.value + + return try FinalizedCoreTransaction(handle: transactionHandle) + } } /// Which wallet key signs a masternode (evonode) credit withdrawal. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift index 911314a095..e2c21d4c74 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift @@ -293,6 +293,68 @@ extension PlatformWalletManager { }.value } + /// Prepare — but do not broadcast — the same ProUpServTx + /// `trackedMasternodeUpdateService` would send, so the host can show the + /// transaction before the user commits to it. + /// + /// Ownership matches `masternodePrepareUpdateService`: the returned + /// token holds a signed transaction with reserved inputs — broadcast it, + /// or let it deinit, which abandons it and releases the reservation. + public func trackedMasternodePrepareUpdateService( + walletId: Data, + proTxHash: Data, + operatorKey: String, + platformP2PPort: UInt16? = nil, + operatorPayoutAddress: String? = nil + ) async throws -> FinalizedCoreTransaction { + 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") + } + + let handle = self.handle + // Only the raw handle crosses the task boundary; the owning token is + // built here, so a thrown error can't strand it. + let transactionHandle = try await Task.detached(priority: .userInitiated) { () -> Handle in + let resolver = MnemonicResolver() + var outHandle: Handle = NULL_HANDLE + let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in + walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + operatorKey.withCString { cKey -> PlatformWalletFFIResult in + func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_tracked_masternode_prepare_update_service( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + cKey, + platformP2PPort != nil, + platformP2PPort ?? 0, + payoutPtr, + resolver.handle, + &outHandle + ) + } + if let operatorPayoutAddress { + return operatorPayoutAddress.withCString { call($0) } + } + return call(nil) + } + } + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return outHandle + }.value + + return try FinalizedCoreTransaction(handle: transactionHandle) + } + // MARK: - Shared marshalling /// One-entry-call helper for FFI functions returning a masternode