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 {