Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
//! Mirrors the structure of `platform_wallet::wallet::platform_addresses`.

mod fund_from_asset_lock;
mod quote;
mod sync;
mod transfer;
mod wallet;
mod withdrawal;

// Re-export all FFI types and functions.
pub use fund_from_asset_lock::*;
pub use quote::*;
pub use sync::*;
pub use transfer::*;
pub use wallet::*;
Expand Down
194 changes: 194 additions & 0 deletions packages/rs-platform-wallet-ffi/src/platform_addresses/quote.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//! FFI for the state-aware address funding fee quote
//! (`getAddressFundingFeeQuote`).

use dashcore::hashes::Hash;
use dpp::address_funds::PlatformAddress;

use crate::check_ptr;
use crate::core_wallet_types::OutPointFFI;
use crate::error::*;
use crate::handle::*;
use crate::runtime::block_on_worker;
use crate::{unwrap_option_or_return, unwrap_result_or_return};

/// A state-aware fee quote for a 0-input / 1-output address funding from a
/// fresh asset lock, as computed by a node.
///
/// Plain data — nothing to free. The quote is planning data from a single
/// node (the response carries no proof): sizing the funding lock stays
/// governed by `minimum_required_lock_credits` plus the application's own
/// margin policy.
#[repr(C)]
#[derive(Debug, Clone, Copy, Default)]
pub struct AddressFundingFeeQuoteFFI {
/// State-aware estimate of the fee the network would charge for the
/// funding executed near the quoted state (includes the requested user
/// fee increase).
pub estimated_fee_credits: u64,
/// The consensus admission floor for the asset lock.
pub minimum_required_lock_credits: u64,
/// The protocol version the node quoted with.
pub protocol_version: u32,
/// The committed block height the quote was computed on.
pub state_height: u64,
}

/// Fetch a state-aware fee quote for funding one platform address from a
/// fresh asset lock (0 address inputs, 1 remainder output).
///
/// Asynchronous network call executed on the wallet's worker runtime; the
/// node computes the quote read-only against its committed state. There is
/// no offline fallback — a network failure returns an error, never a stale
/// constant.
///
/// - `recipient_address` / `recipient_address_len`: the serialized platform
/// address of the funding recipient (as produced by the wallet's address
/// APIs).
/// - `prepared_outpoint`: the exact asset-lock outpoint when the wallet has
/// already built and signed the lock transaction; NULL lets the node use a
/// deterministic placeholder with the same expected search depth. An
/// outpoint that is already spent on Platform is rejected by the node.
/// - `user_fee_increase`: the fee increase the quote should include; the
/// SDK's chain-lock retry loop can raise a funding by up to 14 units.
///
/// # Safety
/// - `recipient_address` must be valid for `recipient_address_len` bytes.
/// - `prepared_outpoint` must be NULL or a valid pointer.
/// - `out_quote` must be a valid, writable pointer.
#[no_mangle]
pub unsafe extern "C" fn platform_address_wallet_quote_funding_fee(
handle: Handle,
recipient_address: *const u8,
recipient_address_len: usize,
prepared_outpoint: *const OutPointFFI,
user_fee_increase: u16,
out_quote: *mut AddressFundingFeeQuoteFFI,
) -> PlatformWalletFFIResult {
check_ptr!(out_quote);
*out_quote = AddressFundingFeeQuoteFFI::default();
check_ptr!(recipient_address);

let address_bytes = std::slice::from_raw_parts(recipient_address, recipient_address_len);
let recipient = match PlatformAddress::from_bytes(address_bytes) {
Ok(recipient) => recipient,
Err(e) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!("recipient_address is not a serialized platform address: {e}"),
);
}
};

let outpoint: Option<[u8; 36]> = if prepared_outpoint.is_null() {
None
} else {
let outpoint_ffi = *prepared_outpoint;
// The same byte layout the chain uses for outpoint keys:
// raw txid bytes followed by the vout in little endian.
let out_point = dashcore::OutPoint {
txid: dashcore::Txid::from_byte_array(outpoint_ffi.txid),
vout: outpoint_ffi.vout,
};
Some(out_point.into())
};
Comment on lines +82 to +93

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Add a regression test for prepared-outpoint serialization

The guard test supplies a null prepared_outpoint in every call, leaving the new non-null conversion branch untested. This branch defines an endian-sensitive chain key used to detect an already-consumed asset lock. Add an offline unit test with a patterned 32-byte txid and a non-symmetric vout, asserting that conversion produces exactly raw_txid || vout.to_le_bytes(). This will catch accidental txid reversal or native-endian vout serialization without requiring a network-backed wallet.

source: ['codex']


let option = PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| {
let wallet_clone = wallet.clone();
block_on_worker(async move {
wallet_clone
.quote_funding_fee(recipient, outpoint, user_fee_increase)
.await
})
});
Comment on lines +95 to +102

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Release the handle-storage lock before awaiting the network quote

HandleStorage::with_item retains its global RwLock read guard until its closure returns. Because block_on_worker runs inside that closure, the guard remains held for the entire network request, preventing creation or destruction of any platform-address wallet that needs the storage write lock. The wallet is already cheap and safe to clone, and the transfer and withdrawal bindings release this lock before starting their long-running work. Clone the wallet out of storage first, then invoke the worker runtime.

Suggested change
let option = PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| {
let wallet_clone = wallet.clone();
block_on_worker(async move {
wallet_clone
.quote_funding_fee(recipient, outpoint, user_fee_increase)
.await
})
});
let option = PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| wallet.clone());
let wallet = unwrap_option_or_return!(option);
let result = block_on_worker(async move {
wallet
.quote_funding_fee(recipient, outpoint, user_fee_increase)
.await
});

source: ['codex']

let result = unwrap_option_or_return!(option);
let quote = unwrap_result_or_return!(result);

*out_quote = AddressFundingFeeQuoteFFI {
estimated_fee_credits: quote.estimated_fee_credits,
minimum_required_lock_credits: quote.minimum_required_lock_credits,
protocol_version: quote.protocol_version,
state_height: quote.state_height,
};
PlatformWalletFFIResult::ok()
}

#[cfg(test)]
mod tests {
use super::*;

/// Null-pointer and invalid-handle guards fail closed with the right
/// codes and never touch the out parameter's success path.
#[test]
fn test_guards_fail_closed() {
let mut out = AddressFundingFeeQuoteFFI {
estimated_fee_credits: 0xDEAD,
..Default::default()
};

// Null out pointer.
let mut result = unsafe {
platform_address_wallet_quote_funding_fee(
0,
std::ptr::null(),
0,
std::ptr::null(),
0,
std::ptr::null_mut(),
)
};
assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer);
unsafe { platform_wallet_ffi_result_free(&mut result) };

// Null address pointer: out must be reset to the zero sentinel.
let mut result = unsafe {
platform_address_wallet_quote_funding_fee(
0,
std::ptr::null(),
0,
std::ptr::null(),
0,
&mut out,
)
};
assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer);
assert_eq!(out.estimated_fee_credits, 0, "sentinel must be written");
unsafe { platform_wallet_ffi_result_free(&mut result) };

// Malformed address bytes.
let bad_address = [0xFFu8; 3];
let mut result = unsafe {
platform_address_wallet_quote_funding_fee(
0,
bad_address.as_ptr(),
bad_address.len(),
std::ptr::null(),
0,
&mut out,
)
};
assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorInvalidParameter
);
unsafe { platform_wallet_ffi_result_free(&mut result) };

// Unknown handle with a well-formed address.
let address = dpp::address_funds::PlatformAddress::P2pkh([7; 20]).to_bytes();
let mut result = unsafe {
platform_address_wallet_quote_funding_fee(
0,
address.as_ptr(),
address.len(),
std::ptr::null(),
0,
&mut out,
)
};
assert_eq!(
result.code,
PlatformWalletFFIResultCode::NotFound,
"an unknown handle maps to NotFound"
);
unsafe { platform_wallet_ffi_result_free(&mut result) };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -902,4 +902,109 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable {
)
}
}

// MARK: - Funding fee quote

/// A state-aware fee quote for a 0-input / 1-output address funding from
/// a fresh asset lock, computed by a node (`getAddressFundingFeeQuote`).
///
/// Planning data from a single node — the response carries no proof.
/// Sizing the funding lock stays governed by
/// `minimumRequiredLockCredits` plus the application's own margin policy.
public struct AddressFundingFeeQuote: Sendable, Equatable {
/// State-aware estimate of the fee the network would charge for the
/// funding executed near the quoted state (includes the requested
/// user fee increase).
public let estimatedFeeCredits: UInt64
/// The consensus admission floor for the asset lock.
public let minimumRequiredLockCredits: UInt64
/// The protocol version the node quoted with.
public let protocolVersion: UInt32
/// The committed block height the quote was computed on.
public let stateHeight: UInt64
}

/// Fetch a state-aware fee quote for funding one platform address from a
/// fresh asset lock (0 address inputs, 1 remainder output).
///
/// Asynchronous network call: the node prices the exact production
/// operations against its committed state (measured tree depths instead
/// of worst-case constants). There is no offline fallback — a network
/// failure throws, never a stale constant.
///
/// - Parameters:
/// - recipientAddress: the serialized platform address of the funding
/// recipient, in the same byte format the `Addresses` query APIs use.
/// - preparedOutpointTxid: the 32-byte txid of an already built and
/// signed lock transaction, when the wallet has one; `nil` lets the
/// node use a deterministic placeholder with the same expected
/// search depth. An outpoint already spent on Platform is rejected.
Comment on lines +938 to +941

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Specify the required txid byte order in the public Swift API

The wrapper copies preparedOutpointTxid byte-for-byte into OutPointFFI, and Rust passes those bytes to Txid::from_byte_array before serializing the Platform outpoint key. The parameter therefore requires the raw little-endian wire representation rather than the customary display-order txid. The sibling resumeFundFromAssetLock API documents this requirement explicitly. Without equivalent documentation here, a caller can reverse the wrong bytes and query a different outpoint, causing a spent prepared lock to appear absent instead of being rejected.

Suggested change
/// - preparedOutpointTxid: the 32-byte txid of an already built and
/// signed lock transaction, when the wallet has one; `nil` lets the
/// node use a deterministic placeholder with the same expected
/// search depth. An outpoint already spent on Platform is rejected.
/// - preparedOutpointTxid: the 32-byte raw txid in little-endian wire
/// order (the same byte order as `OutPointFFI.txid`) of an already
/// built and signed lock transaction. If sourced from display-order
/// hex, decode it back to raw order before passing it here. `nil` lets
/// the node use a deterministic placeholder with the same expected
/// search depth. An outpoint already spent on Platform is rejected.

source: ['codex']

/// - preparedOutpointVout: the credit output index of the prepared
/// lock; ignored when `preparedOutpointTxid` is `nil`.
/// - userFeeIncrease: the fee increase the quote should include; the
/// SDK's chain-lock retry loop can raise a funding by up to 14 units.
public func quoteFundingFee(
recipientAddress: Data,
preparedOutpointTxid: Data? = nil,
preparedOutpointVout: UInt32 = 0,
userFeeIncrease: UInt16 = 0
) async throws -> AddressFundingFeeQuote {
if let txid = preparedOutpointTxid, txid.count != 32 {
throw PlatformWalletError.invalidParameter(
"preparedOutpointTxid must be exactly 32 bytes (was \(txid.count))"
)
}
let handle = self.handle

return try await Task.detached(priority: .userInitiated) {
() -> AddressFundingFeeQuote in
var quote = AddressFundingFeeQuoteFFI()
let result: PlatformWalletFFIResult
if let txid = preparedOutpointTxid {
var txidTuple: (
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
) = (
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
)
txid.withUnsafeBytes { src in
Swift.withUnsafeMutableBytes(of: &txidTuple) { dst in
dst.copyMemory(from: src)
}
}
var outPoint = OutPointFFI(txid: txidTuple, vout: preparedOutpointVout)
result = recipientAddress.withUnsafeBytes { addr in
platform_address_wallet_quote_funding_fee(
handle,
addr.bindMemory(to: UInt8.self).baseAddress,
UInt(recipientAddress.count),
&outPoint,
userFeeIncrease,
&quote
)
}
} else {
result = recipientAddress.withUnsafeBytes { addr in
platform_address_wallet_quote_funding_fee(
handle,
addr.bindMemory(to: UInt8.self).baseAddress,
UInt(recipientAddress.count),
nil,
userFeeIncrease,
&quote
)
}
}
try result.check()
return AddressFundingFeeQuote(
estimatedFeeCredits: quote.estimated_fee_credits,
minimumRequiredLockCredits: quote.minimum_required_lock_credits,
protocolVersion: quote.protocol_version,
stateHeight: quote.state_height
)
}.value
}
}
Loading