Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -47,7 +47,9 @@ import org.dashfoundation.example.util.toHex
* Shield funds from an asset lock — port of `ShieldedFundFromAssetLockView.swift`.
* Gated on shielded support ([ShieldedGate]). Shows the Halo 2 prover
* readiness (via [ShieldedProver.isReady], warming it on entry) and the
* consensus-pinned shield fee ([ShieldedProver.estimateFee]).
* consensus-pinned shield fee
* ([org.dashfoundation.dashsdk.wallet.PlatformWalletManager.estimateShieldedFee],
* computed at the manager's network-tracked platform version).
*
* Submit is wired to the real shield FFI: the recipient defaults to the
* wallet's own bound shielded address ("shield to self", via
Expand Down Expand Up @@ -77,10 +79,12 @@ fun ShieldedFundScreen(walletIdHex: String, navController: NavHostController) {
runCatching { ShieldedProver.warmUp() }
value = runCatching { ShieldedProver.isReady() }.getOrDefault(false)
}
val feeEstimate by produceState<Long?>(initialValue = null) {
value = runCatching {
ShieldedProver.estimateFee(ShieldedProver.FeeKind.TransferOrShield, 2)
}.getOrNull()
val feeEstimate by produceState<Long?>(initialValue = null, manager) {
value = manager?.let { m ->
runCatching {
m.estimateShieldedFee(ShieldedProver.FeeKind.TransferOrShield, 2)
}.getOrNull()
}
}

// Default "shield to self" recipient — the wallet's bound shielded
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ private const val MEMO_BYTE_LIMIT = 32
*
* The shielded flows settle in credits (1 DASH = 1e11) and block through a
* ~30s Halo 2 proof; their consensus-pinned fee comes from
* [ShieldedProver.estimateFee]. A `ShieldedSpendUnconfirmed` outcome is
* `PlatformWalletManager.estimateShieldedFee` (computed at the manager's
* network-tracked platform version). A `ShieldedSpendUnconfirmed` outcome is
* surfaced through the SUCCESS path ("may have gone through — do not
* retry"), mirroring iOS SendViewModel.swift:790.
*/
Expand Down Expand Up @@ -288,25 +289,23 @@ fun SendTransactionScreen(
}
}
}
val shieldedFeeEstimate by produceState<Long?>(initialValue = null, flow) {
value = when (flow) {
val shieldedFeeEstimate by produceState<Long?>(initialValue = null, flow, manager) {
val activeManager = manager
val kind = when (flow) {
// Type 15 Shield reserves the same compute_minimum_shielded_fee(2)
// base as a shielded→shielded transfer (← iOS estimateFee: .transfer
// for .platformToShielded), so they share the TransferOrShield kind.
SendFlow.SHIELDED_TO_SHIELDED, SendFlow.PLATFORM_TO_SHIELDED ->
runCatching {
ShieldedProver.estimateFee(ShieldedProver.FeeKind.TransferOrShield, 2)
}.getOrNull()
SendFlow.SHIELDED_TO_PLATFORM ->
runCatching {
ShieldedProver.estimateFee(ShieldedProver.FeeKind.Unshield, 2)
}.getOrNull()
SendFlow.SHIELDED_TO_CORE ->
runCatching {
ShieldedProver.estimateFee(ShieldedProver.FeeKind.Withdrawal, 2)
}.getOrNull()
ShieldedProver.FeeKind.TransferOrShield
SendFlow.SHIELDED_TO_PLATFORM -> ShieldedProver.FeeKind.Unshield
SendFlow.SHIELDED_TO_CORE -> ShieldedProver.FeeKind.Withdrawal
SendFlow.CORE_TO_CORE, null -> null
}
value = if (activeManager != null && kind != null) {
runCatching { activeManager.estimateShieldedFee(kind, 2) }.getOrNull()
} else {
null
}
}

val recipientKnown = addressType != DashAddressType.Unknown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ package org.dashfoundation.dashsdk.ffi
* mirrors `rs-unified-sdk-jni/src/funding.rs`.
*
* Internal: the public API is
* [org.dashfoundation.dashsdk.funding.ShieldedProver]. These entry points
* are process-global (no wallet handle) and back the shielded funding
* [org.dashfoundation.dashsdk.funding.ShieldedProver] for the
* process-global prover probes and
* [org.dashfoundation.dashsdk.wallet.PlatformWalletManager.estimateShieldedFee]
* for the (manager-handle) fee estimator. They back the shielded funding
* screens' prover-status indicator and fee preview. Available only when
* the native library was built with shielded support
* ([org.dashfoundation.dashsdk.Sdk.hasShielded]); calling them on a
Expand All @@ -24,10 +26,11 @@ internal object FundingNative {
/**
* The flat shielded fee in credits for a transition of [kind]
* (0 = ShieldedTransfer/Shield, 1 = Unshield, 2 = ShieldedWithdrawal)
* and Orchard action count [numActions]. Pure computation; throws on
* an unknown kind or overflow.
* and Orchard action count [numActions], computed at [managerHandle]'s
* network-tracked platform version. No network round-trip; throws on
* an unknown kind, an invalid manager handle, or overflow.
*/
external fun estimateShieldedFee(kind: Int, numActions: Int): Long
external fun estimateShieldedFee(managerHandle: Long, kind: Int, numActions: Int): Long

// ── Shielded funding submits (manager-handle calls) ──────────────

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,25 @@ import org.dashfoundation.dashsdk.errors.mapNativeErrors
import org.dashfoundation.dashsdk.ffi.FundingNative

/**
* Thin wrapper over the shielded-funding support JNI surface
* (`FundingNative`) — the Halo 2 prover warm-up / readiness probe and the
* shielded fee estimator that back the shielded funding screens'
* prover-status indicator and fee preview.
* Thin wrapper over the process-global shielded-funding support JNI
* surface (`FundingNative`) — the Halo 2 prover warm-up / readiness probe
* that backs the shielded funding screens' prover-status indicator.
*
* Process-global (no wallet handle). Only meaningful on a shielded build
* The shielded fee estimator lives on
* [org.dashfoundation.dashsdk.wallet.PlatformWalletManager.estimateShieldedFee]
* (it resolves the manager's network-tracked platform version, so it needs
* the manager handle); only its [FeeKind] selector is declared here.
*
* Only meaningful on a shielded build
* ([org.dashfoundation.dashsdk.Sdk.hasShielded]); the caller must gate on
* that before use.
*/
object ShieldedProver {

/** Fee-kind selector for [estimateFee]. */
/**
* Fee-kind selector for
* [org.dashfoundation.dashsdk.wallet.PlatformWalletManager.estimateShieldedFee].
*/
enum class FeeKind(val raw: Int) {
/** ShieldedTransfer / Shield (the base flat fee). */
TransferOrShield(0),
Expand All @@ -38,13 +45,4 @@ object ShieldedProver {
suspend fun isReady(): Boolean = withContext(Dispatchers.IO) {
mapNativeErrors { FundingNative.proverIsReady() }
}

/**
* The flat shielded fee in credits for a transition of [kind] and
* Orchard action count [numActions] (a single-note spend with change
* is 2 actions).
*/
suspend fun estimateFee(kind: FeeKind, numActions: Int): Long = withContext(Dispatchers.IO) {
mapNativeErrors { FundingNative.estimateShieldedFee(kind.raw, numActions) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import org.dashfoundation.dashsdk.ffi.DpnsMarketplaceNative
import org.dashfoundation.dashsdk.ffi.FundingNative
import org.dashfoundation.dashsdk.ffi.NativeWalletEventBridge
import org.dashfoundation.dashsdk.ffi.WalletManagerNative
import org.dashfoundation.dashsdk.funding.ShieldedProver
import org.dashfoundation.dashsdk.persistence.DashDatabase
import org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler
import org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity
Expand Down Expand Up @@ -1444,6 +1445,27 @@ class PlatformWalletManager(
}
}

/**
* Consensus-pinned flat shielded fee (in credits) for a pool-paid
* shielded transition of [kind] with [numActions] Orchard actions —
* port of Swift's `PlatformWalletManager.estimateShieldedFee`
* (`PlatformWalletManagerShieldedSync.swift`). Computed at this
* manager's network-tracked platform version (the same version the
* shielded builders carve fees with), so the preview can't drift from
* the fee the consensus gate validates even when the connected network
* hasn't activated the client's latest protocol version yet. No
* network round-trip. A single-note spend with change is
* `numActions = 2`.
*/
suspend fun estimateShieldedFee(
kind: ShieldedProver.FeeKind,
numActions: Int = 2,
): Long = withContext(Dispatchers.IO) {
mapNativeErrors {
FundingNative.estimateShieldedFee(managerHandle, kind.raw, numActions)
}
}

/**
* Fund a wallet's shielded (Orchard) pool from a fresh Core L1 asset
* lock — port of Swift's `shieldedFundFromAssetLock`. Blocks for the
Expand Down
168 changes: 118 additions & 50 deletions packages/rs-platform-wallet-ffi/src/shielded_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,24 @@ pub unsafe extern "C" fn platform_wallet_shielded_prover_is_ready() -> bool {
CachedOrchardProver::new().is_ready()
}

/// Map a fee `kind` byte to its consensus fee formula, or `None` for an
/// unknown kind. All three formulas share the `(num_actions, version) →
/// credits` shape, so the selection is version-independent and testable
/// against any explicit [`PlatformVersion`].
#[allow(clippy::type_complexity)]
fn shielded_fee_formula(
kind: u8,
) -> Option<
fn(usize, &dpp::version::PlatformVersion) -> Result<dpp::fee::Credits, dpp::ProtocolError>,
> {
match kind {
0 => Some(compute_minimum_shielded_fee),
1 => Some(compute_shielded_unshield_fee),
2 => Some(compute_shielded_withdrawal_fee),
_ => None,
}
}

/// Estimate the consensus-pinned flat shielded fee (in credits) for a
/// pool-paid shielded transition.
///
Expand All @@ -169,40 +187,47 @@ pub unsafe extern "C" fn platform_wallet_shielded_prover_is_ready() -> bool {
/// the flat Core withdrawal-document cost).
///
/// `num_actions` is the Orchard action count of the bundle the host will
/// build (a single-note spend with change is 2 actions). The version is
/// pinned to [`PlatformVersion::latest()`] — the same version the shielded
/// builders in `platform-wallet` resolve via `sdk.version()`, so the
/// estimate can't drift from the fee the builder carves and the consensus
/// gate validates.
/// build (a single-note spend with change is 2 actions). The fee is
/// computed at `handle`'s manager's network-tracked platform version
/// (`sdk.version()`) — the same version the shielded builders in
/// `platform-wallet` resolve — so the estimate can't drift from the fee
/// the builder carves and the consensus gate validates, even when the
/// connected network hasn't activated the client's latest protocol
/// version yet.
///
/// Pure computation: no wallet handle, no network. Writes the fee to
/// `out_fee` and returns `ok()`. An unknown `kind` returns
/// `ErrorInvalidParameter`; a fee-formula overflow returns
/// `ErrorArithmeticOverflow`.
/// No network round-trip and no wallet resolution — just the handle →
/// version lookup and a pure computation. Writes the fee to `out_fee` and
/// returns `ok()`. An unknown `kind` returns `ErrorInvalidParameter` (and
/// is checked before the handle, so it fails the same way regardless of
/// handle validity); an unknown `handle` returns `ErrorInvalidHandle`; a
/// fee-formula overflow returns `ErrorArithmeticOverflow`.
///
/// # Safety
/// `out_fee` must point to 8 writable bytes (a `u64`).
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_shielded_estimate_fee(
handle: Handle,
kind: u8,
num_actions: usize,
out_fee: *mut u64,
) -> PlatformWalletFFIResult {
Comment thread
QuantumExplorer marked this conversation as resolved.
check_ptr!(out_fee);

let platform_version = dpp::version::PlatformVersion::latest();
let fee = match kind {
0 => compute_minimum_shielded_fee(num_actions, platform_version),
1 => compute_shielded_unshield_fee(num_actions, platform_version),
2 => compute_shielded_withdrawal_fee(num_actions, platform_version),
other => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!("unknown shielded fee kind {other} (expected 0/1/2)"),
);
}
let Some(formula) = shielded_fee_formula(kind) else {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!("unknown shielded fee kind {kind} (expected 0/1/2)"),
);
};
match fee {
let Some(platform_version) =
PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| manager.sdk().version())
else {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidHandle,
format!("invalid manager handle: {handle}"),
);
};
match formula(num_actions, platform_version) {
Ok(credits) => {
*out_fee = credits;
PlatformWalletFFIResult::ok()
Expand Down Expand Up @@ -1637,47 +1662,90 @@ mod tests {
);
}

/// Pin the fee estimator to the on-chain ground-truth values observed at the current platform
/// version with 2 actions (single-note spend + change). These are the exact credits the
/// builder carves and the consensus gate validates, so the host's "Estimated Fee" must match.
/// Resolve the 2-action fee for `kind` at an explicit protocol version
/// through the same formula table the FFI dispatches on.
fn estimate_at(kind: u8, protocol_version: u32) -> u64 {
let version = dpp::version::PlatformVersion::get(protocol_version)
.expect("protocol version must exist");
shielded_fee_formula(kind).expect("known kind")(2, version)
.expect("fee formula must not overflow at 2 actions")
Comment thread
QuantumExplorer marked this conversation as resolved.
}

/// Pin the fee estimator to the on-chain ground-truth values observed at protocol 13 (the
/// released fee constants) with 2 actions (single-note spend + change). The estimator resolves
/// the version from the manager's network-tracked `sdk.version()`, so on a protocol-13 network
/// these are the exact credits the builder carves and the consensus gate validates.
#[test]
fn estimate_fee_matches_observed_onchain_values_at_protocol_13() {
// kind 0 — ShieldedTransfer / Shield base.
assert_eq!(
estimate_at(0, 13),
162_851_200,
"shielded transfer fee (2 actions, protocol 13)"
);
// kind 1 — Unshield.
assert_eq!(
estimate_at(1, 13),
168_934_000,
"unshield fee (2 actions, protocol 13)"
);
// kind 2 — ShieldedWithdrawal.
assert_eq!(
estimate_at(2, 13),
275_191_200,
"shielded withdrawal fee (2 actions, protocol 13)"
);
}

/// The protocol-14 side of the boundary: the rebalanced constants
/// (40M proof verification + 550 storage bytes/action). A network that
/// has activated protocol 14 must quote these, and a network still on
/// protocol 13 must NOT — the pre-fix estimator pinned
/// `PlatformVersion::latest()` and silently under-quoted protocol-13
/// networks by ~30%.
#[test]
fn estimate_fee_matches_observed_onchain_values_for_2_actions() {
fn estimate_fee_matches_rebalanced_values_at_protocol_14() {
assert_eq!(
estimate_at(0, 14),
114_140_000,
"shielded transfer fee (2 actions, protocol 14)"
);
assert_eq!(
estimate_at(1, 14),
120_222_800,
"unshield fee (2 actions, protocol 14)"
);
assert_eq!(
estimate_at(2, 14),
226_480_000,
"shielded withdrawal fee (2 actions, protocol 14)"
);
}

#[test]
fn estimate_fee_rejects_unknown_kind() {
unsafe {
let estimate = |kind: u8| {
let mut fee: u64 = 0;
let result = platform_wallet_shielded_estimate_fee(kind, 2, &mut fee);
assert_eq!(
result.code,
PlatformWalletFFIResultCode::Success,
"kind {kind} must succeed"
);
fee
};
// kind 0 — ShieldedTransfer / Shield base.
assert_eq!(
estimate(0),
114_140_000,
"shielded transfer fee (2 actions)"
);
// kind 1 — Unshield.
assert_eq!(estimate(1), 120_222_800, "unshield fee (2 actions)");
// kind 2 — ShieldedWithdrawal.
let mut fee: u64 = 0;
// The kind check runs before handle resolution, so a bogus kind
// fails identically with or without a live manager handle.
let result = platform_wallet_shielded_estimate_fee(0, 7, 2, &mut fee);
assert_eq!(
estimate(2),
226_480_000,
"shielded withdrawal fee (2 actions)"
result.code,
PlatformWalletFFIResultCode::ErrorInvalidParameter
);
}
}

#[test]
fn estimate_fee_rejects_unknown_kind() {
fn estimate_fee_rejects_unknown_manager_handle() {
unsafe {
let mut fee: u64 = 0;
let result = platform_wallet_shielded_estimate_fee(7, 2, &mut fee);
let result = platform_wallet_shielded_estimate_fee(0, 0, 2, &mut fee);
assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorInvalidParameter
PlatformWalletFFIResultCode::ErrorInvalidHandle,
"a versionless fallback here would silently mis-quote — an \
unknown handle must be a hard error"
);
}
}
Expand Down
Loading
Loading