Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
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
153 changes: 151 additions & 2 deletions packages/rs-platform-wallet-ffi/src/platform_address_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,24 @@ use std::collections::BTreeMap;
// ---------------------------------------------------------------------------

/// Fixed-size C-compatible platform address.
///
/// `address_type` mirrors the [`PlatformAddress`] variant discriminant
/// (`0 = P2pkh`, `1 = P2sh`) and is preserved faithfully by the
/// [`From<PlatformAddress>`] direction. The **reverse** direction
/// ([`TryFrom<PlatformAddressFFI>`]) used by the platform-address
/// transfer/withdraw entry points (`parse_outputs`,
/// `parse_explicit_inputs`, `parse_explicit_inputs_with_nonces`) accepts
/// `0` (P2PKH) **only** — see that impl for why. Callers driving those
/// entry points must pass `address_type = 0`.
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct PlatformAddressFFI {
/// 0 = P2pkh, 1 = P2sh
/// `0 = P2pkh`, `1 = P2sh`.
///
/// NOTE: the platform-address transfer/withdraw surface only honors
/// `0` on the way **in** (see [`TryFrom<PlatformAddressFFI>`]); `1`
/// round-trips out of [`From<PlatformAddress>`] but is rejected if
/// fed back into a transfer/withdraw input or output.
pub address_type: u8,
/// 20-byte hash
pub hash: [u8; 20],
Expand All @@ -36,10 +50,42 @@ impl From<PlatformAddress> for PlatformAddressFFI {

impl TryFrom<PlatformAddressFFI> for PlatformAddress {
type Error = &'static str;
/// Accepts `address_type = 0` (P2PKH) **only**.
///
/// This conversion backs the platform-address transfer/withdraw
/// inputs and outputs (`parse_explicit_inputs`,
/// `parse_explicit_inputs_with_nonces`, `parse_outputs`). P2SH
/// (`address_type = 1`) is intentionally rejected here even though
/// the [`PlatformAddress`] enum and the consensus transition can
/// represent it:
///
/// - **Inputs** are spent via `Signer<PlatformAddress>`, whose FFI
/// `VTableSigner::sign_create_witness` produces only P2PKH
/// witnesses and explicitly errors on P2SH (the iOS
/// `KeychainSigner` holds P2PKH key material only). A P2SH input
/// cannot be signed on this path.
/// - **Outputs/recipients** on this surface are always P2PKH in
/// practice: the wallet derives P2PKH platform-payment addresses,
/// and the Swift transfer UI tags own-wallet and pasted-hash
/// recipients as P2PKH.
///
/// Accepting `1` here would only relocate the failure deeper (to the
/// signer for inputs) without enabling a working P2SH transfer, so
/// the contract is narrowed to P2PKH and the rejection is specific.
/// The identity-side siblings (`identity_transfer.rs`,
/// `identity_registration_with_signer.rs`) accept `1` because there
/// the address is a pure recipient signed by an *identity* key, never
/// spent as a `PlatformAddress` — a genuinely different capability.
fn try_from(ffi: PlatformAddressFFI) -> Result<Self, Self::Error> {
match ffi.address_type {
0 => Ok(PlatformAddress::P2pkh(ffi.hash)),
_ => Err("Unsupported address type"),
1 => Err("platform-address transfers/withdrawals support P2PKH \
(address_type 0) only; P2SH (address_type 1) cannot be \
signed or spent on this surface"),
_ => Err(
"invalid address_type (platform-address transfers/withdrawals \
accept P2PKH, address_type 0, only)",
),
}
}
}
Expand Down Expand Up @@ -516,6 +562,109 @@ mod tests {
assert_eq!(err, "Duplicate input address");
}

/// The platform-address transfer/withdraw surface accepts P2PKH
/// (`address_type = 0`) only. P2SH (`address_type = 1`) must be
/// rejected by the shared `TryFrom` with a P2SH-specific message, and
/// any other discriminant with the generic invalid-type message —
/// across all three parse entry points (outputs + both input shapes).
/// The `From<PlatformAddress>` direction still emits `1` for P2SH, so
/// the asymmetry is intentional and pinned here.
#[test]
fn try_from_accepts_p2pkh_and_rejects_p2sh_and_unknown() {
const P2SH_MSG: &str = "platform-address transfers/withdrawals support P2PKH \
(address_type 0) only; P2SH (address_type 1) cannot be \
signed or spent on this surface";
const UNKNOWN_MSG: &str = "invalid address_type (platform-address transfers/withdrawals \
accept P2PKH, address_type 0, only)";

// 0 → P2pkh round-trips.
let p2pkh = PlatformAddressFFI {
address_type: 0,
hash: [0x11; 20],
};
assert_eq!(
PlatformAddress::try_from(p2pkh).expect("address_type 0 must be accepted"),
PlatformAddress::P2pkh([0x11; 20]),
);

// 1 → rejected with the P2SH-specific message.
let p2sh = PlatformAddressFFI {
address_type: 1,
hash: [0x22; 20],
};
assert_eq!(
PlatformAddress::try_from(p2sh).expect_err("address_type 1 (P2SH) must be rejected"),
P2SH_MSG,
);

// Anything else → generic invalid-type message.
let unknown = PlatformAddressFFI {
address_type: 2,
hash: [0x33; 20],
};
assert_eq!(
PlatformAddress::try_from(unknown).expect_err("unknown address_type must be rejected"),
UNKNOWN_MSG,
);

// The `From` direction still faithfully emits the P2SH
// discriminant; only the reverse (transfer/withdraw input) path is
// narrowed.
assert_eq!(
PlatformAddressFFI::from(PlatformAddress::P2sh([0x44; 20])).address_type,
1,
);
}

/// All three input/output parse helpers funnel through the same
/// narrowed `TryFrom`, so a P2SH (`address_type = 1`) entry is rejected
/// with the P2SH-specific diagnostic on every entry point.
#[test]
fn parse_helpers_reject_p2sh_address_type() {
const P2SH_MSG: &str = "platform-address transfers/withdrawals support P2PKH \
(address_type 0) only; P2SH (address_type 1) cannot be \
signed or spent on this surface";

let p2sh = PlatformAddressFFI {
address_type: 1,
hash: [0xAB; 20],
};

let out = [AddressBalanceEntryFFI {
address: p2sh,
balance: 1_000_000,
nonce: 0,
account_index: 0,
address_index: 0,
}];
assert_eq!(
unsafe { parse_outputs(out.as_ptr(), out.len()) }
.expect_err("parse_outputs must reject P2SH"),
P2SH_MSG,
);

let inp = [ExplicitInputFFI {
address: p2sh,
balance: 1_000_000,
}];
assert_eq!(
unsafe { parse_explicit_inputs(inp.as_ptr(), inp.len()) }
.expect_err("parse_explicit_inputs must reject P2SH"),
P2SH_MSG,
);

let inp_nonce = [ExplicitInputWithNonceFFI {
address: p2sh,
nonce: 1,
balance: 1_000_000,
}];
assert_eq!(
unsafe { parse_explicit_inputs_with_nonces(inp_nonce.as_ptr(), inp_nonce.len()) }
.expect_err("parse_explicit_inputs_with_nonces must reject P2SH"),
P2SH_MSG,
);
}

/// Distinct addresses are accepted and the keys end up in DPP-canonical
/// (lexicographic) order regardless of the caller's array order.
#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,52 @@ pub unsafe extern "C" fn platform_address_wallet_total_credits(
PlatformWalletFFIResult::ok()
}

/// Get the per-input minimum credit amount (`min_input_amount`) the
/// chain enforces for address-funds transitions, read from the wallet's
/// current platform version.
///
/// Pure getter: resolve the handle, read
/// `PlatformAddressWallet::min_input_amount()` (which reads the constant
/// off the wallet's SDK-resolved `PlatformVersion`), write it to
/// `out_min_input_amount`. This is the same floor the transfer/withdraw
/// auto-selectors use to drop sub-minimum dust inputs, so a UI gate that
/// sums only balances ≥ this stays in step with what Rust will spend.
#[no_mangle]
pub unsafe extern "C" fn platform_address_wallet_min_input_amount(
handle: Handle,
out_min_input_amount: *mut u64,
) -> PlatformWalletFFIResult {
check_ptr!(out_min_input_amount);

let option =
PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| wallet.min_input_amount());
*out_min_input_amount = unwrap_option_or_return!(option);
PlatformWalletFFIResult::ok()
}

/// Get the per-output minimum credit amount (`min_output_amount`) the
/// chain enforces for address-funds transitions, read from the wallet's
/// current platform version.
///
/// Pure getter: resolve the handle, read
/// `PlatformAddressWallet::min_output_amount()` (which reads the constant
/// off the wallet's SDK-resolved `PlatformVersion`), write it to
/// `out_min_output_amount`. DPP rejects any address-funds output below
/// this floor, so a transfer UI gate that requires the requested amount to
/// reach it stays in step with what DPP will accept.
#[no_mangle]
pub unsafe extern "C" fn platform_address_wallet_min_output_amount(
handle: Handle,
out_min_output_amount: *mut u64,
) -> PlatformWalletFFIResult {
check_ptr!(out_min_output_amount);

let option =
PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| wallet.min_output_amount());
*out_min_output_amount = unwrap_option_or_return!(option);
PlatformWalletFFIResult::ok()
}

/// Get all platform addresses with their cached balances.
///
/// On success, `out_entries` and `out_count` are set to a heap-allocated array.
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"
);
}
}
Loading
Loading