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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,22 @@ public final class PersistentShieldedNote {
self.lastUpdated = now
}
}

public extension PersistentShieldedNote {
/// Predicate for one wallet's unspent (spendable) notes — the
/// rows whose `value` sum IS that wallet's shielded balance.
/// Single home for the filter so every balance surface agrees on
/// what "unspent" means.
static func unspentPredicate(walletId: Data) -> Predicate<PersistentShieldedNote> {
#Predicate<PersistentShieldedNote> {
$0.walletId == walletId && $0.isSpent == false
}
}

/// Predicate for ALL wallets' unspent notes; callers scope by
/// walletId in memory (multi-wallet surfaces like the identity
/// funding picker).
static var unspentPredicate: Predicate<PersistentShieldedNote> {
#Predicate<PersistentShieldedNote> { $0.isSpent == false }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// PlatformWalletManager+ShieldedDisplay.swift
// SwiftExampleApp
//
// App-side display convenience over the per-wallet shielded engine
// lookup. Kept in the app (not the SDK) because it composes two SDK
// calls for UI display; the SDK stays persist/load/bridge-only.

import Foundation
import SwiftDashSDK

extension PlatformWalletManager {
/// Bech32m-encoded default Orchard payment address for `walletId`
/// (`account`), or `nil` when the wallet has no engine-bound
/// shielded sub-wallet yet (or the wallet is unknown to this
/// manager). Single home for the resolve-then-encode pattern the
/// per-wallet shielded surfaces share.
func shieldedDisplayAddress(
walletId: Data,
account: UInt32 = 0,
network: Network
) -> String? {
guard let raw = try? shieldedDefaultAddress(
walletId: walletId,
account: account
) else { return nil }
return DashAddress.encodeOrchard(rawBytes: raw, network: network)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// ShieldedEngineBindPlan.swift
// SwiftExampleApp
//
// Pure iteration seam for the multi-wallet shielded engine-bind that
// `SwiftExampleAppApp.rebindWalletScopedServices()` performs. Extracted
// so the "engine-bind every OTHER loaded wallet" contract can be
// unit-tested without a configured `PlatformWalletManager` (whose
// `bindEngine` path calls into FFI and needs a live handle).

import Foundation

/// Invoke `bindEngine` once for every wallet id in `allWalletIds`
/// EXCEPT `mirrorWalletId` (the app-level `firstWallet`, which is
/// engine-bound separately via `ShieldedService.bind(...)` when the UI
/// mirror is attached).
///
/// The per-id closure is best-effort and independent: it must not
/// throw (each `ShieldedService.bindEngine` already swallows its own
/// errors), but even if a caller passes a throwing closure — as the
/// tests do to simulate one wallet's missing mnemonic — a failure for
/// one id must NOT stop the remaining ids from being bound. Each thrown
/// error is caught and dropped so the loop always visits every
/// non-mirror wallet.
///
/// Order is not significant to the coordinator (it iterates
/// registrations each sync tick), so the caller may pass ids in any
/// order.
///
/// `@MainActor`-isolated because the only caller
/// (`rebindWalletScopedServices`) is, and the `bindEngine` closure it
/// passes touches `@MainActor` `ShieldedService` state — keeping the
/// helper on the main actor lets that call be synchronous with no
/// actor hop under Swift 6 strict concurrency.
@MainActor
func engineBindOtherWallets(
allWalletIds: some Sequence<Data>,
mirrorWalletId: Data,
bindEngine: (Data) throws -> Void
) {
for walletId in allWalletIds where walletId != mirrorWalletId {
// Independent + best-effort: one id's failure can't block the
// rest. `ShieldedService.bindEngine` never throws in production;
// the `try?`-style catch here is belt-and-braces for the test
// seam and any future throwing binder.
try? bindEngine(walletId)
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
// ShieldedService.swift
// SwiftExampleApp
//
// Display-state surface for the Rust-owned shielded (Orchard) sync
// coordinator. The service binds to a single wallet, subscribes to
// the platform-wallet manager's shielded sync events, and exposes
// `@Published` properties for the UI. It does not own any of the
// shielded crypto: bind, sync, and persistence all live on the Rust
// `platform-wallet` side.
// Single UI mirror + multi-engine-bind for the Rust-owned shielded
// (Orchard) sync coordinator.
//
// The service mirrors exactly ONE wallet — the app-level
// `firstWallet` — for the GLOBAL Sync-status surface: `bind(...)`
// attaches the published mirror (`boundWalletId`, `shieldedBalance`,
// subscriptions, timing) to that wallet and drives the Sync tab. It
// does not own any of the shielded crypto: bind, sync, and
// persistence all live on the Rust `platform-wallet` side.
//
// `bindEngine(...)` is the additive companion used by
// `rebindWalletScopedServices()` to engine-register EVERY OTHER
// loaded wallet into the same network-scoped coordinator (no mirror
// repoint). A single shielded sync pass then trial-decrypts against
// the union of all wallets' viewing keys and routes note hits to each
// wallet's own persister (SH-14/15/16 cross-wallet flows). Per-wallet
// receive addresses and balances are read on demand
// (`walletManager.shieldedDefaultAddress(walletId:)`,
// `PersistentShieldedNote` rows) rather than from this singleton
// mirror.

import Foundation
import SwiftUI
Expand Down Expand Up @@ -353,6 +367,71 @@ class ShieldedService: ObservableObject {
}
}

/// Register `walletId`'s shielded sub-wallet with the Rust
/// coordinator WITHOUT repointing this service's display mirror.
///
/// `bind(...)` attaches the single UI mirror (boundWalletId,
/// shieldedBalance, subscriptions, …) to exactly one wallet — the
/// app-level `firstWallet`. `bindEngine(...)` is the additive
/// companion: it engine-binds EVERY OTHER loaded wallet into the
/// same network-scoped coordinator so a single shielded sync pass
/// trial-decrypts against the union of all wallets' viewing keys and
/// routes note hits to each wallet's own persister. Per-wallet
/// receive addresses and balances are then read on demand
/// (`walletManager.shieldedDefaultAddress(walletId:)`,
/// `PersistentShieldedNote` rows) rather than from this singleton
/// mirror.
///
/// Best-effort and independent per wallet: a missing mnemonic /
/// declined resolver for one wallet logs and returns without
/// affecting the others or the mirror. Idempotent — safe to call
/// every rebind pass (`configureShielded` no-ops on the same path;
/// `bindShielded` replaces that wallet's registration).
///
/// Returns whether the engine registration succeeded; existing
/// callers may ignore it.
@discardableResult
func bindEngine(
walletManager: PlatformWalletManager,
walletId: Data,
network: Network,
resolver: MnemonicResolver,
accounts: [UInt32] = [0]
) -> Bool {
let dbPath = Self.dbPath(for: network)
let sortedAccounts = Array(Set(accounts)).sorted()

// No "already bound" fast path on purpose: the only cheap probe,
// `shieldedDefaultAddress`, reflects the wallet-level sub-wallet
// binding — which SURVIVES `clearShielded` (Clear drops only the
// coordinator registrations; there is no sub-wallet unbind FFI).
// Skipping on that signal would silently leave post-Clear wallets
// unregistered (sync passes would never scan them again). Coordinator
// registration has no cheap query, so we always re-bind; the
// mnemonic read + ZIP-32 re-derivation is low-millisecond per wallet
// and rebind fires are rare (wallet-set change, network switch,
// Sync Now).
do {
try walletManager.configureShielded(dbPath: dbPath)
try walletManager.bindShielded(
walletId: walletId,
resolver: resolver,
accounts: sortedAccounts
)
SDKLogger.log(
"Shielded engine-bound: walletId=\(walletId.prefix(4).map { String(format: "%02x", $0) }.joined())… network=\(network.networkName) accounts=\(sortedAccounts)",
minimumLevel: .medium
)
return true
} catch {
SDKLogger.log(
"Shielded engine-bind failed for walletId=\(walletId.prefix(4).map { String(format: "%02x", $0) }.joined())…: \(error.localizedDescription)",
minimumLevel: .medium
)
return false
}
}

/// Re-bind the singleton service to a different wallet using the
/// `walletManager` / `resolver` / `network` stashed by the first
/// `bind(...)`. Per-detail-view code paths call this when the
Expand Down Expand Up @@ -421,12 +500,47 @@ class ShieldedService: ObservableObject {
resolver: resolver,
accounts: accounts
)
// `bind` is best-effort; if it failed (e.g. the
// mnemonic resolver was declined), `isBound` stays
// false and `lastError` is populated. Bail rather
// than chain a sync that will fail the same way.
guard isBound else { return }
// Mirror bind is best-effort — on failure `lastError` is
// already populated by `bind(...)`, and we still run the
// engine pass below so other wallets with intact mnemonics
// re-register (a mirror-only failure must not dark the whole
// fleet).
}

// Re-register any loaded wallet that lost its engine binding — see
// prior comment (post-Clear recovery): `clearShielded` drops EVERY
// wallet, and a detail-view `switchTo` in between re-binds only the
// wallet being viewed, so the recovery branch above may not even
// run. This runs on every Sync Now: with no cheap
// coordinator-registration probe (see `bindEngine`), each pass
// re-derives every other wallet's keys (low-millisecond per wallet)
// — the price of correct post-Clear re-registration. Track whether
// ANYTHING is registered: if the mirror bind failed AND no other
// wallet bound, a sync pass would skip every wallet and produce a
// meaningless result over the bind error the user needs to see.
var anyWalletRegistered = isBound
if let mirrorWalletId = boundWalletId, let resolver, let network {
engineBindOtherWallets(
allWalletIds: walletManager.wallets.keys,
mirrorWalletId: mirrorWalletId
) { otherWalletId in
if bindEngine(
walletManager: walletManager,
walletId: otherWalletId,
network: network,
resolver: resolver
) {
anyWalletRegistered = true
}
}
}
// Nothing registered (mirror failed + every other bind failed, or no
// bind credentials at all — the Sync Now button is disabled when
// `!canResume`, so this mainly covers the all-binds-failed case):
// bail rather than chain a sync that would skip every wallet —
// preserves the pre-existing "don't chain a sync that will fail the
// same way" intent, per-wallet-ized.
guard anyWalletRegistered else { return }

isSyncing = true
lastError = nil
Expand Down Expand Up @@ -601,6 +715,34 @@ class ShieldedService: ObservableObject {
// registries and the next sync re-saves notes via
// the changeset path. Best-effort — failure logs but
// doesn't abort the wipe.
//
// Re-binding scope after Clear: `clearShielded` drops
// EVERY wallet (not just the mirror's `firstWallet`)
// from the coordinator. "Sync Now" (`manualSync()`)
// UNCONDITIONALLY re-registers EVERY loaded wallet on each
// tap: the mirror wallet via `bind(...)` (in the recovery
// branch, only when unbound), and every OTHER loaded wallet
// via a `engineBindOtherWallets` / `bindEngine` pass that
// runs on every Sync Now regardless of the recovery branch.
// That unconditional pass matters because a detail-view
// `switchTo` between Clear and Sync Now re-binds only the
// viewed wallet (flipping `isBound` true and skipping the
// recovery branch), which would otherwise leave the other
// wallets engine-unregistered. The pass is also best-effort
// across the mirror: a mirror-bind FAILURE (missing mnemonic
// / declined resolver) no longer bails Sync Now — the engine
// pass still runs so every OTHER wallet with an intact
// mnemonic re-registers, and Sync Now only bails when NOTHING
// registered (mirror + every other bind failed). So
// cross-wallet shielded flows (SH-14/15/16) come back
// immediately on the first post-Clear Sync Now, not only on
// the next `rebindWalletScopedServices()` fire.
// `rebindWalletScopedServices` remains the recovery path for
// wallets loaded LATER (a wallet added after the Clear isn't
// in the manager's set at Sync-Now time); it re-`bindEngine`s
// every wallet on any wallet-set change or network switch. We
// keep the WIPE scope global on purpose (see the class-level
// doc below) — this note is about the re-BIND scope.
if let managerForStop {
do {
try managerForStop.clearShielded()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -725,9 +725,15 @@ class SendViewModel: ObservableObject {
// to self-shield only.
let enteredRecipient = recipientAddress
.trimmingCharacters(in: .whitespacesAndNewlines)
let ownShieldedAddress =
shieldedService.addressesByAccount[0]
?? shieldedService.orchardDisplayAddress
// Resolve THIS wallet's own default Orchard address from
// the engine rather than the single-mirror
// `shieldedService` (which tracks `firstWallet`). Every
// loaded wallet is engine-bound, so `shieldedDefaultAddress`
// resolves for the wallet actually being sent from.
let ownShieldedAddress = walletManager.shieldedDisplayAddress(
walletId: wallet.walletId,
network: network
)
if !enteredRecipient.isEmpty,
enteredRecipient != ownShieldedAddress {
// Don't advertise "leave it blank": a blank recipient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import SwiftData
struct AccountListView: View {
let wallet: PersistentWallet
@EnvironmentObject var walletManager: PlatformWalletManager
@EnvironmentObject var shieldedService: ShieldedService
@EnvironmentObject var platformState: AppState

@Query private var accounts: [PersistentAccount]

Expand Down Expand Up @@ -65,16 +65,26 @@ struct AccountListView: View {
}

/// Bound shielded accounts to render in their own section
/// below the Core / Platform accounts. Empty until
/// `ShieldedService.bind` has populated the list — which
/// happens once per wallet detail open.
/// below the Core / Platform accounts. Empty until this wallet's
/// engine binding lands (`rebindWalletScopedServices`).
private var shieldedAccountsForThisWallet: [UInt32] {
// Gate on the service's currently-bound wallet id so navigating
// between wallet details doesn't briefly show the *previous*
// wallet's shielded accounts before the singleton service
// finishes rebinding to this wallet.
guard shieldedService.boundWalletId == wallet.walletId else { return [] }
return shieldedService.boundAccounts
// Engine-bound wallets expose account 0 by default. Resolve
// per-wallet from the engine (via `shieldedAddress(for:)`) rather
// than the single UI mirror so the section shows for ANY loaded
// wallet, not just `firstWallet`.
shieldedAddress(for: 0) != nil ? [0] : []
}

/// Bech32m Orchard receive address for `account` on the viewed
/// wallet, resolved per-wallet from the engine (rather than the
/// single UI mirror's `addressesByAccount`). `nil` until this
/// wallet's bind lands or if encoding fails.
private func shieldedAddress(for account: UInt32) -> String? {
walletManager.shieldedDisplayAddress(
walletId: wallet.walletId,
account: account,
network: platformState.currentNetwork
)
}

var body: some View {
Expand Down Expand Up @@ -118,7 +128,7 @@ struct AccountListView: View {
ForEach(shieldedAccountsForThisWallet, id: \.self) { account in
ShieldedAccountRowView(
accountIndex: account,
address: shieldedService.addressesByAccount[account]
address: shieldedAddress(for: account)
)
}
}
Expand Down
Loading
Loading