Skip to content
Open
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
11 changes: 10 additions & 1 deletion packages/rs-platform-wallet-ffi/src/dashpay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,8 @@ pub unsafe extern "C" fn platform_wallet_fetch_sent_contact_requests(
/// - `core_signer_handle` must be a valid, non-destroyed
/// `*mut MnemonicResolverHandle`. Ownership is retained by the caller —
/// this function does NOT destroy it.
/// - `out_fee_duffs` may be NULL to ignore the fee; when non-null it must
/// point to valid writable `u64` storage, written only on success.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn platform_wallet_send_dashpay_payment(
Expand All @@ -543,6 +545,7 @@ pub unsafe extern "C" fn platform_wallet_send_dashpay_payment(
memo: *const c_char,
core_signer_handle: *mut MnemonicResolverHandle,
out_txid: *mut [u8; 32],
out_fee_duffs: *mut u64,
) -> PlatformWalletFFIResult {
check_ptr!(core_signer_handle);
check_ptr!(out_txid);
Expand Down Expand Up @@ -592,7 +595,13 @@ pub unsafe extern "C" fn platform_wallet_send_dashpay_payment(
})
});
let result = unwrap_option_or_return!(option);
let (txid, _entry) = unwrap_result_or_return!(result);
let (txid, _entry, fee_duffs) = unwrap_result_or_return!(result);
// Exact network fee of the broadcast transaction (inputs − outputs),
// straight from the builder — nullable so callers that don't care
// can pass NULL.
if !out_fee_duffs.is_null() {
unsafe { *out_fee_duffs = fee_duffs };
}
use dashcore::hashes::Hash;
let bytes = txid.to_raw_hash().to_byte_array();
unsafe {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -538,8 +538,10 @@ impl<B: TransactionBroadcaster + ?Sized> DashPayView<'_, B> {
///
/// # Returns
///
/// The `Txid` of the broadcast transaction and the newly created
/// [`PaymentEntry`] recording the outgoing payment.
/// The `Txid` of the broadcast transaction, the newly created
/// [`PaymentEntry`] recording the outgoing payment, and the exact
/// network fee in duffs (inputs − outputs) of the broadcast
/// transaction.
pub async fn send_payment<S, C>(
&self,
from_identity_id: &Identifier,
Expand All @@ -552,6 +554,7 @@ impl<B: TransactionBroadcaster + ?Sized> DashPayView<'_, B> {
(
dashcore::Txid,
crate::wallet::identity::types::dashpay::payment::PaymentEntry,
u64, // network fee in duffs, from the broadcast transaction
),
PlatformWalletError,
>
Expand All @@ -575,7 +578,7 @@ impl<B: TransactionBroadcaster + ?Sized> DashPayView<'_, B> {
// re-acquires that (non-reentrant) lock internally.
self.drain_pending_contact_crypto(provider).await;

let (payment_address, tx) = {
let (payment_address, tx, fee) = {
let mut wm = self.wallet_manager.write().await;

// Resolve the external account's xpub so we can derive addresses.
Expand Down Expand Up @@ -664,14 +667,48 @@ impl<B: TransactionBroadcaster + ?Sized> DashPayView<'_, B> {
// `impl<S: Signer> TransactionSigner for S`) rather than the
// resident `wallet`, so funding-input signatures are produced
// from Keychain-derived keys without a resident seed.
let (tx, _fee) = builder
let (tx, _size_based_fee) = builder
.build_signed(signer, |addr| {
managed_account.address_derivation_path(&addr)
})
.await
.map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?;

(payment_address, tx)
// Exact network fee: Σ(selected input values) − Σ(output
// values). The builder's returned fee is size-based
// (`fee_rate × encoded_size`) and under-reports whenever it
// drops a dust change remainder (≤ 546 duffs) to miners
// instead of emitting the change output — the dust is part
// of what the sender actually pays. Input values are read
// from the account UTXO map (the build reserves selected
// UTXOs but does not remove them; removal happens on spend
// detection).
let account_utxos = &info
.core_wallet
.accounts
.standard_bip44_accounts
.get(&0)
.ok_or_else(|| {
PlatformWalletError::TransactionBuild(
"BIP-44 managed account 0 not found".to_string(),
)
})?
.utxos;
let input_total = tx.input.iter().try_fold(0u64, |acc, txin| {
account_utxos
.get(&txin.previous_output)
.map(|utxo| acc.saturating_add(utxo.txout.value))
.ok_or_else(|| {
PlatformWalletError::TransactionBuild(format!(
"selected input {} missing from the account UTXO set",
txin.previous_output
))
})
})?;
let output_total: u64 = tx.output.iter().map(|o| o.value).sum();
let fee = input_total.saturating_sub(output_total);

(payment_address, tx, fee)
};

// --- 3. Broadcast the transaction, releasing the build's UTXO
Expand Down Expand Up @@ -726,7 +763,7 @@ impl<B: TransactionBroadcaster + ?Sized> DashPayView<'_, B> {
})?;
}

Ok((txid, entry))
Ok((txid, entry, fee))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1924,7 +1924,8 @@ extension ManagedPlatformWallet {

/// Send a Dash payment to an established DashPay contact.
/// `amountDuffs` is in duffs (1 DASH = 100_000_000 duffs).
/// Returns the 32-byte transaction id.
/// Returns the 32-byte transaction id plus the exact network fee
/// (duffs) of the broadcast transaction, straight from the builder.
///
/// Prerequisite: `register_external_contact_account` must have
/// run for the `(fromIdentityId, toContactIdentityId)` pair on
Expand All @@ -1936,7 +1937,7 @@ extension ManagedPlatformWallet {
toContactIdentityId: Identifier,
amountDuffs: UInt64,
memo: String? = nil
) async throws -> Data {
) async throws -> (txid: Data, feeDuffs: UInt64) {
let handle = self.handle
let fromBytes: [UInt8] = fromIdentityId.withFFIBytes { ptr in
Array(UnsafeBufferPointer(start: ptr, count: 32))
Expand All @@ -1951,7 +1952,8 @@ extension ManagedPlatformWallet {
// derived, digest signed, buffers zeroed) — the seed never becomes
// resident and no private key leaves Swift.
let coreSigner = MnemonicResolver()
return try await Task.detached(priority: .userInitiated) { () -> Data in
return try await Task.detached(priority: .userInitiated) { () -> (txid: Data, feeDuffs: UInt64) in
var feeDuffs: UInt64 = 0
var txidTuple: (
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
Expand All @@ -1976,7 +1978,8 @@ extension ManagedPlatformWallet {
amountDuffs,
memoPtr,
coreSigner.handle,
&txidTuple
&txidTuple,
&feeDuffs
)
}
if let memoCopy {
Expand All @@ -1988,7 +1991,8 @@ extension ManagedPlatformWallet {
}
}
try result.check()
return Swift.withUnsafeBytes(of: &txidTuple) { Data($0) }
let txid = Swift.withUnsafeBytes(of: &txidTuple) { Data($0) }
return (txid: txid, feeDuffs: feeDuffs)
}.value
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import CryptoKit
import Foundation

/// **Dev/QA tool only.** App-level client for the testnet DASH faucet at
/// `faucet.thepasta.org`. This is a plain `URLSession` HTTP client plus a
/// pure-Swift `cap.js` proof-of-work solver — it deliberately does **not**
/// touch the wallet / Platform FFI, the `platform-wallet` crate, or any
/// SDK business logic. It exists purely so QA can top up a fresh/drained
/// testnet wallet with one tap instead of round-tripping through the web
/// faucet (issue #3897).
/// **Dev/QA tool only.** Client for the testnet DASH faucet at
/// `faucet.thepasta.org`, shared by SwiftExampleApp and dashwallet-ios
/// (promoted here from the example app so the apps don't carry drifting
/// copies). This is a plain `URLSession` HTTP client plus a pure-Swift
/// `cap.js` proof-of-work solver — it deliberately does **not** touch the
/// wallet / Platform FFI, the `platform-wallet` crate, or any SDK business
/// logic. It exists purely so QA can top up a fresh/drained testnet wallet
/// with one tap instead of round-tripping through the web faucet
/// (issue #3897).
///
/// The faucet enforces a server-side captcha (`cap.js`). The "soft"
/// challenge is a small proof-of-work (≈6.5M SHA-256 ops) that we solve
Expand Down Expand Up @@ -125,7 +127,7 @@ enum CapSolver {

/// Result of a faucet funding attempt. The view turns `.failed` /
/// `.rateLimited` into the clipboard + web-faucet fallback.
enum TestnetFaucetOutcome: Sendable {
public enum TestnetFaucetOutcome: Sendable {
/// Funds were sent. `txid` is the Core transaction id; `amount` is the
/// tDASH amount reported by `/api/status` (`coreFaucetAmount`).
case sent(txid: String, amount: Double)
Expand All @@ -138,21 +140,21 @@ enum TestnetFaucetOutcome: Sendable {
// MARK: - HTTP client

/// Thin async client for the testnet faucet. Stateless; create one per use.
struct TestnetFaucet {
public struct TestnetFaucet {
/// Faucet web host. Used both for the JSON API and as the web fallback URL.
static let webURL = URL(string: "https://faucet.thepasta.org/")!
public static let webURL = URL(string: "https://faucet.thepasta.org/")!

private let host = URL(string: "https://faucet.thepasta.org")!
private let session: URLSession

init(session: URLSession = .shared) {
public init(session: URLSession = .shared) {
self.session = session
}

/// Request 1 tDASH to `address`. Solves the soft captcha on-device and
/// posts to `/api/core-faucet`. Safe to call from the main actor — the
/// proof-of-work runs on a detached background-priority task.
func requestCoreDash(address: String) async -> TestnetFaucetOutcome {
public func requestCoreDash(address: String) async -> TestnetFaucetOutcome {
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return .failed(reason: "No address available")
Expand Down Expand Up @@ -263,6 +265,17 @@ struct TestnetFaucet {
throw FaucetError.message("Unsupported captcha challenge (c=\(c), s=\(s), d=\(d))")
}

// The per-field bounds alone still admit c=256, d=6 ≈ 4.3B expected
// attempts — enough to pin the CPU until the 30 s timeout on every
// tap. Cap the AGGREGATE expected work (c × 16^d). The live faucet's
// soft challenge is c=100, d=4 ≈ 6.6M attempts; 64M gives ~10×
// headroom for server-side tuning while keeping the worst case to a
// few seconds of parallel hashing.
let expectedWork = Double(c) * pow(16, Double(d))
guard expectedWork <= 64_000_000 else {
throw FaucetError.message("Captcha challenge too expensive (c=\(c), d=\(d))")
}

// Solve all c sub-challenges off the main actor, parallelized, under
// an overall timeout so even an in-bounds-but-slow challenge can't
// hang the UI indefinitely (the timeout cancels the solve tasks).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ struct SendDashPayPaymentSheet: View {
// useful to pass. The Rust-side
// `PaymentEntry.memo` slot stays available for
// future local-note wiring.
let txid = try await wallet.sendDashPayPayment(
let (txid, _) = try await wallet.sendDashPayPayment(
fromIdentityId: senderIdentity.identityId,
toContactIdentityId: contact.identityId,
amountDuffs: duffs,
Expand Down
Loading