diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 419a7115955..503054c5333 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -44,7 +44,10 @@ use std::os::raw::c_char; use dashcore::hashes::Hash; use dpp::address_funds::{OrchardAddress, PlatformAddress}; -use dpp::shielded::ShieldedMemo; +use dpp::shielded::{ + compute_minimum_shielded_fee, compute_shielded_unshield_fee, compute_shielded_withdrawal_fee, + ShieldedMemo, +}; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use platform_wallet::wallet::asset_lock::AssetLockFunding; use platform_wallet::wallet::shielded::CachedOrchardProver; @@ -151,6 +154,64 @@ pub unsafe extern "C" fn platform_wallet_shielded_prover_is_ready() -> bool { CachedOrchardProver::new().is_ready() } +/// Estimate the consensus-pinned flat shielded fee (in credits) for a +/// pool-paid shielded transition. +/// +/// `kind` selects the transition's fee formula: +/// - `0` → ShieldedTransfer / Shield (`compute_minimum_shielded_fee` — the +/// base flat fee; Shield's structure check reserves the same base via +/// `compute_minimum_shielded_fee(2)`), +/// - `1` → Unshield (`compute_shielded_unshield_fee` — base + the flat +/// `AddBalanceToAddress` output-write cost), +/// - `2` → ShieldedWithdrawal (`compute_shielded_withdrawal_fee` — base + +/// 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. +/// +/// 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`. +/// +/// # Safety +/// `out_fee` must point to 8 writable bytes (a `u64`). +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_shielded_estimate_fee( + 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)"), + ); + } + }; + match fee { + Ok(credits) => { + *out_fee = credits; + PlatformWalletFFIResult::ok() + } + Err(e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorArithmeticOverflow, + format!("shielded fee estimation failed: {e}"), + ), + } +} + /// Encode an optional host-supplied memo string into the on-chain /// 36-byte `DashMemo` layout via [`ShieldedMemo`]. /// @@ -1001,4 +1062,49 @@ mod tests { "over-length memo must surface as an invalid-parameter error" ); } + + /// 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. + #[test] + fn estimate_fee_matches_observed_onchain_values_for_2_actions() { + 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), + 162_851_200, + "shielded transfer fee (2 actions)" + ); + // kind 1 — Unshield. + assert_eq!(estimate(1), 168_934_000, "unshield fee (2 actions)"); + // kind 2 — ShieldedWithdrawal. + assert_eq!( + estimate(2), + 275_191_200, + "shielded withdrawal fee (2 actions)" + ); + } + } + + #[test] + fn estimate_fee_rejects_unknown_kind() { + unsafe { + let mut fee: u64 = 0; + let result = platform_wallet_shielded_estimate_fee(7, 2, &mut fee); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + } + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 9394b5ffc90..8e9302b2e75 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -423,6 +423,38 @@ extension PlatformWalletManager { platform_wallet_shielded_prover_is_ready() } + /// Which consensus fee formula a pool-paid shielded transition is + /// charged under. Mirrors the `kind` byte the Rust FFI + /// `platform_wallet_shielded_estimate_fee` dispatches on. + public enum ShieldedFeeKind: UInt8 { + /// ShieldedTransfer / Shield base (`compute_minimum_shielded_fee`). + case transfer = 0 + /// Unshield (`compute_shielded_unshield_fee`). + case unshield = 1 + /// ShieldedWithdrawal (`compute_shielded_withdrawal_fee`). + case withdrawal = 2 + } + + /// 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( + kind: ShieldedFeeKind, + numActions: Int = 2 + ) throws -> UInt64 { + var fee: UInt64 = 0 + // `num_actions` is `usize` on the Rust side → imported as `UInt`. + try platform_wallet_shielded_estimate_fee( + kind.rawValue, + UInt(numActions), + &fee + ).check() + return fee + } + /// Shielded → Shielded transfer. Spends notes from `account` /// on `walletId` and creates a new note for `recipientRaw43` /// (the recipient's raw 43-byte Orchard payment address). diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 272cd13dfbf..86703a5517d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -33,6 +33,11 @@ enum SendFlow: Equatable { } } + /// Static fee estimate. Authoritative only for the non-shielded + /// flows (`coreToCore`, `platformToPlatform`); the shielded flows are + /// consensus-pinned and resolved through the Rust FFI estimator in + /// `SendViewModel.estimateFee(for:)`, so their values here are only + /// the FFI-unavailable fallback (the real fee is ~500x larger). var estimatedFee: UInt64 { switch self { case .coreToCore: return 500_000 // ~0.005 DASH @@ -214,7 +219,36 @@ class SendViewModel: ObservableObject { default: detectedFlow = nil } - estimatedFee = detectedFlow?.estimatedFee + estimatedFee = detectedFlow.map(estimateFee(for:)) + } + + /// 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. + /// + /// `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. + private func estimateFee(for flow: SendFlow) -> UInt64 { + let kind: PlatformWalletManager.ShieldedFeeKind? + switch flow { + case .shieldedToShielded, .platformToShielded: + kind = .transfer + case .shieldedToPlatform: + kind = .unshield + case .shieldedToCore: + kind = .withdrawal + case .coreToCore, .platformToPlatform: + kind = nil + } + guard let kind else { return flow.estimatedFee } + return (try? PlatformWalletManager.estimateShieldedFee(kind: kind, numActions: 2)) + ?? flow.estimatedFee } // MARK: - Send Execution diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift index 55456119bd2..cd457c89b8f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift @@ -12,12 +12,25 @@ import SwiftDashSDK /// runs on `@MainActor` so SwiftUI observers see consistent /// transitions. /// -/// The 5-step progress bar in `RegistrationProgressView` derives its -/// step from a combination of `phase` (Step 1, 4, 5) and the live -/// `PersistentAssetLock` row queried via `@Query` filtered by -/// `(walletId, identityIndex)` (Step 2/3, driven by `statusRaw`). +/// The progress bar in `RegistrationProgressView` derives its step +/// from `phase` plus `fundingKind`: asset-lock funding combines `phase` +/// with the live `PersistentAssetLock` row (queried via `@Query` on +/// `(walletId, identityIndex)`, driven by `statusRaw`); shielded-pool +/// funding has no asset lock and is driven by `phase` + elapsed time +/// since `lastSubmittedAt` (the Halo 2 proof is the long pole). @MainActor final class IdentityRegistrationController: ObservableObject { + /// How this registration is funded. Drives which step set the + /// progress view renders: the asset-lock funding paths (Core / + /// Platform-Payment / resume) emit a `PersistentAssetLock` row and + /// walk the build → IS/CL → register steps; the shielded-pool path + /// (Type-20 IdentityCreateFromShieldedPool) has no asset lock — its + /// long pole is the Halo 2 proof — so it needs its own step set. + enum FundingKind: Equatable { + case assetLock + case shieldedPool + } + enum Phase: Equatable { /// Pre-submit. The controller exists but `submit` hasn't /// fired yet. Not surfaced by `RegistrationProgressView` @@ -77,6 +90,12 @@ final class IdentityRegistrationController: ObservableObject { let walletId: Data let identityIndex: UInt32 + /// Funding source for this registration. Read by + /// `RegistrationProgressSection` to pick the asset-lock vs shielded + /// step set. Defaults to `.assetLock` so existing call sites are + /// unchanged. + let fundingKind: FundingKind + /// Timestamp of the most recent `submit` call. Used by the /// coordinator's TTL-based retention policy (`.completed` rows /// purge ~30s after the success transition). @@ -89,9 +108,14 @@ final class IdentityRegistrationController: ObservableObject { /// off the same shape. private var task: Task? - init(walletId: Data, identityIndex: UInt32) { + init( + walletId: Data, + identityIndex: UInt32, + fundingKind: FundingKind = .assetLock + ) { self.walletId = walletId self.identityIndex = identityIndex + self.fundingKind = fundingKind } /// Transition to `.preparingKeys`. Called by the caller before diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift index 339d367ba51..ec1cff65649 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift @@ -90,6 +90,7 @@ final class RegistrationCoordinator: ObservableObject { func startRegistration( walletId: Data, identityIndex: UInt32, + fundingKind: IdentityRegistrationController.FundingKind = .assetLock, body: @escaping () async throws -> Data ) -> IdentityRegistrationController { let key = SlotKey(walletId: walletId, identityIndex: identityIndex) @@ -113,7 +114,8 @@ final class RegistrationCoordinator: ObservableObject { } let controller = IdentityRegistrationController( walletId: walletId, - identityIndex: identityIndex + identityIndex: identityIndex, + fundingKind: fundingKind ) controllers[key] = controller controller.enterPreparingKeys() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 98176d9cc1e..aec3cd8de9a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -1197,6 +1197,7 @@ struct CreateIdentityView: View { let controller = coordinator.startRegistration( walletId: walletId, identityIndex: identityIndex, + fundingKind: .shieldedPool, body: { // `shieldedIdentityCreateFromPool` lives on the manager // (it's wallet-id-routed, unlike the per-wallet diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift index 7e43dd40b3b..ec1f1a2750f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift @@ -2,12 +2,15 @@ import SwiftUI import SwiftData import SwiftDashSDK -/// Embeddable 5-step progress section. Use inside any parent -/// `Form` so the progress UI doesn't nest a second `Form`, which -/// SwiftUI doesn't render cleanly. For the standalone navigation -/// destination see `RegistrationProgressView` below. +/// Embeddable progress section. Use inside any parent `Form` so the +/// progress UI doesn't nest a second `Form`, which SwiftUI doesn't +/// render cleanly. For the standalone navigation destination see +/// `RegistrationProgressView` below. /// -/// Step mapping: +/// Renders one of two step sets keyed off `controller.fundingKind`: +/// +/// Asset-lock funding (5 steps, `statusRaw`-driven from the live +/// `PersistentAssetLock` row): /// /// 1. Building asset-lock tx → activeLock `statusRaw == 0` /// 2. Broadcasting → activeLock `statusRaw == 1` and @@ -25,6 +28,13 @@ import SwiftDashSDK /// 5. Registering identity → activeLock `statusRaw == 2 or 3` /// AND controller still `.inFlight` /// +/// Shielded-pool funding (4 steps, phase + elapsed-time driven — there +/// is no asset lock and no per-stage signal from Rust during the opaque +/// FFI call; see `shieldedCurrentStep`): +/// +/// 1. Selecting shielded notes 2. Generating Halo 2 proof +/// 3. Broadcasting transition 4. Registering identity +/// /// `.completed` is the *terminal* state and is not a separate step; /// `RegistrationProgressView` renders the "Identity created" banner /// + "View Identity" navigation below this section in its own @@ -67,25 +77,38 @@ struct RegistrationProgressSection: View { ) } + /// Number of visual steps for the active funding source. The + /// asset-lock path has 5 (build → broadcast → IS → CL → register); + /// the shielded-pool path has 4 (select notes → Halo 2 proof → + /// broadcast → register). + private var stepCount: Int { + switch controller.fundingKind { + case .assetLock: return 5 + case .shieldedPool: return 4 + } + } + var body: some View { // `TimelineView` re-fires the body every 1 s so the // elapsed-time heuristic that distinguishes step 2 / 3 / 4 - // refreshes without an external timer. The lock row's - // `updatedAt` is the anchor. + // (asset-lock) or step 1 / 2 (shielded) refreshes without an + // external timer. The asset-lock anchor is the lock row's + // `updatedAt`; the shielded anchor is `controller.lastSubmittedAt`. TimelineView(.periodic(from: .now, by: 1.0)) { timeline in let now = timeline.date + let count = stepCount let step = currentStep(now: now) let isFailed = isFailed let errorMessage = failureMessage Section { - ForEach(1...5, id: \.self) { idx in + ForEach(1...count, id: \.self) { idx in stepRow( index: idx, title: stepTitle(idx), state: stepState(idx, currentStep: step, isFailed: isFailed) ) - if idx == 5, let message = errorMessage { + if idx == count, let message = errorMessage { Text(message) .font(.caption) .foregroundColor(.red) @@ -112,6 +135,9 @@ struct RegistrationProgressSection: View { /// Broadcasting → Waiting-IS → Waiting-CL transition within /// `statusRaw == 1`. private func currentStep(now: Date) -> Int { + if controller.fundingKind == .shieldedPool { + return shieldedCurrentStep(now: now) + } switch controller.phase { case .idle, .preparingKeys: return 1 @@ -178,6 +204,47 @@ struct RegistrationProgressSection: View { return 4 } + /// Visually-brief window (seconds since `lastSubmittedAt`) for the + /// shielded "Selecting notes" step before the long Halo 2 proof + /// step takes over. Note selection is sub-second on the Rust side; + /// this just lets the user see step 1 register before step 2 spins. + private static let shieldedNoteSelectionWindow: TimeInterval = 2.0 + + /// Step 1...4 for the shielded-pool funding path. There is NO + /// per-stage signal from Rust during the opaque + /// `platform_wallet_manager_shielded_identity_create_from_pool` + /// call (note-select → Halo 2 proof → broadcast → confirm all run + /// inside one blocking FFI call), so transitions are driven from + /// `controller.phase` plus elapsed time since `lastSubmittedAt`: + /// + /// 1. Selecting shielded notes → `.idle` / `.preparingKeys`, or + /// the first `shieldedNoteSelectionWindow` seconds of `.inFlight`. + /// 2. Generating Halo 2 proof → `.inFlight` after that window. + /// Kept active for the rest of the call: broadcast + confirm + /// (steps 3/4) can't be observed separately, so they stay + /// `.pending` rather than flipping to a green check for work + /// that may not have happened yet. + /// On `.completed` return 5 (one past the last step) so all rows + /// render `.done`. On `.failed` mark the step we'd reached. + private func shieldedCurrentStep(now: Date) -> Int { + switch controller.phase { + case .idle, .preparingKeys: + return 1 + case .completed: + return 5 + case .inFlight: + guard let submittedAt = controller.lastSubmittedAt else { return 1 } + let elapsed = now.timeIntervalSince(submittedAt) + return elapsed < Self.shieldedNoteSelectionWindow ? 1 : 2 + case .failed: + // Fail on the proof step unless we never left note + // selection (no submit timestamp yet). + guard let submittedAt = controller.lastSubmittedAt else { return 1 } + let elapsed = now.timeIntervalSince(submittedAt) + return elapsed < Self.shieldedNoteSelectionWindow ? 1 : 2 + } + } + /// True when step 4 should appear "skipped" rather than /// "active" — i.e. the lock came back InstantSend-locked /// (statusRaw == 2) so the CL fallback was never needed. Drives @@ -231,6 +298,15 @@ struct RegistrationProgressSection: View { } private func stepTitle(_ idx: Int) -> String { + if controller.fundingKind == .shieldedPool { + switch idx { + case 1: return "Selecting shielded notes" + case 2: return "Generating Halo 2 proof" + case 3: return "Broadcasting transition" + case 4: return "Registering identity" + default: return "" + } + } switch idx { case 1: return "Building asset-lock transaction" case 2: return "Broadcasting" @@ -262,12 +338,15 @@ struct RegistrationProgressSection: View { // symmetric carve-out keeps the icons honest — without // it, the CL-success path renders a green "InstantSend // proof received ✅" check even though no IS proof was - // ever observed. - if idx == 3 && step3WasSkipped { - return .skipped - } - if idx == 4 && step4WasSkipped { - return .skipped + // ever observed. Asset-lock only — the shielded path's + // steps 3/4 (broadcast / register) have no IS/CL duality. + if controller.fundingKind == .assetLock { + if idx == 3 && step3WasSkipped { + return .skipped + } + if idx == 4 && step4WasSkipped { + return .skipped + } } return .done } @@ -341,6 +420,15 @@ struct RegistrationProgressSection: View { if isFailed { return "Tap Dismiss in Pending Registrations to clear this entry." } + if controller.fundingKind == .shieldedPool { + switch step { + case 1: return "Selecting shielded notes to spend from the pool." + case 2: return "Generating the Halo 2 proof — this can take ~1–2 minutes." + case 3: return "Broadcasting the IdentityCreateFromShieldedPool transition." + case 4: return "Registering the proof-verified identity on Platform." + default: return "" + } + } switch step { case 1: return "Building a Core asset-lock transaction from wallet funds." case 2: return "Sending the asset-lock transaction to peers." @@ -355,7 +443,8 @@ struct RegistrationProgressSection: View { /// Standalone navigation destination for a registration in flight, /// completed, or failed. Pushed from `CreateIdentityView` on submit /// and from the "Pending Registrations" row on the identities tab. -/// Renders the 7-step progress, plus the terminal section on +/// Renders the funding-source-specific progress steps (see +/// `RegistrationProgressSection`), plus the terminal section on /// `.completed` (success banner + "View Identity" navigation) or /// `.failed` (inline error). Embedders that already render a /// `Form` should use `RegistrationProgressSection` directly.