Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 107 additions & 1 deletion packages/rs-platform-wallet-ffi/src/shielded_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}"),
),
}
}
Comment on lines +184 to +213

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Estimator pins PlatformVersion::latest() instead of the SDK's active version

platform_wallet_shielded_estimate_fee resolves compute_*_shielded_fee against PlatformVersion::latest() and the doc comment claims this matches what the builders pin via sdk.version() — so the estimate "can't drift from the fee the builder carves". That equivalence only holds when the host's compiled-in latest() matches the network's active protocol version. Mid-upgrade or on a devnet pinned to an older version the displayed estimate can diverge from the consensus-pinned fee actually carved. Either accept a protocol_version parameter (so Swift can pass sdk.version()), or soften the docstring to acknowledge the drift window. The UI consequence is bounded (a labeled ~estimate plus a placeholder fallback), so this is a hardening suggestion, not a blocker.

source: ['claude']


/// Encode an optional host-supplied memo string into the on-chain
/// 36-byte `DashMemo` layout via [`ShieldedMemo`].
///
Expand Down Expand Up @@ -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)"
);
}
}
Comment on lines +1066 to +1097

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Hardcoded ground-truth fees will need rebaselining on each fee-version bump

estimate_fee_matches_observed_onchain_values_for_2_actions pins kind 0/1/2 at 2 actions to exact credit values (162_851_200 / 168_934_000 / 275_191_200). Because the FFI itself uses PlatformVersion::latest(), any future protocol version that adjusts the shielded fee constants will break this test even though the FFI plumbing is still correct. Either source the expected values from compute_minimum_shielded_fee(2, PlatformVersion::latest()) (validates plumbing, not constants), or add a comment that this test is intentionally version-pinned for drift detection so future maintainers know to rebaseline rather than tear out.

source: ['claude']


#[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
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +444 to +456

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Negative numActions traps before throws path fires

estimateShieldedFee is a public throws API but accepts numActions: Int and feeds it straight into UInt(numActions). UInt.init(Int) traps on negative values, so an accidental caller passing a negative number crashes the host app at the language boundary instead of surfacing an invalidParameter error through the documented throwing channel. The Rust side already returns a clean error on misuse, but Swift never gets there. Guard before the conversion (or change the parameter to UInt) so invalid input is reported through the declared error path.

Suggested change
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
}
public static func estimateShieldedFee(
kind: ShieldedFeeKind,
numActions: Int = 2
) throws -> UInt64 {
guard numActions >= 0 else {
throw PlatformWalletError.invalidParameter("numActions must be non-negative")
}
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
}

source: ['codex', 'claude']


/// 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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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).
Expand All @@ -89,9 +108,14 @@ final class IdentityRegistrationController: ObservableObject {
/// off the same shape.
private var task: Task<Void, Never>?

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -113,7 +114,8 @@ final class RegistrationCoordinator: ObservableObject {
}
let controller = IdentityRegistrationController(
walletId: walletId,
identityIndex: identityIndex
identityIndex: identityIndex,
fundingKind: fundingKind
)
controllers[key] = controller
controller.enterPreparingKeys()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading