Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
926eebb
feat(swift-example-app): wallet-signed Transfer & Withdraw for platfo…
QuantumExplorer Jun 16, 2026
20761a2
fix(swift-example-app): address review feedback on platform-address t…
QuantumExplorer Jun 16, 2026
6b65c44
fix(swift-example-app): honor source account + classify wrong-network…
QuantumExplorer Jun 16, 2026
057b64e
fix(platform-wallet): reserve withdrawal fee on the fee-source input
QuantumExplorer Jun 16, 2026
1bcedc7
fix(swift-sdk): honor transfer account, pin signer, overflow-safe fee…
QuantumExplorer Jun 16, 2026
5382a74
fix(swift-sdk): filter dust from withdrawal inputs, scope pickers to …
QuantumExplorer Jun 16, 2026
c1e291b
fix(swift-sdk): scope transfer change address to key class 0 and pers…
QuantumExplorer Jun 16, 2026
8ab7c00
fix(platform-wallet): guard fee-source index u16 narrowing in withdrawal
QuantumExplorer Jun 16, 2026
15cd3d2
fix(swift-example-app): restrict withdraw Core fee rate to protocol-v…
QuantumExplorer Jun 16, 2026
51f0c44
fix(swift-sdk): make platform-address transfer P2PKH-only contract ac…
QuantumExplorer Jun 16, 2026
a62d1fa
refactor(swift-sdk): move platform-address transfer input selection i…
QuantumExplorer Jun 16, 2026
10a3da7
fix(swift-example-app): scope generic Send platform source account to…
QuantumExplorer Jun 16, 2026
c07e77a
fix(swift-example-app): fund-aware platform account selection, non-fa…
QuantumExplorer Jun 16, 2026
e0f106b
fix(swift-example-app): scope transfer collision guard to key class 0…
QuantumExplorer Jun 16, 2026
f808adf
fix(swift-example-app): exclude recipient from Send account funding c…
QuantumExplorer Jun 16, 2026
9b5b7d7
fix(swift-sdk): gate Transfer/Withdraw account totals on min_input_am…
QuantumExplorer Jun 17, 2026
91e7c4b
fix(swift-sdk): stop withdraw sheet burning Core receive addresses; g…
QuantumExplorer Jun 17, 2026
6dd8ef7
feat(swift-sdk): withdrawal preflight so the Withdraw gate matches Ru…
QuantumExplorer Jun 21, 2026
93ee8c0
Merge remote-tracking branch 'origin/v3.1-dev' into claude/vigilant-l…
QuantumExplorer Jun 21, 2026
471811b
fix(swift-sdk): withdrawal planner enforces all structure-validator l…
QuantumExplorer Jun 21, 2026
9212110
fix(swift-sdk): transfer AUTO selector mirrors withdrawal's max-input…
QuantumExplorer Jun 21, 2026
332afbd
fix(swift-example-app): bring generic Send flow to parity with Rust t…
QuantumExplorer Jun 21, 2026
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 @@ -112,6 +112,52 @@ pub unsafe extern "C" fn platform_address_wallet_addresses_with_balances(
PlatformWalletFFIResult::ok()
}

/// Get all platform addresses with their cached balances for a specific
/// platform-payment account (`account_index`, key class 0).
///
/// Account-scoped sibling of
/// [`platform_address_wallet_addresses_with_balances`]: resolves the requested
/// account rather than always the first one, and stamps each returned entry's
/// `account_index` with the requested value. Callers that build explicit
/// transfer inputs must use this so the spent source account matches the
/// `account_index` the transfer persists/nonces against.
///
/// On success, `out_entries` and `out_count` are set to a heap-allocated array.
/// Free with `platform_address_wallet_free_address_balances`.
#[no_mangle]
pub unsafe extern "C" fn platform_address_wallet_addresses_with_balances_for_account(
handle: Handle,
account_index: u32,
out_entries: *mut *mut AddressBalanceEntryFFI,
out_count: *mut usize,
) -> PlatformWalletFFIResult {
check_ptr!(out_entries);
check_ptr!(out_count);

let option = PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| {
let balances =
runtime().block_on(wallet.addresses_with_balances_for_account(account_index));
balances
.into_iter()
.map(|(address, balance)| AddressBalanceEntryFFI {
address: address.into(),
balance,
nonce: 0,
account_index,
address_index: 0,
})
.collect::<Vec<_>>()
});
let entries = unwrap_option_or_return!(option);
*out_count = entries.len();
if entries.is_empty() {
*out_entries = std::ptr::null_mut();
} else {
*out_entries = Box::into_raw(entries.into_boxed_slice()) as *mut AddressBalanceEntryFFI;
}
PlatformWalletFFIResult::ok()
}

// ---------------------------------------------------------------------------
// Memory deallocation
// ---------------------------------------------------------------------------
Expand Down
152 changes: 152 additions & 0 deletions packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ use crate::platform_address_types::*;
use crate::{unwrap_option_or_return, unwrap_result_or_return};
use dpp::identity::core_script::CoreScript;
use rs_sdk_ffi::{SignerHandle, VTableSigner};
use std::os::raw::c_char;
use std::str::FromStr;

use super::{parse_input_selection, runtime};

Expand Down Expand Up @@ -64,3 +66,153 @@ pub unsafe extern "C" fn platform_address_wallet_withdraw(
*out_changeset = PlatformAddressChangeSetFFI::from(&changeset);
PlatformWalletFFIResult::ok()
}

/// Withdraw platform credits to a Core L1 address given as a base58
/// string (e.g. `yXV…` on testnet / `X…` on mainnet).
///
/// Sibling of [`platform_address_wallet_withdraw`] that accepts a
/// human-facing Core address instead of a pre-built `output_script`
/// byte buffer. The address is parsed and **network-checked against
/// the wallet's own network** entirely on the Rust side — a
/// testnet-shaped address can never be withdrawn to on a mainnet
/// wallet (and vice versa). The resulting P2PKH/P2SH `script_pubkey`
/// is then handed to the same `wallet.withdraw(...)` entry point, so
/// input selection, fee strategy, and signing are identical to the
/// raw-script path.
///
/// `signer_address_handle` is a `*mut SignerHandle` produced by
/// `dash_sdk_signer_create_with_ctx` (e.g. via `KeychainSigner.handle`)
/// and is consumed as `Signer<PlatformAddress>` for each input
/// address. The caller retains ownership of the handle; this function
/// does NOT destroy it.
///
/// Free result with `platform_address_wallet_free_changeset`.
///
/// # Safety
/// - `core_address` must be a valid, non-null, NUL-terminated C string.
/// - `signer_address_handle` must be a valid, non-destroyed
/// `*mut SignerHandle` that outlives this call.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn platform_address_wallet_withdraw_to_address(
handle: Handle,
account_index: u32,
input_type: InputSelectionType,
explicit_inputs: *const ExplicitInputFFI,
explicit_inputs_count: usize,
nonce_inputs: *const ExplicitInputWithNonceFFI,
nonce_inputs_count: usize,
core_address: *const c_char,
core_fee_per_byte: u32,
fee_strategy: *const FeeStrategyStepFFI,
fee_strategy_count: usize,
signer_address_handle: *mut SignerHandle,
out_changeset: *mut PlatformAddressChangeSetFFI,
) -> PlatformWalletFFIResult {
check_ptr!(out_changeset);
check_ptr!(core_address);
check_ptr!(signer_address_handle);

let address_str = unwrap_result_or_return!(std::ffi::CStr::from_ptr(core_address).to_str());
// Parse the address as network-unchecked first; the network is
// pulled from the wallet (not threaded as a parameter, which would
// be ambiguous if the two disagreed) and enforced below.
let unchecked_address = unwrap_result_or_return!(dashcore::Address::from_str(address_str));

let input_selection = unwrap_result_or_return!(parse_input_selection(
input_type,
explicit_inputs,
explicit_inputs_count,
nonce_inputs,
nonce_inputs_count,
));

let fee = parse_fee_strategy(fee_strategy, fee_strategy_count);

// SAFETY: caller guarantees `signer_address_handle` is a valid,
// non-destroyed handle that outlives this call.
let address_signer: &VTableSigner = &*(signer_address_handle as *const VTableSigner);

// The closure returns a typed `PlatformWalletFFIResult` on the error
// side so the network-mismatch case can surface as the dedicated
// `ErrorInvalidNetwork` code instead of flattening to `ErrorUnknown`
// via the blanket `From<PlatformWalletError>` impl. The withdraw
// error still routes through that blanket conversion (`.into()`),
// preserving its per-variant code mapping.
let option = PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| {
// Network check: reject an address that doesn't belong to the
// wallet's network before any signing or submission happens.
// Mirrors the `require_network` precedent used elsewhere in the
// FFI for Core-address handling. `require_network` consumes the
// unchecked address, which isn't reused afterwards.
let checked_address = unchecked_address
.require_network(wallet.network())
Comment thread
QuantumExplorer marked this conversation as resolved.
.map_err(|e| {
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidNetwork,
format!(
"Core address is not valid for the wallet's network ({:?}): {e}",
wallet.network()
),
)
})?;
let core_script = CoreScript::new(checked_address.script_pubkey());
runtime()
.block_on(wallet.withdraw(
account_index,
input_selection,
core_script,
core_fee_per_byte,
fee,
None,
address_signer,
))
.map_err(PlatformWalletFFIResult::from)
});
// `result` is `Result<PlatformAddressChangeSet, PlatformWalletFFIResult>`:
// a network mismatch is already a typed `ErrorInvalidNetwork` result,
// any other withdraw failure is the blanket-mapped wallet error.
let result = unwrap_option_or_return!(option);
let changeset = unwrap_result_or_return!(result);
Comment thread
QuantumExplorer marked this conversation as resolved.
*out_changeset = PlatformAddressChangeSetFFI::from(&changeset);
PlatformWalletFFIResult::ok()
}

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

/// Pins the exact network-validation mechanism
/// `platform_address_wallet_withdraw_to_address` relies on: a
/// testnet-prefixed Core address must pass `require_network` on a
/// testnet wallet and fail on a mainnet wallet, and the resulting
/// script must be a P2PKH that builds a `CoreScript`.
///
/// We exercise the helper logic directly (parse → require_network →
/// script_pubkey → CoreScript) rather than the FFI entry point,
/// which would need a live wallet handle.
#[test]
fn withdraw_address_network_check_rejects_wrong_network() {
// A valid testnet-prefixed (0x8C, "y…") P2PKH address.
let addr = "yMqShkrgjTRuReBGFpQr7FozEF1QcNBBYA";
let unchecked = dashcore::Address::from_str(addr).expect("valid base58 address");

// Mainnet wallet must reject a testnet address.
assert!(
unchecked.clone().require_network(Network::Mainnet).is_err(),
"testnet address must fail require_network(Mainnet)"
);

// Testnet wallet must accept it, and the script must be P2PKH.
let checked = unchecked
.require_network(Network::Testnet)
.expect("testnet address must pass require_network(Testnet)");
let script = checked.script_pubkey();
let core_script = CoreScript::new(script);
assert!(
core_script.is_p2pkh(),
"a P2PKH address must produce a P2PKH CoreScript"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,11 @@ impl PlatformAddressWallet {
///
/// Returns the balances from the last call to [`sync_balances`](Self::sync_balances),
/// [`transfer`](Self::transfer), or [`withdraw`](Self::withdraw).
///
/// Resolves against the **first** platform-payment account (account index 0,
/// key class 0). Callers that need a specific account must use
/// [`addresses_with_balances_for_account`](Self::addresses_with_balances_for_account)
/// so input selection and persistence agree on the source account.
pub async fn addresses_with_balances(&self) -> Vec<(PlatformAddress, Credits)> {
let wm = self.wallet_manager.read().await;
wm.get_wallet_info(&self.wallet_id)
Expand All @@ -313,6 +318,40 @@ impl PlatformAddressWallet {
.unwrap_or_default()
}

/// Get all platform addresses with their cached balances for a specific
/// platform-payment account.
///
/// Account-scoped sibling of [`addresses_with_balances`](Self::addresses_with_balances):
/// resolves the account via `platform_payment_managed_account_at_index`
/// (key class 0) so the returned balances come from the requested
/// `account_index` rather than always the first account. The transfer path
/// builds its explicit inputs from this so the spent source account matches
/// the `account_index` it persists/nonces against — without this the public
/// `transfer(account_index, ..)` API would silently spend account 0 while
/// telling the chain a different account.
///
/// Returns an empty vec (not an error) when the account has no addresses,
/// matching `addresses_with_balances`'s behaviour for a missing account.
pub async fn addresses_with_balances_for_account(
&self,
account_index: u32,
) -> Vec<(PlatformAddress, Credits)> {
let wm = self.wallet_manager.read().await;
wm.get_wallet_info(&self.wallet_id)
.and_then(|info| {
info.core_wallet
.platform_payment_managed_account_at_index(account_index)
})
.map(|account| {
account
.address_balances
.iter()
.map(|(p2pkh, &bal)| (PlatformAddress::P2pkh(p2pkh.to_bytes()), bal))
.collect()
})
.unwrap_or_default()
}

/// Current incremental-sync watermark (`last_known_recent_block`)
/// from the unified platform-address provider.
///
Expand Down
Loading
Loading