From ec7b3bbb080a7143473724bdae0fde03e6a0ba5b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 24 Aug 2026 16:34:42 +0200 Subject: [PATCH 1/5] fix(platform-wallet): estimate shielded fees at the network's active protocol version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit platform_wallet_shielded_estimate_fee pinned PlatformVersion::latest(), so once a client ships the protocol-14 fee rebalance its fee preview under-quotes any network still running protocol 13 (114.14M vs the 162.85M credits the consensus gate actually validates for a 2-action transfer) — while the shielded builders correctly carve fees at the manager's network-tracked sdk.version(). Take the manager handle and resolve the version through the manager's SDK, exactly like the builders do. An unknown handle is a hard ErrorInvalidHandle — a versionless fallback would silently mis-quote. Thread the handle through the Swift binding (estimateShieldedFee becomes an instance method), the JNI export, and the Kotlin wrapper (moved from the process-global ShieldedProver onto PlatformWalletManager, matching the other manager-handle entry points), and update both example apps' send/fund screens. Pin the estimator's formula table on both sides of the boundary: protocol 13 (162,851,200 / 168,934,000 / 275,191,200 credits at 2 actions) and protocol 14 (114,140,000 / 120,222,800 / 226,480,000). Raised by review on #4467; stacks on feat/shielded-fee-rebalance. Co-Authored-By: Claude Fable 5 --- .../example/ui/shielded/ShieldedFundScreen.kt | 14 +- .../ui/wallet/SendTransactionScreen.kt | 27 ++- .../dashsdk/ffi/FundingNative.kt | 13 +- .../dashsdk/funding/ShieldedProver.kt | 28 ++- .../dashsdk/wallet/PlatformWalletManager.kt | 22 +++ .../src/shielded_send.rs | 168 ++++++++++++------ packages/rs-unified-sdk-jni/src/funding.rs | 23 ++- .../PlatformWalletManagerShieldedSync.swift | 20 ++- .../Core/ViewModels/SendViewModel.swift | 34 ++-- .../Core/Views/SendTransactionView.swift | 27 +++ 10 files changed, 262 insertions(+), 114 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedFundScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedFundScreen.kt index bb7aeb6b18a..f776854287e 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedFundScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedFundScreen.kt @@ -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 @@ -77,10 +79,12 @@ fun ShieldedFundScreen(walletIdHex: String, navController: NavHostController) { runCatching { ShieldedProver.warmUp() } value = runCatching { ShieldedProver.isReady() }.getOrDefault(false) } - val feeEstimate by produceState(initialValue = null) { - value = runCatching { - ShieldedProver.estimateFee(ShieldedProver.FeeKind.TransferOrShield, 2) - }.getOrNull() + val feeEstimate by produceState(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 diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt index 6916f830feb..5d49247a6ad 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt @@ -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. */ @@ -288,25 +289,23 @@ fun SendTransactionScreen( } } } - val shieldedFeeEstimate by produceState(initialValue = null, flow) { - value = when (flow) { + val shieldedFeeEstimate by produceState(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 diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index d85f538d31d..b769c5158a2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -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 @@ -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) ────────────── diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt index 822aa67fbd4..b0c1fba1304 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt @@ -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), @@ -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) } - } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 07d143c1c57..a479db6c1dc 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -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 @@ -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 diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 0926f6eff14..df8e53f621d 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -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, +> { + 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. /// @@ -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 { 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() @@ -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") + } + + /// 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" ); } } diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index f8dc82f050a..f53384c8277 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -7,15 +7,18 @@ //! //! ## What lives here (and what deliberately doesn't) //! -//! These three entry points are process-global, take no wallet handle, -//! and back the shielded funding screens' prover-status indicator and -//! fee preview: +//! Three support entry points back the shielded funding screens' +//! prover-status indicator and fee preview: //! - [`platform_wallet_shielded_warm_up_prover`] — kick the ~30s Halo 2 -//! proving-key build onto a background thread. +//! proving-key build onto a background thread (process-global, no +//! handle). //! - [`platform_wallet_shielded_prover_is_ready`] — poll whether that -//! build has finished (UI "preparing prover…" affordance). +//! build has finished (UI "preparing prover…" affordance; also +//! process-global). //! - [`platform_wallet_shielded_estimate_fee`] — the flat shielded fee in -//! credits for a transition of a given kind + action count. +//! credits for a transition of a given kind + action count, computed at +//! the manager's network-tracked platform version (so it takes the +//! manager `Handle`, unlike the two prover probes). //! //! The heavy shielded funding transitions themselves — the shielded //! fund-from-asset-lock (+ its resume-by-outpoint variant) and the @@ -87,12 +90,15 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_proverI /// The flat shielded fee in credits for a transition of the given `kind` /// (`0` = ShieldedTransfer/Shield, `1` = Unshield, `2` = ShieldedWithdrawal) /// and Orchard action `count` (a single-note spend with change is 2 -/// actions). Pure computation — no wallet handle, no network. Throws on an -/// unknown kind or a fee-formula overflow. +/// actions), computed at `managerHandle`'s network-tracked platform +/// version — the same version the shielded builders carve fees with. No +/// network round-trip. Throws on an unknown kind, an invalid manager +/// handle, or a fee-formula overflow. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_estimateShieldedFee( mut env: JNIEnv, _class: JClass, + manager_handle: jlong, kind: jint, num_actions: jint, ) -> jlong { @@ -104,6 +110,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_estimat let mut out_fee: u64 = 0; let result = unsafe { platform_wallet_ffi::platform_wallet_shielded_estimate_fee( + manager_handle as Handle, kind as u8, num_actions.max(0) as usize, &mut out_fee as *mut u64, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 1b94323e60e..deb9918461f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -465,18 +465,26 @@ extension PlatformWalletManager { } /// Consensus-pinned flat shielded fee (in credits) for a pool-paid - /// shielded transition with `numActions` Orchard actions. Pure - /// computation on the Rust side (no handle, no network) against - /// `PlatformVersion::latest()` — the same version the builders pin — - /// so the estimate can't drift from the carved fee. A single-note - /// spend with change is `numActions: 2`. - public static func estimateShieldedFee( + /// shielded transition with `numActions` Orchard actions, computed at + /// this manager's network-tracked platform version (`sdk.version()`) — + /// the same version the shielded builders carve fees with — so the + /// estimate can't drift from the carved fee even when the connected + /// network hasn't activated the client's latest protocol version yet. + /// No network round-trip; just the handle → version lookup and a pure + /// computation. A single-note spend with change is `numActions: 2`. + public func estimateShieldedFee( kind: ShieldedFeeKind, numActions: Int = 2 ) throws -> UInt64 { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } var fee: UInt64 = 0 // `num_actions` is `usize` on the Rust side → imported as `UInt`. try platform_wallet_shielded_estimate_fee( + handle, kind.rawValue, UInt(numActions), &fee diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 872e7bcb94c..feb342c6d91 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -136,6 +136,15 @@ class SendViewModel: ObservableObject { /// core/shielded flows are unaffected. @Published var platformMinOutputAmount: UInt64? + /// Consensus-pinned shielded fee estimates (credits, 2 actions) per fee + /// kind, resolved through the wallet manager's network-tracked platform + /// version and pushed in by the VIEW + /// (`SendTransactionView.resolveShieldedFees()`) on appear — the view + /// model has no wallet handle of its own, same as + /// `platformMinOutputAmount` above. A missing entry falls back to the + /// static `SendFlow.estimatedFee` placeholder in `estimateFee(for:)`. + @Published var shieldedFeeEstimates: [PlatformWalletManager.ShieldedFeeKind: UInt64] = [:] + private let network: Network init(network: Network) { @@ -437,16 +446,20 @@ class SendViewModel: ObservableObject { /// Resolve the estimated fee (in the flow's settlement unit) for the /// active flow. The shielded flows are consensus-pinned and computed - /// in Rust (`compute_*_shielded_fee` via the FFI estimator), so this - /// bridges to that rather than re-deriving the constants in Swift. + /// in Rust (`compute_*_shielded_fee` via the FFI estimator, at the + /// manager's network-tracked platform version), so this reads the + /// view-pushed `shieldedFeeEstimates` rather than re-deriving the + /// constants in Swift. /// - /// `numActions: 2` — the exact action count isn't known until the - /// builder selects notes; a single-note spend with change (the common - /// case) serializes to 2 Orchard actions. The transparent `Shield` - /// (`platformToShielded`) reserves the same `compute_minimum_shielded_fee(2)` - /// base as its structure-check minimum, so it shares the transfer kind. - /// On an FFI error we fall back to the static enum placeholder rather - /// than surfacing a fee of nil for a flow we can otherwise send. + /// The estimates are for 2 Orchard actions — the exact action count + /// isn't known until the builder selects notes; a single-note spend + /// with change (the common case) serializes to 2 actions. The + /// transparent `Shield` (`platformToShielded`) reserves the same + /// `compute_minimum_shielded_fee(2)` base as its structure-check + /// minimum, so it shares the transfer kind. When the view hasn't + /// resolved a fee (or the FFI errored) we fall back to the static enum + /// placeholder rather than surfacing a fee of nil for a flow we can + /// otherwise send. private func estimateFee(for flow: SendFlow) -> UInt64 { let kind: PlatformWalletManager.ShieldedFeeKind? switch flow { @@ -464,8 +477,7 @@ class SendViewModel: ObservableObject { kind = nil } guard let kind else { return flow.estimatedFee } - return (try? PlatformWalletManager.estimateShieldedFee(kind: kind, numActions: 2)) - ?? flow.estimatedFee + return shieldedFeeEstimates[kind] ?? flow.estimatedFee } // MARK: - Send Execution diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index f068a062b69..444c9b6d3e1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -346,6 +346,11 @@ struct SendTransactionView: View { // `canSend` can reject a sub-`min_output_amount` platform // transfer up front instead of after submit. resolvePlatformLimits() + // Same push pattern for the consensus-pinned shielded fees: + // the estimator needs the manager handle (it resolves the + // network-tracked platform version), which the view model + // deliberately doesn't hold. + resolveShieldedFees() } .onChange(of: viewModel.detectedAddressType) { _, _ in autoSelectSource() @@ -515,6 +520,28 @@ struct SendTransactionView: View { } } + /// Resolve the consensus-pinned shielded fee estimates (2 Orchard + /// actions — single-note spend with change) once on appear and push + /// them into the view model, mirroring `resolvePlatformLimits()`. The + /// estimator computes at the manager's network-tracked platform + /// version, so a network still on an older protocol version quotes the + /// fee its consensus gate actually validates. A kind that fails to + /// resolve is simply absent — `estimateFee(for:)` falls back to the + /// static placeholder. + private func resolveShieldedFees() { + guard viewModel.shieldedFeeEstimates.isEmpty else { return } + var fees: [PlatformWalletManager.ShieldedFeeKind: UInt64] = [:] + for kind: PlatformWalletManager.ShieldedFeeKind in [.transfer, .unshield, .withdrawal] { + fees[kind] = try? walletManager.estimateShieldedFee(kind: kind, numActions: 2) + } + viewModel.shieldedFeeEstimates = fees + // A flow detected before the push (e.g. a prefilled recipient) + // computed its fee from the placeholder — recompute it. + if viewModel.detectedFlow != nil { + viewModel.updateFlow() + } + } + /// Choose which key-class-0 Platform Payment account funds a /// platform → platform transfer, returning `nil` when no single /// account can cover the requested amount + fee. From 92f2505d9611d142257797644dcd68736f5dab6c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 24 Aug 2026 16:49:57 +0200 Subject: [PATCH 2/5] fix(swift-sdk): reject a negative numActions in estimateShieldedFee instead of trapping UInt(numActions) traps at runtime on a negative Int; throw PlatformWalletError.invalidParameter at the boundary instead, matching the JNI bridge's sign check. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletManagerShieldedSync.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index deb9918461f..d3fefcb85b4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -481,8 +481,14 @@ extension PlatformWalletManager { "PlatformWalletManager not configured" ) } + // `num_actions` is `usize` on the Rust side → imported as `UInt`, + // whose checked initializer traps on a negative Int. + guard numActions >= 0 else { + throw PlatformWalletError.invalidParameter( + "numActions must be non-negative, got \(numActions)" + ) + } var fee: UInt64 = 0 - // `num_actions` is `usize` on the Rust side → imported as `UInt`. try platform_wallet_shielded_estimate_fee( handle, kind.rawValue, From d9c62e2c386ad57e415af4ea949ef83b1c264f0b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 24 Aug 2026 18:22:06 +0200 Subject: [PATCH 3/5] test(platform-wallet-ffi): pin the estimator's manager-handle version resolution end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercise platform_wallet_shielded_estimate_fee through live mock-SDK managers pinned to protocol 13 and protocol 14, asserting the two rebalance-boundary transfer fees through the same exported entry point — a latest() regression now fails the protocol-13 half. Co-Authored-By: Claude Fable 5 --- .../src/shielded_send.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index df8e53f621d..4c1f03b8442 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1722,6 +1722,85 @@ mod tests { ); } + /// The heart of the fix, exercised end-to-end through the exported + /// estimator: the version is resolved from the manager handle's + /// network-tracked `sdk.version()`, so managers pinned to protocol 13 + /// and protocol 14 quote different fees through the SAME entry point. + /// Reverting the lookup to `PlatformVersion::latest()` fails the + /// protocol-13 half of this test. + #[test] + fn estimate_fee_resolves_version_through_manager_handle() { + unsafe extern "C" fn begin_changeset( + _context: *mut std::os::raw::c_void, + _wallet_id: *const u8, + ) -> i32 { + 0 + } + unsafe extern "C" fn end_changeset( + _context: *mut std::os::raw::c_void, + _wallet_id: *const u8, + _success: bool, + ) -> i32 { + 0 + } + + for (protocol_version, expected_transfer_fee) in + [(13u32, 162_851_200u64), (14, 114_140_000)] + { + let version = dpp::version::PlatformVersion::get(protocol_version) + .expect("protocol version must exist"); + let sdk = dash_sdk::SdkBuilder::new_mock() + .with_version(version) + .build() + .expect("mock sdk"); + + let persistence = crate::persistence::PersistenceCallbacks { + on_changeset_begin_fn: Some(begin_changeset), + on_changeset_end_fn: Some(end_changeset), + ..Default::default() + }; + let events = crate::event_handler::EventHandlerCallbacks { + context: std::ptr::null_mut(), + on_wallet_event_fn: None, + on_error_fn: None, + on_platform_address_sync_completed_fn: None, + on_shielded_sync_completed_fn: None, + on_shielded_sync_progress_fn: None, + on_shielded_tree_progress_fn: None, + release_fn: None, + }; + let mut handle: Handle = 0; + let create = unsafe { + crate::manager::platform_wallet_manager_create( + &sdk as *const dash_sdk::Sdk as *const std::os::raw::c_void, + &persistence, + &events, + &mut handle, + ) + }; + assert_eq!( + create.code, + PlatformWalletFFIResultCode::Success, + "manager create must succeed at protocol {protocol_version}" + ); + + let mut fee: u64 = 0; + let result = unsafe { platform_wallet_shielded_estimate_fee(handle, 0, 2, &mut fee) }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::Success, + "estimate must succeed at protocol {protocol_version}" + ); + assert_eq!( + fee, expected_transfer_fee, + "2-action transfer fee quoted through a protocol-{protocol_version} manager" + ); + + let destroy = unsafe { crate::manager::platform_wallet_manager_destroy(handle) }; + assert_eq!(destroy.code, PlatformWalletFFIResultCode::Success); + } + } + #[test] fn estimate_fee_rejects_unknown_kind() { unsafe { From 4e7df3ee6f60e019bd2a5355339f110a35004b4a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 25 Aug 2026 09:13:44 +0200 Subject: [PATCH 4/5] fix(example-apps): re-resolve shielded fee estimates after the protocol-version ratchet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK learns the network's protocol version on a background refresh after the send screens can already appear, so fee previews resolved before the ratchet completed were computed at the seed version and cached. Swift re-runs resolveShieldedFees() (guard dropped — the recompute is a pure handle lookup) when AppState publishes platformProtocolVersion; the Kotlin screens key their fee producers on the same published version. Co-Authored-By: Claude Fable 5 --- .../example/ui/shielded/ShieldedFundScreen.kt | 10 ++++++- .../ui/wallet/SendTransactionScreen.kt | 8 ++++- .../Core/ViewModels/SendViewModel.swift | 3 +- .../Core/Views/SendTransactionView.swift | 29 ++++++++++++++----- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedFundScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedFundScreen.kt index f776854287e..aaffb1013ff 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedFundScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedFundScreen.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import org.dashfoundation.dashsdk.funding.ShieldedProver import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState import org.dashfoundation.example.navigation.ShieldedFundProgress import org.dashfoundation.example.services.shielded.ShieldedFundFromAssetLockCoordinator.StartFundingResult import org.dashfoundation.example.ui.components.ErrorAlertDialog @@ -66,6 +67,7 @@ import org.dashfoundation.example.util.toHex fun ShieldedFundScreen(walletIdHex: String, navController: NavHostController) { ShieldedGate(navController) { val container = LocalAppContainer.current + val appState = LocalAppState.current val walletId = remember(walletIdHex) { walletIdHex.hexToBytes() } val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() @@ -79,7 +81,13 @@ fun ShieldedFundScreen(walletIdHex: String, navController: NavHostController) { runCatching { ShieldedProver.warmUp() } value = runCatching { ShieldedProver.isReady() }.getOrDefault(false) } - val feeEstimate by produceState(initialValue = null, manager) { + // Re-keyed on the published protocol version: the SDK learns the + // network's version on a background refresh after the manager + // exists, so an estimate produced before the ratchet completed was + // computed at the seed version (← iOS SendTransactionView + // re-resolves on `platformState.platformProtocolVersion`). + val protocolVersion by appState.platformProtocolVersion.collectAsStateWithLifecycle() + val feeEstimate by produceState(initialValue = null, manager, protocolVersion) { value = manager?.let { m -> runCatching { m.estimateShieldedFee(ShieldedProver.FeeKind.TransferOrShield, 2) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt index 5d49247a6ad..4c1cb2ddb1f 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt @@ -289,7 +289,13 @@ fun SendTransactionScreen( } } } - val shieldedFeeEstimate by produceState(initialValue = null, flow, manager) { + // Re-key on the published protocol version too: the SDK learns the + // network's version on a background refresh after the manager exists, + // so an estimate produced before the ratchet completed was computed at + // the seed version (← iOS SendTransactionView re-resolves on + // `platformState.platformProtocolVersion` the same way). + val protocolVersion by appState.platformProtocolVersion.collectAsStateWithLifecycle() + val shieldedFeeEstimate by produceState(initialValue = null, flow, manager, protocolVersion) { val activeManager = manager val kind = when (flow) { // Type 15 Shield reserves the same compute_minimum_shielded_fee(2) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index feb342c6d91..20b914021bb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -139,7 +139,8 @@ class SendViewModel: ObservableObject { /// Consensus-pinned shielded fee estimates (credits, 2 actions) per fee /// kind, resolved through the wallet manager's network-tracked platform /// version and pushed in by the VIEW - /// (`SendTransactionView.resolveShieldedFees()`) on appear — the view + /// (`SendTransactionView.resolveShieldedFees()`) on appear and again + /// when the async protocol-version refresh publishes — the view /// model has no wallet handle of its own, same as /// `platformMinOutputAmount` above. A missing entry falls back to the /// static `SendFlow.estimatedFee` placeholder in `estimateFee(for:)`. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 444c9b6d3e1..5e2b10bc685 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -355,6 +355,14 @@ struct SendTransactionView: View { .onChange(of: viewModel.detectedAddressType) { _, _ in autoSelectSource() } + .onChange(of: platformState.platformProtocolVersion) { _, _ in + // The SDK learns the network's protocol version on a + // detached task after `AppState` publishes it; estimates + // resolved before that ratchet completed were computed at + // the seed version. Re-resolve so the preview matches the + // version the builders will read at submission time. + resolveShieldedFees() + } .sheet(isPresented: $showQRScanner) { // Same network the view model was built with // (`wallet.network ?? .testnet`) so the scanner validates @@ -521,15 +529,20 @@ struct SendTransactionView: View { } /// Resolve the consensus-pinned shielded fee estimates (2 Orchard - /// actions — single-note spend with change) once on appear and push - /// them into the view model, mirroring `resolvePlatformLimits()`. The - /// estimator computes at the manager's network-tracked platform - /// version, so a network still on an older protocol version quotes the - /// fee its consensus gate actually validates. A kind that fails to - /// resolve is simply absent — `estimateFee(for:)` falls back to the - /// static placeholder. + /// actions — single-note spend with change) and push them into the + /// view model, mirroring `resolvePlatformLimits()`. The estimator + /// computes at the manager's network-tracked platform version, so a + /// network still on an older protocol version quotes the fee its + /// consensus gate actually validates. A kind that fails to resolve is + /// simply absent — `estimateFee(for:)` falls back to the static + /// placeholder. + /// + /// Runs on appear AND whenever `platformState.platformProtocolVersion` + /// publishes: the version refresh is async, so estimates resolved + /// before the ratchet completed were computed at the seed version and + /// must be replaced (the recompute is a pure handle lookup — no + /// caching guard needed). private func resolveShieldedFees() { - guard viewModel.shieldedFeeEstimates.isEmpty else { return } var fees: [PlatformWalletManager.ShieldedFeeKind: UInt64] = [:] for kind: PlatformWalletManager.ShieldedFeeKind in [.transfer, .unshield, .withdrawal] { fees[kind] = try? walletManager.estimateShieldedFee(kind: kind, numActions: 2) From 6e95fe7c144102d1a42f16a195767b512b6c715b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 25 Aug 2026 10:09:42 +0200 Subject: [PATCH 5/5] fix(example-apps): re-read the shielded fee estimate at the SDK's live version The published AppState.platformProtocolVersion is not a complete signal for "the estimator's version changed": refresh_protocol_version() swallows a failed proven fetch and returns the unchanged seed, and the SDK ratchets its shared AtomicU32 from any proof-verified query's response metadata without publishing at all. Keying estimation solely on that value let a preview keep a seed-version fee while the submission builders read the newer sdk.version(). The estimator FFI already resolves manager.sdk().version() on every call with no round-trip, so no new export is needed - the apps just have to re-read at the points where the value matters. Swift re-resolves in the Send action, so the fee the coverage math and the summary use is the one taken closest to submission; the Kotlin screens re-key their fee producers on a resume epoch, covering both a transient startup failure and an activation that lands while the screen stays open. Co-Authored-By: Claude Fable 5 --- .../example/ui/shielded/ShieldedFundScreen.kt | 20 ++++++++++++++++- .../ui/wallet/SendTransactionScreen.kt | 20 ++++++++++++++++- .../Core/ViewModels/SendViewModel.swift | 6 ++--- .../Core/Views/SendTransactionView.swift | 22 ++++++++++++++----- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedFundScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedFundScreen.kt index aaffb1013ff..b8e8799b8d0 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedFundScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedFundScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -30,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import org.dashfoundation.dashsdk.funding.ShieldedProver @@ -87,7 +89,23 @@ fun ShieldedFundScreen(walletIdHex: String, navController: NavHostController) { // computed at the seed version (← iOS SendTransactionView // re-resolves on `platformState.platformProtocolVersion`). val protocolVersion by appState.platformProtocolVersion.collectAsStateWithLifecycle() - val feeEstimate by produceState(initialValue = null, manager, protocolVersion) { + // ...and re-read on every resume, because the flow alone can go + // stale: that refresh republishes the unchanged seed when its + // proven fetch fails, and the SDK independently ratchets its + // version from ANY proof-verified query's response metadata + // without publishing at all. The estimate is a pure handle + // lookup, so re-reading is free. + var feeReadEpoch by remember { mutableIntStateOf(0) } + LifecycleResumeEffect(Unit) { + feeReadEpoch++ + onPauseOrDispose {} + } + val feeEstimate by produceState( + initialValue = null, + manager, + protocolVersion, + feeReadEpoch, + ) { value = manager?.let { m -> runCatching { m.estimateShieldedFee(ShieldedProver.FeeKind.TransferOrShield, 2) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt index 4c1cb2ddb1f..5656d273640 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -42,6 +43,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import kotlinx.coroutines.delay @@ -295,7 +297,23 @@ fun SendTransactionScreen( // the seed version (← iOS SendTransactionView re-resolves on // `platformState.platformProtocolVersion` the same way). val protocolVersion by appState.platformProtocolVersion.collectAsStateWithLifecycle() - val shieldedFeeEstimate by produceState(initialValue = null, flow, manager, protocolVersion) { + // ...and re-read on every resume, because the flow alone can go stale: + // that refresh republishes the unchanged seed when its proven fetch + // fails, and the SDK independently ratchets its version from ANY + // proof-verified query's response metadata without publishing at all. + // The estimate is a pure handle lookup, so re-reading is free. + var feeReadEpoch by remember { mutableIntStateOf(0) } + LifecycleResumeEffect(Unit) { + feeReadEpoch++ + onPauseOrDispose {} + } + val shieldedFeeEstimate by produceState( + initialValue = null, + flow, + manager, + protocolVersion, + feeReadEpoch, + ) { val activeManager = manager val kind = when (flow) { // Type 15 Shield reserves the same compute_minimum_shielded_fee(2) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 20b914021bb..d86c0f13e05 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -139,9 +139,9 @@ class SendViewModel: ObservableObject { /// Consensus-pinned shielded fee estimates (credits, 2 actions) per fee /// kind, resolved through the wallet manager's network-tracked platform /// version and pushed in by the VIEW - /// (`SendTransactionView.resolveShieldedFees()`) on appear and again - /// when the async protocol-version refresh publishes — the view - /// model has no wallet handle of its own, same as + /// (`SendTransactionView.resolveShieldedFees()`) on appear, when the + /// async protocol-version refresh publishes, and again from the Send + /// action — the view model has no wallet handle of its own, same as /// `platformMinOutputAmount` above. A missing entry falls back to the /// static `SendFlow.estimatedFee` placeholder in `estimateFee(for:)`. @Published var shieldedFeeEstimates: [PlatformWalletManager.ShieldedFeeKind: UInt64] = [:] diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 5e2b10bc685..de48fdf33bb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -246,6 +246,15 @@ struct SendTransactionView: View { } ToolbarItem(placement: .navigationBarTrailing) { Button("Send") { + // The estimator reads the SDK's live platform + // version on every call, and the SDK can ratchet + // that version from ANY proof-verified query's + // response metadata without `AppState` publishing + // again — so a read taken here, immediately + // before `executeSend`, is the only one + // guaranteed to agree with the version the + // shielded builders carve their fee at. + resolveShieldedFees() Task { guard let sdk = platformState.sdk else { return } // Look up the managed wallet by the @@ -537,11 +546,14 @@ struct SendTransactionView: View { /// simply absent — `estimateFee(for:)` falls back to the static /// placeholder. /// - /// Runs on appear AND whenever `platformState.platformProtocolVersion` - /// publishes: the version refresh is async, so estimates resolved - /// before the ratchet completed were computed at the seed version and - /// must be replaced (the recompute is a pure handle lookup — no - /// caching guard needed). + /// Runs on appear, whenever `platformState.platformProtocolVersion` + /// publishes, and once more from the Send action: the startup refresh + /// is async AND can fail (it then republishes the unchanged seed), + /// while the SDK independently ratchets its version from any + /// proof-verified query's response metadata without publishing at + /// all. Only a read taken at submit time is guaranteed to match the + /// version the builders carve with (the recompute is a pure handle + /// lookup — no caching guard needed). private func resolveShieldedFees() { var fees: [PlatformWalletManager.ShieldedFeeKind: UInt64] = [:] for kind: PlatformWalletManager.ShieldedFeeKind in [.transfer, .unshield, .withdrawal] {