From e5cb65d40913e51f69d93100baea29468a7917b2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 10 Jun 2026 06:21:14 +0200 Subject: [PATCH] feat(swift-sdk): select Core or Platform source when shielding in example app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "+" next to Shielded Balance previously always opened the Type 18 asset-lock flow ("Shield from Asset Lock"). The sheet now hosts both shield-into-pool entry points behind a segmented Source picker: * Core Balance — the existing Type 18 asset-lock flow, unchanged (controller/coordinator progress, resume-from-lock stays Core-only). * Platform Balance — the Type 15 path via the already-wrapped `shieldedShield(...)`: spends credits from a chosen DIP-17 Platform Payment account into the wallet's own shielded pool (always shield-to-self), with its own lightweight in-flight/terminal section. The navigation title tracks the picker ("Shield from Core Balance" / "Shield from Platform Balance"). No Rust/FFI changes — pure example-app wiring over the existing platform-wallet entry points. Also adds a round-trip regression test proving the Type 15 client pair end-to-end: a shield bundle built to the wallet's own default address must trial-decrypt under the same keyset's prepared IVK (exactly one of the two padded actions, at the shielded amount). This is the test that discriminates client-side correctness when a wallet scans notes but claims none — e.g. the stale-build/devnet note-format skew (280 vs 312 byte items around e9dc7dbd4c) diagnosed with it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/wallet/shielded/sync.rs | 6 + .../shielded/sync/shield_decrypt_tests.rs | 148 +++++++ .../Core/Views/WalletDetailView.swift | 8 +- .../Views/ShieldedFundFromAssetLockView.swift | 382 +++++++++++++++++- 4 files changed, 536 insertions(+), 8 deletions(-) create mode 100644 packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 146ef4b31dd..9f488b6360a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -998,3 +998,9 @@ mod tests { /// `sync_notes_across`). #[cfg(test)] mod ovk_recovery_tests; + +/// Round-trip guard for the Type 15 client pair: the shield builder's +/// serialized actions must trial-decrypt under the same keyset's IVK +/// (the chain stores them verbatim, so this covers the full path). +#[cfg(test)] +mod shield_decrypt_tests; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs new file mode 100644 index 00000000000..64279767ac3 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs @@ -0,0 +1,148 @@ +//! Round-trip: a Type 15 Shield bundle built by the wallet's own +//! builder must be trial-decryptable by the same wallet's IVK. +//! +//! This is the exact client-side pair the app exercises end-to-end: +//! +//! * build side — `operations::shield` derives the recipient from +//! `OrchardKeySet::default_address` and calls dpp's +//! `build_shield_transition`, whose serialized actions are stored +//! verbatim on-chain (`ShieldedActionNote::from(&SerializedAction)` +//! → `insert_note_op`, all field-name-mapped, no re-encoding); +//! * scan side — the sync stream parses the stored item back into +//! `ShieldedEncryptedNote {cmx, nullifier, cv_net, encrypted_note}` +//! and runs `try_decrypt_note` under the account's prepared IVK. +//! +//! So building the transition here and feeding its actions straight +//! into `try_decrypt_note` reproduces the full client round trip with +//! the chain's (verbatim) storage cut out of the middle. Exactly one +//! of the two padded actions must decrypt: the real recipient output. +//! The other is Orchard's dummy padding output, decryptable by no one. + +use std::collections::BTreeMap; + +use dash_sdk::platform::shielded::try_decrypt_note; +use dashcore::Network; +use dpp::address_funds::{ + AddressFundsFeeStrategyStep, AddressWitness, OrchardAddress, PlatformAddress, +}; +use dpp::identity::signer::Signer; +use dpp::platform_value::BinaryData; +use dpp::shielded::builder::build_shield_transition; +use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::StateTransition; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use drive_proof_verifier::types::ShieldedEncryptedNote; + +use crate::wallet::shielded::keys::OrchardKeySet; +use crate::wallet::shielded::prover::CachedOrchardProver; + +/// Fake transparent-side signer: the input witnesses are irrelevant +/// to note encryption (they sign the transparent inputs, not the +/// Orchard bundle), so a dummy 65-byte signature suffices. +#[derive(Debug)] +struct DummySigner; + +#[async_trait::async_trait] +impl Signer for DummySigner { + async fn sign( + &self, + _key: &PlatformAddress, + _data: &[u8], + ) -> Result { + Ok(BinaryData::new(vec![0u8; 65])) + } + + async fn sign_create_witness( + &self, + _key: &PlatformAddress, + _data: &[u8], + ) -> Result { + Ok(AddressWitness::P2pkh { + signature: BinaryData::new(vec![0u8; 65]), + }) + } + + fn can_sign_with(&self, _key: &PlatformAddress) -> bool { + true + } +} + +#[tokio::test] +async fn shield_built_note_is_trial_decryptable_by_own_ivk() { + let seed = [0x42u8; 32]; + let keys = OrchardKeySet::from_seed(&seed, Network::Testnet, 0) + .expect("ZIP-32 derivation from a fixed seed should succeed"); + + // Recipient = the wallet's own default address — the same + // conversion `operations::shield` performs via + // `default_orchard_address`. + let recipient = OrchardAddress::from_raw_bytes(&keys.default_address.to_raw_address_bytes()) + .expect("default address must convert to OrchardAddress"); + + let amount: u64 = 200_000_000_000; // 2 DASH in credits + let mut inputs = BTreeMap::new(); + inputs.insert( + PlatformAddress::P2pkh([0xAB; 20]), + (0u32, 500_000_000_000u64), + ); + + // `OrchardProver` is implemented for `&CachedOrchardProver` + // (the cached key lives in a static), so P = `&CachedOrchardProver` + // and the builder's `&P` is a double reference — the same shape + // `shielded_shield_from_account` passes through `operations::shield`. + let prover = CachedOrchardProver::new(); + let st = build_shield_transition( + &recipient, + amount, + inputs, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &DummySigner, + 0, + &&prover, + [0u8; 36], + PlatformVersion::latest(), + ) + .await + .expect("shield transition build should succeed"); + + let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + panic!("expected a Shield state transition"); + }; + + // Output-only bundles pad to exactly 2 actions. + assert_eq!( + v0.actions.len(), + 2, + "output-only shield bundle must carry exactly 2 padded actions" + ); + + // Reassemble each action exactly as the chain stores and the sync + // stream re-parses it, then trial-decrypt with the same keyset's + // prepared IVK — the scanner's exact call. + let ivk = keys.prepared_ivk(); + let decrypted: Vec = v0 + .actions + .iter() + .filter_map(|a| { + let wire = ShieldedEncryptedNote { + cmx: a.cmx.to_vec(), + nullifier: a.nullifier.to_vec(), + cv_net: a.cv_net.to_vec(), + encrypted_note: a.encrypted_note.clone(), + }; + try_decrypt_note(&ivk, &wire).map(|(note, _addr)| note.value().inner()) + }) + .collect(); + + assert_eq!( + decrypted.len(), + 1, + "exactly one action (the real recipient output) must trial-decrypt; \ + 0 means the scanner can never see shielded funds, 2 means padding leaked" + ); + assert_eq!( + decrypted[0], amount, + "decrypted note value must equal the shielded amount" + ); +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 917cf4a87bd..79b22dc30a7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -1018,8 +1018,10 @@ struct BalanceCardView: View { // Shielded Balance row — mirrors the Platform // Balance row's trailing `+` affordance. When // `onFundShielded` is wired the user can open the - // Core L1 → shielded-pool funding sheet (Type 18, - // `ShieldFromAssetLockTransition`). + // shielding sheet, which now lets them choose the + // source: Core L1 → pool (Type 18, + // `ShieldFromAssetLockTransition`) or Platform credits + // → pool (Type 15, `shieldedShield`). WalletBalanceRow( label: "Shielded Balance", amount: shieldedService.shieldedBalance, @@ -1029,7 +1031,7 @@ struct BalanceCardView: View { trailingAction: onFundShielded.map { fund in WalletBalanceRow.TrailingAction( systemImage: "plus.circle.fill", - accessibilityLabel: "Shield from Core Asset Lock", + accessibilityLabel: "Add to Shielded Balance", action: fund ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift index 7dff964d163..b29c094ef01 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShieldedFundFromAssetLockView.swift @@ -1,6 +1,20 @@ // ShieldedFundFromAssetLockView.swift // SwiftExampleApp // +// Hosts TWO shield-into-pool entry points behind one sheet, chosen +// with an in-view "Source" segmented picker (fresh-build flow only): +// +// * Core Balance → the Type 18 asset-lock flow described below +// (build lock → wait IS-lock → Halo 2 proof → ShieldFromAssetLock). +// * Platform Balance → a Type 15 shield (`shieldedShield(...)`) that +// spends Platform credits from a DIP-17 Platform Payment account +// INTO this wallet's own shielded pool (always shield-to-self; +// no recipient choice). Single awaited FFI call, no asset-lock +// controller/coordinator — Halo 2 proof (~30s) runs inside. +// +// The resume-from-asset-lock path is Core-only and never shows the +// Source picker. +// // Stepped UI for shielding credits from a Core (SPV) wallet balance // into a wallet's Orchard (Type 18) pool. Drives // `PlatformWalletManager.shieldedFundFromAssetLock(...)` end-to-end: @@ -59,9 +73,34 @@ struct ShieldedFundFromAssetLockView: View { /// carry that — both are set at ST-submission time. var resumeFromLock: PersistentAssetLock? = nil + /// All persisted accounts. Filtered down to the wallet's DIP-17 + /// Platform Payment accounts inside `platformAccountOptions` + /// (the Platform-source branch). Unused by the Core branch. + @Query private var allAccounts: [PersistentAccount] + + /// All persisted platform addresses. Summed per Platform Payment + /// account inside `platformAccountOptions` to show each account's + /// spendable credit balance in the picker. + @Query private var allPlatformAddresses: [PersistentPlatformAddress] + + // MARK: - Source selection + + /// Which balance the fresh-build flow shields from. Drives both + /// the body branching and the live navigation title. The resume + /// path ignores this (it's Core-only). + private enum ShieldSource: String, CaseIterable, Identifiable { + case coreBalance = "Core Balance" + case platformBalance = "Platform Balance" + var id: String { rawValue } + } + @State private var source: ShieldSource = .coreBalance + // MARK: - Selection state @State private var fundingCoreAccountIndex: UInt32? = nil + /// DIP-17 Platform Payment account index the Platform branch + /// spends credits from. `paymentAccount` arg to `shieldedShield`. + @State private var platformAccountIndex: UInt32? = nil /// 43-byte raw recipient Orchard address. Defaults to the /// wallet's bound shielded default in `autoSelectDefaults`. @State private var recipientRaw43: Data? = nil @@ -71,14 +110,35 @@ struct ShieldedFundFromAssetLockView: View { /// fight the formatter on partial input. @State private var recipientHex: String = "" @State private var amountDash: String = "0.001" + /// Platform-branch amount in DASH (parsed to credits, 1e11/DASH). + /// Kept separate from `amountDash` so the Core (duffs) and + /// Platform (credits) unit systems never share a text buffer. + @State private var platformAmountDash: String = "0.001" // MARK: - Submit state @State private var submitError: SubmitError? = nil @State private var activeController: ShieldedFundFromAssetLockController? = nil + /// Platform-branch submit lifecycle. The Platform path does NOT + /// go through the asset-lock controller/coordinator — it's a + /// single awaited `shieldedShield(...)` FFI call — so it tracks + /// its own minimal phase here. Mirrors the Core controller's + /// terminal-section shape visually only. + private enum PlatformShieldPhase: Equatable { + case idle + case inFlight + case completed + case failed(String) + } + @State private var platformShieldPhase: PlatformShieldPhase = .idle + /// 1 DASH = 1e8 duffs (Core side). private static let duffsPerDash: UInt64 = 100_000_000 + /// 1 DASH = 1e11 Platform credits. The Platform branch types an + /// amount in DASH but `shieldedShield` takes credits, so the + /// Platform path multiplies by this (vs. `duffsPerDash` on Core). + private static let creditsPerDash: UInt64 = 100_000_000_000 /// The asset-lock floor mirrors /// `FundFromAssetLockPlatformAddressView` (1mDASH). private static let minDuffs: UInt64 = 100_000 @@ -87,9 +147,17 @@ struct ShieldedFundFromAssetLockView: View { NavigationStack { Form { if let controller = activeController { + // Core asset-lock flow in flight (Type 18). ShieldedFundFromAssetLockProgressSection(controller: controller) progressTerminalSection(controller: controller) + } else if platformShieldPhase != .idle { + // Platform → pool flow in flight / terminal (Type + // 15). Single awaited call, so its progress lives + // entirely in `platformShieldPhase` — no controller. + platformShieldProgressSection } else if resumeFromLock != nil { + // Resume is Core-only and never shows the Source + // picker — the lock already exists. walletSection resumeFromAssetLockSection recipientSection @@ -97,21 +165,34 @@ struct ShieldedFundFromAssetLockView: View { submitSection } } else { + // Fresh build: the user first picks a Source, then + // sees the matching funding/amount sections. walletSection - coreFundingSection - recipientSection - amountSection + sourcePickerSection + switch source { + case .coreBalance: + coreFundingSection + recipientSection + amountSection + case .platformBalance: + platformAccountSection + platformAmountSection + platformRecipientSection + } if canSubmit { submitSection } } } - .navigationTitle("Shield from Asset Lock") + .navigationTitle(navTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } - .disabled(activeController?.phase == .inFlight) + .disabled( + activeController?.phase == .inFlight + || platformShieldPhase == .inFlight + ) } } .alert(item: $submitError) { err in @@ -125,6 +206,17 @@ struct ShieldedFundFromAssetLockView: View { } } + /// Live navigation title. Resume is Core-only ("Shield from Asset + /// Lock"); the fresh-build flow tracks the Source picker so the + /// title flips between Core / Platform as the user toggles it. + private var navTitle: String { + if resumeFromLock != nil { return "Shield from Asset Lock" } + switch source { + case .coreBalance: return "Shield from Core Balance" + case .platformBalance: return "Shield from Platform Balance" + } + } + // MARK: - Sections private var walletSection: some View { @@ -170,6 +262,183 @@ struct ShieldedFundFromAssetLockView: View { } } + /// Segmented Core/Platform source toggle (fresh-build flow only). + /// Switching it re-renders the funding sections below and flips + /// the navigation title via `navTitle`. + private var sourcePickerSection: some View { + Section { + Picker("Source", selection: $source) { + ForEach(ShieldSource.allCases) { src in + Text(src.rawValue).tag(src) + } + } + .pickerStyle(.segmented) + } footer: { + Text( + "Core Balance locks L1 DASH into an asset lock (Type 18). " + + "Platform Balance spends existing Platform credits straight " + + "into your shielded pool (Type 15)." + ) + } + } + + // MARK: - Platform-source sections + + /// Platform Payment account picker — the credit source for the + /// Platform branch. Mirrors `FundFromAssetLockPlatformAddressView`'s + /// `platformAccountSection`. The chosen index becomes the + /// `paymentAccount` argument to `shieldedShield`. + @ViewBuilder + private var platformAccountSection: some View { + let options = platformAccountOptions + Section { + if options.isEmpty { + Text("No DIP-17 Platform Payment accounts on this wallet yet.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Picker("Platform Account", selection: $platformAccountIndex) { + Text("Select…").tag(Optional.none) + ForEach(options, id: \.accountIndex) { opt in + Text("Account #\(opt.accountIndex) — \(formatCredits(opt.totalCredits))") + .tag(Optional(opt.accountIndex)) + } + } + } + } header: { + Text("Platform Source") + } footer: { + Text( + "Platform Payment account whose credits are spent into the " + + "shielded pool. Picker shows each account's current credit " + + "balance." + ) + } + } + + /// Amount field for the Platform branch. The number is in DASH + /// for display ergonomics but converts to *credits* (1e11/DASH) + /// — distinct from the Core branch's duffs (1e8/DASH). A + /// separate field from `amountDash` keeps the two unit systems + /// from leaking into each other. + @ViewBuilder + private var platformAmountSection: some View { + Section { + HStack { + TextField("Amount", text: $platformAmountDash) + .keyboardType(.decimalPad) + .textFieldStyle(.roundedBorder) + .disabled(platformShieldPhase == .inFlight) + Text("DASH") + .foregroundColor(.secondary) + } + } header: { + Text("Amount") + } footer: { + if let credits = platformAmountCredits { + Text( + "\(formatCredits(credits)) will be moved from the selected " + + "Platform account into your shielded pool." + ) + } else { + Text("Amount in DASH; converted to Platform credits (1e11/DASH).") + } + } + } + + /// Read-only recipient row for the Platform branch. `shieldedShield` + /// has no recipient parameter — it always shields into this + /// wallet's own shielded pool (account 0). We surface the actual + /// default address when it's resolvable, falling back to a static + /// "your shielded address" line. + @ViewBuilder + private var platformRecipientSection: some View { + Section { + HStack { + Label("Recipient", systemImage: "lock.shield") + Spacer() + if let own = try? walletManager.shieldedDefaultAddress(walletId: wallet.walletId) { + Text(hexShort(own)) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text("your shielded address") + .foregroundColor(.secondary) + } + } + } header: { + Text("Destination") + } footer: { + Text( + "Always shields to your own wallet's shielded pool (account 0). " + + "This path has no recipient choice." + ) + } + } + + /// Terminal/progress section for the Platform branch. Visual + /// twin of `progressTerminalSection` but driven by + /// `platformShieldPhase` (no controller). Dismiss on Done/Dismiss. + @ViewBuilder + private var platformShieldProgressSection: some View { + switch platformShieldPhase { + case .inFlight: + Section { + HStack(spacing: 12) { + ProgressView() + Text("Building proof (~30s)…") + .foregroundColor(.secondary) + } + } + case .completed: + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Shielded", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + Text( + "The shielded note appears in your balance after the next " + + "sync pass." + ) + .font(.caption) + .foregroundColor(.secondary) + Button { + dismiss() + } label: { + Text("Done") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + case .failed(let message): + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Shield failed", systemImage: "xmark.octagon.fill") + .foregroundColor(.red) + .font(.headline) + Text(message) + .font(.callout) + .foregroundColor(.primary) + .textSelection(.enabled) + Button { + dismiss() + } label: { + Text("Dismiss") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding(.top, 4) + } + } + case .idle: + EmptyView() + } + } + @ViewBuilder private var recipientSection: some View { Section { @@ -351,6 +620,39 @@ struct ShieldedFundFromAssetLockView: View { // MARK: - Submit private func submit() { + // Platform branch: a single awaited `shieldedShield(...)` that + // moves `credits` from the chosen Platform Payment account into + // this wallet's own shielded pool (account 0). No recipient, + // no asset-lock controller/coordinator — the Halo 2 proof runs + // inside the call. Mirrors the `SendViewModel` self-shield path. + if resumeFromLock == nil && source == .platformBalance { + guard + let paymentAccount = platformAccountIndex, + let credits = platformAmountCredits + else { return } + let signer = KeychainSigner(modelContainer: modelContext.container) + let manager = walletManager + let walletId = wallet.walletId + platformShieldPhase = .inFlight + Task { + do { + try await manager.shieldedShield( + walletId: walletId, + shieldedAccount: 0, + paymentAccount: paymentAccount, + amount: credits, + addressSigner: signer + ) + await MainActor.run { platformShieldPhase = .completed } + } catch { + await MainActor.run { + platformShieldPhase = .failed(error.localizedDescription) + } + } + } + return + } + guard let recipient = recipientRaw43 else { return } let walletId = wallet.walletId @@ -447,6 +749,49 @@ struct ShieldedFundFromAssetLockView: View { let balanceDuffs: UInt64 } + private struct PlatformAccountOption { + let accountIndex: UInt32 + let totalCredits: UInt64 + } + + /// DIP-17 Platform Payment accounts on this wallet with their + /// summed credit balance. Mirrors + /// `FundFromAssetLockPlatformAddressView.platformAccountOptions`: + /// `accountType == 14` is the PlatformPayment discriminant; per- + /// account credits are summed from the wallet's platform addresses. + private var platformAccountOptions: [PlatformAccountOption] { + let accounts = allAccounts + .filter { $0.wallet.walletId == wallet.walletId } + .filter { $0.accountType == 14 } + .sorted { $0.accountIndex < $1.accountIndex } + return accounts.map { acct in + let total = allPlatformAddresses + .filter { + $0.walletId == wallet.walletId && $0.accountIndex == acct.accountIndex + } + .reduce(into: UInt64(0)) { acc, addr in acc &+= addr.balance } + return PlatformAccountOption(accountIndex: acct.accountIndex, totalCredits: total) + } + } + + /// Credit balance of the currently-selected Platform Payment + /// account, or `0` if none is selected. Gates `canSubmit` on the + /// Platform branch (`creditsWanted <= balance`). + private var selectedPlatformAccountCredits: UInt64 { + guard let idx = platformAccountIndex else { return 0 } + return platformAccountOptions.first(where: { $0.accountIndex == idx })?.totalCredits ?? 0 + } + + /// Platform branch: the DASH the user typed converted to credits + /// (1e11/DASH). Distinct from `parsedDuffs` (Core, 1e8/DASH). + private var platformAmountCredits: UInt64? { + let raw = platformAmountDash.trimmingCharacters(in: .whitespacesAndNewlines) + guard let dash = Double(raw), dash > 0 else { return nil } + let creditsDouble = dash * Double(Self.creditsPerDash) + guard creditsDouble.isFinite, creditsDouble <= Double(UInt64.max) else { return nil } + return UInt64(creditsDouble.rounded(.toNearestOrAwayFromZero)) + } + private var coreAccountOptions: [CoreAccountOption] { walletManager.accountBalances(for: wallet.walletId) .filter { $0.typeTag == 0 && $0.standardTag == 0 && $0.confirmed > 0 } @@ -479,6 +824,18 @@ struct ShieldedFundFromAssetLockView: View { if resumeFromLock != nil { return recipientRaw43 != nil && activeController == nil } + // Platform branch: a chosen account, an idle phase (and no + // Core controller running), a positive credit amount that + // the account can cover. No min-floor — the Platform credits + // already exist, unlike the L1 asset-lock floor. + if source == .platformBalance { + let credits = platformAmountCredits ?? 0 + return platformAccountIndex != nil + && platformShieldPhase == .idle + && activeController == nil + && credits > 0 + && credits <= selectedPlatformAccountCredits + } let amount = parsedDuffs ?? 0 return fundingCoreAccountIndex != nil && recipientRaw43 != nil @@ -495,6 +852,14 @@ struct ShieldedFundFromAssetLockView: View { .first { $0.balanceDuffs > 0 }?.accountIndex ?? coreAccountOptions.first?.accountIndex } + // Platform branch: default to the first Platform Payment + // account (prefer one with a credit balance), mirroring the + // Core auto-select. Cheap even when the Core source is active. + if platformAccountIndex == nil { + platformAccountIndex = platformAccountOptions + .first { $0.totalCredits > 0 }?.accountIndex + ?? platformAccountOptions.first?.accountIndex + } if recipientRaw43 == nil { // Default to the wallet's own bound shielded address — // the natural demo case ("shield to self"). The lookup @@ -539,6 +904,13 @@ struct ShieldedFundFromAssetLockView: View { return String(format: "%.8f DASH", dash) } + /// Credit divisor matches `FundFromAssetLockPlatformAddressView` + /// / `CreateIdentityView` (1e11 credits/DASH). + private func formatCredits(_ credits: UInt64) -> String { + let dash = Double(credits) / 100_000_000_000.0 + return String(format: "%.6f DASH (credits)", dash) + } + private func hexShort(_ data: Data) -> String { let hex = data.map { String(format: "%02x", $0) }.joined() if hex.count <= 16 { return hex }