diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs
index 5c520a38575..c4e93f57060 100644
--- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs
+++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs
@@ -339,6 +339,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw(
/// denomination + full key set) + a per-key proof-of-possession produced via
/// `signer_identity_handle`. There is NO platform identity signature.
///
+/// `identity_index` is the DIP-9 identity-registration slot the new identity occupies. On a
+/// successful broadcast the wallet registers the proof-verified identity at this slot in its local
+/// `IdentityManager` (mirroring address-funded registration), which drives the host persister's
+/// identity-row emit. It carries no decision here — it is marshalled straight through to the wallet.
+///
/// On success the 32-byte new identity id (`double_sha256(sorted nullifiers)`) is written to
/// `out_identity_id`. The id is deterministic in the spent notes, so the host can also predict it
/// independently if needed.
@@ -367,6 +372,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p
handle: Handle,
wallet_id_bytes: *const u8,
account: u32,
+ identity_index: u32,
identity_pubkeys: *const IdentityPubkeyFFI,
identity_pubkeys_count: usize,
denomination: u64,
@@ -444,6 +450,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p
.shielded_identity_create_from_pool(
&coordinator,
account,
+ identity_index,
public_keys,
denomination,
send_to_address_on_creation_failure,
diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs
index f99aa8e4bac..493125d0af1 100644
--- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs
+++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs
@@ -711,13 +711,21 @@ impl PlatformWallet {
/// `public_keys` is the new identity's key set (each entry pairs the `IdentityPublicKey` with
/// its `IdentityPublicKeyInCreation` form); `identity_signer` produces each key's
/// proof-of-possession signature. The Orchard spend authority comes from the wallet's own
- /// `OrchardKeySet` (the ASK never crosses to the coordinator). Returns the new identity's id.
+ /// `OrchardKeySet` (the ASK never crosses to the coordinator).
+ ///
+ /// `identity_index` is the DIP-9 identity-registration slot the new identity occupies in the
+ /// local `IdentityManager`; on a successful broadcast the proof-verified identity is registered
+ /// there (mirroring `register_from_addresses`) so the host persister emits the
+ /// `IdentityChangeSet` / `IdentityKeysChangeSet` that creates the app's identity row. A failed
+ /// registration after a successful broadcast is logged and swallowed — the identity already
+ /// exists on chain, so the next sync heals the local row. Returns the new identity's id.
#[cfg(feature = "shielded")]
#[allow(clippy::too_many_arguments)]
pub async fn shielded_identity_create_from_pool
(
&self,
coordinator: &Arc,
account: u32,
+ identity_index: u32,
public_keys: Vec<(
dpp::identity::IdentityPublicKey,
dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation,
@@ -731,29 +739,70 @@ impl PlatformWallet {
P: dpp::shielded::builder::OrchardProver,
IS: dpp::identity::signer::Signer + Send + Sync,
{
- let guard = self.shielded_keys.read().await;
- let keys = guard
- .as_ref()
- .ok_or(PlatformWalletError::ShieldedNotBound)?;
- let keyset = keys.get(&account).ok_or_else(|| {
- PlatformWalletError::ShieldedKeyDerivation(format!(
- "shielded account {account} not bound"
- ))
- })?;
- super::shielded::operations::identity_create_from_shielded_pool(
- &self.sdk,
- coordinator.store(),
- Some(&self.persister),
- self.wallet_id,
- keyset,
- account,
- public_keys,
- denomination,
- send_to_address_on_creation_failure,
- identity_signer,
- &prover,
- )
- .await
+ let (identity_id, identity) = {
+ // Scope the read guard so it's released before we take the wallet-manager write lock
+ // below — the keyset is only needed for the spend, not for the registration step.
+ let guard = self.shielded_keys.read().await;
+ let keys = guard
+ .as_ref()
+ .ok_or(PlatformWalletError::ShieldedNotBound)?;
+ let keyset = keys.get(&account).ok_or_else(|| {
+ PlatformWalletError::ShieldedKeyDerivation(format!(
+ "shielded account {account} not bound"
+ ))
+ })?;
+ super::shielded::operations::identity_create_from_shielded_pool(
+ &self.sdk,
+ coordinator.store(),
+ Some(&self.persister),
+ self.wallet_id,
+ keyset,
+ account,
+ public_keys,
+ denomination,
+ send_to_address_on_creation_failure,
+ identity_signer,
+ &prover,
+ )
+ .await?
+ };
+
+ // Register the proof-verified identity in the local manager at its HD slot, exactly like
+ // `register_from_addresses`' Step 3 — this drives the host persister's
+ // `IdentityChangeSet` / `IdentityKeysChangeSet` emit so the app's identity row is created.
+ // The broadcast already succeeded; a registration failure here (e.g. the slot is already
+ // occupied locally) is logged and swallowed rather than surfaced as an error, since the
+ // identity exists on chain and the next sync heals the local view.
+ {
+ let mut wm = self.wallet_manager.write().await;
+ match wm.get_wallet_info_mut(&self.wallet_id) {
+ Some(info) => {
+ if let Err(e) = info.identity_manager.add_identity(
+ identity,
+ identity_index,
+ self.wallet_id,
+ &self.persister,
+ ) {
+ tracing::warn!(
+ identity_index,
+ error = %e,
+ "IdentityCreateFromShieldedPool broadcast succeeded but registering the \
+ identity in the local manager failed; the on-chain identity exists and \
+ the next sync will heal the local row"
+ );
+ }
+ }
+ None => {
+ tracing::warn!(
+ identity_index,
+ "IdentityCreateFromShieldedPool broadcast succeeded but the wallet info was \
+ not found in the manager; skipping local registration (heals on next sync)"
+ );
+ }
+ }
+ }
+
+ Ok(identity_id)
}
/// Shield credits from a Platform Payment account into the
diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs
index b3c8c26f1c4..a3d3d72c92d 100644
--- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs
+++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs
@@ -38,9 +38,11 @@ use dpp::address_funds::{
AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, OrchardAddress, PlatformAddress,
};
use dpp::fee::Credits;
+use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0};
use dpp::identity::core_script::CoreScript;
+use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0;
use dpp::identity::signer::Signer;
-use dpp::identity::IdentityPublicKey;
+use dpp::identity::{Identity, IdentityPublicKey};
use dpp::prelude::Identifier;
use dpp::shielded::builder::{
build_identity_create_from_shielded_pool_transition, build_shield_transition,
@@ -668,8 +670,10 @@ pub async fn withdraw(
/// per-action spend-auth signatures + binding signature (which commits the derived id + denomination
/// + full key set) + the per-key PoP — there is NO platform identity signature.
///
-/// Returns the new identity's id (`double_sha256(sorted nullifiers)`), derived deterministically
-/// from the spent notes' nullifiers.
+/// Returns the new identity's id (`double_sha256(sorted nullifiers)`, derived deterministically
+/// from the spent notes' nullifiers) together with the proof-verified [`Identity`] returned by the
+/// SDK broadcast. The caller registers that `Identity` in its local `IdentityManager` so the host
+/// persister emits the row, mirroring the address-funded registration path.
#[allow(clippy::too_many_arguments)]
pub async fn identity_create_from_shielded_pool(
sdk: &Arc,
@@ -683,7 +687,7 @@ pub async fn identity_create_from_shielded_pool(
send_to_address_on_creation_failure: PlatformAddress,
identity_signer: &IS,
prover: &P,
-) -> Result
+) -> Result<(Identifier, Identity), PlatformWalletError>
where
S: ShieldedStore,
P: OrchardProver,
@@ -715,6 +719,16 @@ where
"IdentityCreateFromShieldedPool"
);
+ // Snapshot the submitted `IdentityPublicKey` halves keyed by their `KeyID` BEFORE the build
+ // consumes `public_keys`. This is the canonical record of the key set the transition commits to
+ // (the binding signature covers it), so it's the defensive fallback if the proof-verified
+ // identity comes back with an empty `public_keys()` map — same pattern register_from_addresses
+ // uses for its address-funded `put_*` stub.
+ let submitted_public_keys: BTreeMap = public_keys
+ .iter()
+ .map(|(key, _)| (key.id(), key.clone()))
+ .collect();
+
// From here on every error path must release the reservation taken above.
let result = async {
let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?;
@@ -741,22 +755,71 @@ where
trace!("IdentityCreateFromShieldedPool: built, broadcasting via SDK helper...");
// Broadcast through the SDK helper, which re-assembles the transition from the PoP-signed
// keys + bundle params (preserving the per-key signatures) and waits for proven execution.
- sdk.identity_create_from_shielded_pool(
- build.public_keys,
- denomination,
- send_to_address_on_creation_failure,
- build.bundle,
- None,
- )
- .await
- .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?;
+ // It returns a `VerifiedIdentityWithShieldedNullifiers` proof result carrying the
+ // proof-verified `Identity` (and the consumed nullifiers).
+ let proof_result = sdk
+ .identity_create_from_shielded_pool(
+ build.public_keys,
+ denomination,
+ send_to_address_on_creation_failure,
+ build.bundle,
+ None,
+ )
+ .await
+ .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?;
+
+ // Pull the verified `Identity` out of the proof result. The expected variant is
+ // `VerifiedIdentityWithShieldedNullifiers`; if drive-abci ever returns a different one the
+ // broadcast still SUCCEEDED, so we don't turn it into an error — we synthesize the identity
+ // from the derived id + submitted keys (the binding signature committed both) and warn, so
+ // the local row is still created.
+ let identity = match proof_result {
+ StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers(
+ mut identity,
+ _nullifiers,
+ ) => {
+ // The proof-verified id is authoritative: it's recomputed from the proven nullifier
+ // set, while `identity_id` was derived pre-broadcast. They should match (the derived
+ // id is committed in the sighash), but trust the verified one.
+ if identity.id() != identity_id {
+ warn!(
+ derived_id = %identity_id,
+ verified_id = %identity.id(),
+ "IdentityCreateFromShieldedPool: derived id differs from proof-verified id; \
+ using the proof-verified id"
+ );
+ }
+ // Defensive: a proof result can hand back an identity whose `public_keys` map is
+ // empty. Fill it from the submitted set so downstream auth-key checks see the keys
+ // immediately without waiting for the next identity-fetch round (the transition
+ // committed exactly these keys, so id reproducibility is preserved).
+ if identity.public_keys().is_empty() {
+ identity.set_public_keys(submitted_public_keys);
+ }
+ identity
+ }
+ other => {
+ warn!(
+ derived_id = %identity_id,
+ result = %other,
+ "IdentityCreateFromShieldedPool: unexpected proof-result variant; synthesizing \
+ the identity from the derived id + submitted keys so the local row still lands"
+ );
+ Identity::new_with_id_and_keys(
+ identity_id,
+ submitted_public_keys,
+ sdk.version(),
+ )
+ .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?
+ }
+ };
- Ok::(identity_id)
+ Ok::<(Identifier, Identity), PlatformWalletError>((identity.id(), identity))
}
.await;
match result {
- Ok(identity_id) => {
+ Ok((identity_id, identity)) => {
// Best-effort post-broadcast bookkeeping (see `unshield`): mark the spent notes so the
// local balance reflects the exit immediately; any drift heals on the next nullifier
// sync. The on-chain nullifier set — not this local mark — is the authoritative
@@ -776,7 +839,7 @@ where
identity_id = %identity_id,
"IdentityCreateFromShieldedPool broadcast succeeded"
);
- Ok(identity_id)
+ Ok((identity_id, identity))
}
Err(e) => {
cancel_pending(store, id, &selected_notes).await;
diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift
index b578a24263a..00c4ee18c96 100644
--- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift
+++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift
@@ -625,6 +625,13 @@ extension PlatformWalletManager {
/// the bound wallet's own key. Returns the 32-byte new identity id
/// (`double_sha256(sorted nullifiers)`).
///
+ /// `identityIndex` is the DIP-9 identity-registration slot the new
+ /// identity occupies. On a successful broadcast the Rust wallet
+ /// registers the proof-verified identity at this slot in its local
+ /// `IdentityManager` (mirroring address-funded registration), which
+ /// drives the persister callbacks that create the app's identity
+ /// row. This wrapper only marshals it across the FFI.
+ ///
/// `sendToAddressOnCreationFailure` is the REQUIRED fallback
/// platform address as raw `PlatformAddress` storage bytes (21
/// bytes: 1-byte variant tag + 20-byte hash, the encoding
@@ -639,6 +646,7 @@ extension PlatformWalletManager {
public func shieldedIdentityCreateFromPool(
walletId: Data,
account: UInt32 = 0,
+ identityIndex: UInt32,
identityPubkeys: [ManagedPlatformWallet.IdentityPubkey],
denomination: UInt64,
sendToAddressOnCreationFailure: Data,
@@ -717,6 +725,7 @@ extension PlatformWalletManager {
handle,
widPtr,
account,
+ identityIndex,
ffiRowsPtr,
UInt(ffiRowsCount),
denomination,
diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift
index d68479187e8..98176d9cc1e 100644
--- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift
+++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift
@@ -8,12 +8,23 @@
// "Create without Wallet" for the advanced path where the caller
// supplies a raw asset-lock proof.
// 2. When a wallet is chosen: either a PersistentAccount on that
-// wallet (any type — Core pools and Platform Payment both work)
-// or "Fund from unused Asset Lock".
+// wallet (any type — Core pools and Platform Payment both work),
+// "Fund from unused Asset Lock", or "Shielded Balance" (Type 20
+// IdentityCreateFromShieldedPool — funds the identity directly
+// from the wallet's bound Orchard pool).
//
-// The first-pass implementation only wires the Platform Payment
-// funding path — see `submit()`. Core / CoinJoin / walletless paths
-// are still stubs pending their respective FFI entry points.
+// Funding paths wired in `submit()`: Platform Payment
+// (`registerIdentityFromAddresses`), Core / CoinJoin
+// (`registerIdentityWithFunding`), unused asset-lock resume
+// (`resumeIdentityWithAssetLock`), and Shielded Balance
+// (`shieldedIdentityCreateFromPool`). The walletless raw-proof path
+// is still a stub pending its FFI entry point.
+//
+// The Shielded Balance pass differs from the others: it spends a FIXED
+// protocol denomination (not a free-form amount) from the bound
+// shielded (Orchard) pool, takes tens of seconds for the Halo 2 proof,
+// and so routes through the RegistrationCoordinator-hosted controller
+// (survives sheet dismissal, visible under Pending Registrations).
import SwiftUI
import SwiftDashSDK
@@ -41,6 +52,11 @@ struct CreateIdentityView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var walletManager: PlatformWalletManager
@EnvironmentObject var platformState: AppState
+ /// Display-state mirror of the Rust-owned shielded sync. Injected
+ /// at the app root (`SwiftExampleAppApp.swift`). Binds ONE wallet
+ /// at a time — the `.shieldedBalance` funding option is only
+ /// offered when its `boundWalletId` matches the selected wallet.
+ @EnvironmentObject var shieldedService: ShieldedService
/// Default number of Platform identity authentication keys to
/// register in this first-pass flow. First key is MASTER, the
@@ -81,6 +97,24 @@ struct CreateIdentityView: View {
/// docstring; kept here so the conversion logic stays local.
private static let creditsPerDash: UInt64 = 100_000_000_000
+ /// Versioned fixed exit denominations (in CREDITS) a Type-20
+ /// IdentityCreateFromShieldedPool transition may spend from the
+ /// shielded pool — 0.1 / 0.3 / 0.5 / 1.0 DASH. Source of truth:
+ /// `shielded_identity_create_denominations` in
+ /// `packages/rs-platform-version/src/version/drive_abci_versions/`
+ /// `drive_abci_validation_versions/v8.rs`. There is no FFI getter
+ /// for this set, so it's mirrored here — same precedent as
+ /// `identityKeyCreationCostCredits` / `dashpayContractId`. If the
+ /// versioned set changes on the Rust side, update this constant to
+ /// match (a submitted denomination not in the on-chain set is
+ /// rejected at validation).
+ private static let shieldedIdentityCreateDenominations: [UInt64] = [
+ 10_000_000_000, // 0.1 DASH
+ 30_000_000_000, // 0.3 DASH
+ 50_000_000_000, // 0.5 DASH
+ 100_000_000_000, // 1.0 DASH
+ ]
+
/// Duffs per DASH (1e8) — Core-side scale, used by the Core-funded
/// identity path.
private static let duffsPerDash: UInt64 = 100_000_000
@@ -215,6 +249,13 @@ struct CreateIdentityView: View {
/// can lower it but not exceed the available balance.
@State private var amountDash: String = ""
+ /// Chosen fixed exit denomination (in credits) for the
+ /// `.shieldedBalance` funding path. `nil` until the user picks one
+ /// in the denomination picker. Reset on any wallet / funding-source
+ /// change (same reset paths as `amountDash`). Only ever one of
+ /// `Self.shieldedIdentityCreateDenominations`.
+ @State private var selectedDenomination: UInt64? = nil
+
// MARK: - Submit state
/// True while the FFI `registerIdentityFromAddresses` call is in
@@ -404,10 +445,11 @@ struct CreateIdentityView: View {
}
.onChange(of: walletSelection) { _, newValue in
// Reset downstream selection whenever the wallet
- // changes so a stale account / proof can't leak
- // through.
+ // changes so a stale account / proof / denomination
+ // can't leak through.
fundingSelection = nil
walletlessProof = ""
+ selectedDenomination = nil
// Default the identity-registration index to one
// past the highest already-used slot on the newly-
// selected wallet (identities aren't gap-limited; we
@@ -513,6 +555,9 @@ struct CreateIdentityView: View {
@ViewBuilder
private func walletAccountSection(for walletId: Data) -> some View {
let options = accountOptions(for: walletId)
+ // Whether to surface the shielded-pool funding row. Computed
+ // once so the picker body and the footer text stay in lockstep.
+ let showShielded = shieldedOptionAvailable(for: walletId)
Section {
Picker("Funding Source", selection: $fundingSelection) {
Text("Select…")
@@ -521,12 +566,24 @@ struct CreateIdentityView: View {
Text("\(option.label) — \(option.balanceText)")
.tag(Optional(FundingSelection.account(id: option.persistentId)))
}
+ if showShielded {
+ let shieldedText = Self.formatDash(
+ raw: shieldedService.shieldedBalance,
+ divisor: Double(Self.creditsPerDash)
+ )
+ Text("Shielded Balance — \(shieldedText)")
+ .tag(Optional(FundingSelection.shieldedBalance))
+ }
}
.onChange(of: fundingSelection) { _, newValue in
// Pre-fill the amount with the full available balance
// of the selected Platform Payment account so the
- // happy path is one tap. Users can dial it down.
+ // happy path is one tap. Users can dial it down. The
+ // shielded path uses a fixed denomination, not this
+ // free-form field, so clear both the amount and the
+ // denomination on every funding-source change.
amountDash = defaultAmountString(for: newValue)
+ selectedDenomination = nil
}
} header: {
Text("Funding Source")
@@ -534,17 +591,27 @@ struct CreateIdentityView: View {
Text(
"Any account on the selected wallet with a balance can fund "
+ "the identity — Core or Platform Payment. Empty accounts "
- + "are hidden. To resume a prior in-flight registration, "
+ + "are hidden. "
+ + (showShielded
+ ? "Shielded Balance funds the identity directly from this "
+ + "wallet's shielded (Orchard) pool by spending a fixed "
+ + "denomination. "
+ : "")
+ + "To resume a prior in-flight registration, "
+ "use the Resumable Registrations section on the Identities tab."
)
}
}
/// Amount (in DASH) to fund the new identity with. Shown for
- /// Platform Payment and Core / CoinJoin funding sources.
+ /// Platform Payment and Core / CoinJoin funding sources. For the
+ /// `.shieldedBalance` path the free-form amount is replaced by a
+ /// fixed-denomination picker (`shieldedDenominationSection`).
@ViewBuilder
private var amountSection: some View {
- if let account = selectedPlatformAccount {
+ if fundingSelection == .shieldedBalance {
+ shieldedDenominationSection
+ } else if let account = selectedPlatformAccount {
Section {
HStack {
TextField("Amount", text: $amountDash)
@@ -589,6 +656,65 @@ struct CreateIdentityView: View {
}
}
+ /// Fixed-denomination picker for the `.shieldedBalance` funding
+ /// path. The Type-20 transition spends one of the versioned
+ /// denominations (`Self.shieldedIdentityCreateDenominations`), not
+ /// a free-form amount — so this replaces the amount field. Only
+ /// denominations the bound shielded pool can actually cover
+ /// (`<= shieldedService.shieldedBalance`) are offered.
+ @ViewBuilder
+ private var shieldedDenominationSection: some View {
+ // Denominations the pool can cover. Computed off the live
+ // `shieldedService.shieldedBalance` so the list shrinks as the
+ // pool drains (e.g. after a prior shielded spend this session).
+ let affordable = Self.shieldedIdentityCreateDenominations
+ .filter { $0 <= shieldedService.shieldedBalance }
+ Section {
+ if affordable.isEmpty {
+ // Defensive: the option is gated on `shieldedBalance > 0`,
+ // but the smallest denomination (0.1 DASH) can still
+ // exceed a small positive balance. Surface why no
+ // denomination is selectable rather than showing an
+ // empty picker.
+ Text(
+ "The shielded balance is below the smallest "
+ + "denomination (\(Self.formatDash(raw: Self.shieldedIdentityCreateDenominations.first ?? 0, divisor: Double(Self.creditsPerDash)))). "
+ + "Shield more funds first."
+ )
+ .font(.caption)
+ .foregroundColor(.secondary)
+ } else {
+ Picker("Denomination", selection: $selectedDenomination) {
+ Text("Select…")
+ .tag(Optional.none)
+ ForEach(affordable, id: \.self) { denom in
+ Text(Self.formatDash(
+ raw: denom,
+ divisor: Double(Self.creditsPerDash)
+ ))
+ .tag(Optional(denom))
+ }
+ }
+ .disabled(isCreating)
+ }
+ } header: {
+ Text("Denomination")
+ } footer: {
+ let available = Self.formatDash(
+ raw: shieldedService.shieldedBalance,
+ divisor: Double(Self.creditsPerDash)
+ )
+ Text(
+ "Available shielded: \(available). The whole denomination "
+ + "leaves the pool; the metered fee is taken FROM it (the new "
+ + "identity starts at denomination − fee), and any excess "
+ + "spent value returns to the pool as change. The Halo 2 proof "
+ + "takes tens of seconds — registration continues under "
+ + "Pending Registrations if you dismiss this sheet."
+ )
+ }
+ }
+
private var walletlessSection: some View {
Section {
TextEditor(text: $walletlessProof)
@@ -674,6 +800,22 @@ struct CreateIdentityView: View {
}
}
+ /// Per-key cost note for the DashPay-keys footer. The "+N duffs"
+ /// funding-minimum phrasing only applies to the asset-lock-style
+ /// paths (Core / Platform Payment), where the per-key surcharge
+ /// bumps the funding floor. The shielded path spends a FIXED
+ /// denomination and meters the fee FROM it, so there's no duff
+ /// minimum to add to — surface the per-key cost neutrally instead
+ /// of as a misleading "asset-lock minimum" bump.
+ private var dashpayKeysCostNote: String {
+ if fundingSelection == .shieldedBalance {
+ return "The two extra keys add their per-key creation cost to the metered fee taken from the chosen denomination."
+ }
+ let extraDuffs = currentMinFundingDuffs
+ - Self.minFundingDuffs(forKeyCount: Self.defaultKeyCount)
+ return "Adds \(extraDuffs) duffs to the asset-lock minimum."
+ }
+
/// Toggle for the optional DashPay encryption/decryption key
/// pair. Default-on because DashPay is a first-class feature in
/// this app and registering the keys after-the-fact requires
@@ -689,11 +831,9 @@ struct CreateIdentityView: View {
} header: {
Text("DashPay Support")
} footer: {
- let extraDuffs = currentMinFundingDuffs
- - Self.minFundingDuffs(forKeyCount: Self.defaultKeyCount)
Text(
addDashPayKeys
- ? "Registers 2 additional keys at registration — one Encryption + one Decryption (both MEDIUM security, ECDSA secp256k1, bound to the DashPay system contract's `contactRequest` document type). Required for sending and accepting friend requests, sending payments to contacts, and DashPay profile flows. Adds \(extraDuffs) duffs to the asset-lock minimum."
+ ? "Registers 2 additional keys at registration — one Encryption + one Decryption (both MEDIUM security, ECDSA secp256k1, bound to the DashPay system contract's `contactRequest` document type). Required for sending and accepting friend requests, sending payments to contacts, and DashPay profile flows. \(dashpayKeysCostNote)"
: "Identity will register with the default 3 authentication keys only. You can add DashPay encryption/decryption keys later via Add Identity Key on the identity detail screen — but flows like Add Friend won't work until those keys exist."
)
}
@@ -786,6 +926,16 @@ struct CreateIdentityView: View {
let available = coreAccountBalanceDuffs(account)
return duffs >= currentMinFundingDuffs && duffs <= available
}
+ if fundingSelection == .shieldedBalance {
+ // Re-check availability (the bound wallet / balance could
+ // have changed since the option was rendered), require a
+ // chosen denomination, and that the pool still covers it.
+ // The slot-collision check above already applies (the
+ // shielded path isn't `.unusedAssetLock`).
+ guard shieldedOptionAvailable(for: walletId) else { return false }
+ guard let denomination = selectedDenomination else { return false }
+ return denomination <= shieldedService.shieldedBalance
+ }
return false
default:
return false
@@ -797,9 +947,12 @@ struct CreateIdentityView: View {
/// Dispatches identity registration to the correct funding path.
/// Platform-Payment funding uses `registerIdentityFromAddresses`;
/// Core / CoinJoin funding uses `registerIdentityWithFunding`
- /// (asset-lock proof built Rust-side from wallet UTXOs). Other
- /// funding branches (unused asset-lock, walletless) stay disabled
- /// via `canSubmit` until later iterations.
+ /// (asset-lock proof built Rust-side from wallet UTXOs); unused
+ /// asset-lock resume uses `resumeIdentityWithAssetLock`; and the
+ /// Shielded Balance path uses `shieldedIdentityCreateFromPool`
+ /// (Type-20, spends a fixed denomination from the Orchard pool).
+ /// The walletless raw-proof branch stays disabled via `canSubmit`
+ /// until its FFI entry point lands.
private func submit() {
guard
let identityIndex = identityIndex,
@@ -925,6 +1078,15 @@ struct CreateIdentityView: View {
managedWallet: managedWallet,
network: network
)
+ } else if fundingSelection == .shieldedBalance {
+ submitShieldedFunded(
+ walletId: walletId,
+ identityIndex: identityIndex,
+ identityPubkeys: identityPubkeys,
+ signer: signer,
+ managedWallet: managedWallet,
+ network: network
+ )
} else {
submitError = .init(message: "Selected funding source is not yet supported.")
}
@@ -982,6 +1144,87 @@ struct CreateIdentityView: View {
)
}
+ /// Shielded-pool funded registration (Type-20
+ /// IdentityCreateFromShieldedPool). Spends a fixed denomination from
+ /// the wallet's bound Orchard pool to fund a brand-new identity.
+ ///
+ /// Same coordinator-hosted shape as `submitCoreFunded` /
+ /// `submitResumed`: the body closure runs
+ /// `walletManager.shieldedIdentityCreateFromPool` (returns the new
+ /// identity id directly) and `observeController` drives the shared
+ /// `persistCreatedIdentity` + `markIdentitySlotUsed` side-effects.
+ /// The coordinator host matters MORE here than the other paths — the
+ /// Halo 2 proof takes tens of seconds, so a controller that survives
+ /// sheet dismissal (and shows under Pending Registrations) is the
+ /// right shape.
+ ///
+ /// Uses ZIP-32 account 0 (the app's shielded flows are account-0
+ /// default). The Rust wrapper registers the proof-verified identity
+ /// at `identityIndex` on success, so the `PersistentIdentity` row is
+ /// created by the persister callbacks before `persistCreatedIdentity`
+ /// patches its UI-only fields — same as the address-funded path.
+ private func submitShieldedFunded(
+ walletId: Data,
+ identityIndex: UInt32,
+ identityPubkeys: [ManagedPlatformWallet.IdentityPubkey],
+ signer: KeychainSigner,
+ managedWallet: ManagedPlatformWallet,
+ network: Network
+ ) {
+ guard let denomination = selectedDenomination else {
+ submitError = .init(message: "Pick a shielded denomination first.")
+ return
+ }
+ // The fallback failure address is REQUIRED for Type-20. Visibility
+ // of the option is already gated on this being non-nil
+ // (`shieldedOptionAvailable`), but re-resolve + guard here so the
+ // submit path is force-unwrap-free if state shifted underneath us.
+ guard let fallbackAddressBytes = shieldedFallbackAddressBytes(for: walletId) else {
+ submitError = .init(
+ message: "No Platform Payment address is available on this wallet to use as the required shielded creation-failure fallback. Generate one first."
+ )
+ return
+ }
+
+ isCreating = true
+
+ let coordinator = walletManager.registrationCoordinator
+ // Captured locally so the escaping body holds its own reference
+ // rather than re-reading the view's `walletManager` property from
+ // another isolation domain (mirrors how the other submit paths
+ // capture `managedWallet`).
+ let manager = walletManager
+ let controller = coordinator.startRegistration(
+ walletId: walletId,
+ identityIndex: identityIndex,
+ body: {
+ // `shieldedIdentityCreateFromPool` lives on the manager
+ // (it's wallet-id-routed, unlike the per-wallet
+ // `ManagedPlatformWallet` registration methods) and
+ // returns the new identity id directly. Account 0 = the
+ // app's shielded default.
+ let identityId = try await manager.shieldedIdentityCreateFromPool(
+ walletId: walletId,
+ account: 0,
+ identityIndex: identityIndex,
+ identityPubkeys: identityPubkeys,
+ denomination: denomination,
+ sendToAddressOnCreationFailure: fallbackAddressBytes,
+ identitySigner: signer
+ )
+ return identityId
+ }
+ )
+
+ self.activeController = controller
+ observeController(
+ controller,
+ walletId: walletId,
+ identityIndex: identityIndex,
+ network: network
+ )
+ }
+
/// Platform-Payment funded registration. Spends credits from
/// `PersistentPlatformAddress` rows on the selected account.
private func submitPlatformPayment(
@@ -1378,6 +1621,51 @@ struct CreateIdentityView: View {
account.platformAddresses.reduce(0) { $0 + $1.balance }
}
+ /// REQUIRED Type-20 fallback failure address for `walletId`, as raw
+ /// 21-byte `PlatformAddress` storage bytes (1-byte variant tag +
+ /// 20-byte hash). If identity creation fails a stateful check (a
+ /// pubkey hash already registered to another identity) the spend is
+ /// still finalized and the value lands at this address minus a
+ /// penalty — it's bound into the transition sighash, so it can't be
+ /// redirected after signing.
+ ///
+ /// Built from the wallet's Platform Payment account (type tag 14):
+ /// the lowest-`addressIndex` `PersistentPlatformAddress` row gives
+ /// `Data([row.addressType]) + row.addressHash`. This is the exact
+ /// `(addressType, hash)` pairing `buildInputs` feeds the
+ /// address-funded FFI, so the encoding matches
+ /// `PlatformAddress.toBytes()`. Returns `nil` when the wallet has no
+ /// Platform Payment address yet — the `.shieldedBalance` option is
+ /// gated on this being non-nil so `submit` can guard cleanly.
+ private func shieldedFallbackAddressBytes(for walletId: Data) -> Data? {
+ guard let account = allAccounts.first(where: {
+ $0.wallet.walletId == walletId && $0.accountType == 14
+ }) else {
+ return nil
+ }
+ guard let row = account.platformAddresses
+ .min(by: { $0.addressIndex < $1.addressIndex })
+ else {
+ return nil
+ }
+ return Data([row.addressType]) + row.addressHash
+ }
+
+ /// Whether the `.shieldedBalance` funding option should be offered
+ /// for `walletId`. Requires, ALL of:
+ /// - the wallet is the one currently bound to `ShieldedService`,
+ /// - that service reports the wallet as bound (`isBound`),
+ /// - the bound shielded pool has a positive balance,
+ /// - a fallback platform address exists (Type-20 requires it).
+ /// Gating visibility on the fallback lets `submit` guard without a
+ /// force-unwrap and surface a clear error path-free.
+ private func shieldedOptionAvailable(for walletId: Data) -> Bool {
+ shieldedService.boundWalletId == walletId
+ && shieldedService.isBound
+ && shieldedService.shieldedBalance > 0
+ && shieldedFallbackAddressBytes(for: walletId) != nil
+ }
+
/// Derive + Keychain-persist the DashPay encryption/decryption
/// key pair (kid `firstKeyId` = ENCRYPTION, kid `firstKeyId+1` =
/// DECRYPTION), build the matching `IdentityPubkey` rows with
@@ -1796,6 +2084,11 @@ private enum WalletSelection: Hashable {
private enum FundingSelection: Hashable {
case account(id: PersistentIdentifier)
case unusedAssetLock
+ /// Fund the new identity from the wallet's bound shielded (Orchard)
+ /// pool via the Type-20 IdentityCreateFromShieldedPool transition.
+ /// Only offered when the wallet is the one currently bound to
+ /// `ShieldedService` and that pool has a positive balance.
+ case shieldedBalance
}
private struct FundingAccountOption: Identifiable {