From 49419478dbdafccb4f4066c60b9dbb499b37b7ae Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 28 Apr 2026 20:53:43 +0800 Subject: [PATCH 1/4] perf(swift-example-app): trim hydration on per-wallet TransactionListView Restrict the @Query to the columns TransactionRowView actually reads and key List rows on PersistentIdentifier instead of txid. Prevents the navigation push from stalling on wallets with ~1.5k transactions while SwiftData faulted in transactionData blobs for every match on the main thread. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/TransactionListView.swift | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index 487e72a6e8..7121b738f7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -16,12 +16,30 @@ struct TransactionListView: View { // SwiftData's predicate compiler can't lower a double // optional-relationship chain to SQLite and crashes with // `Unsupported function expression TERNARY(...).walletId`. - _transactions = Query( - filter: #Predicate { tx in - tx.walletId == walletId - }, - sort: [SortDescriptor(\PersistentTransaction.firstSeen, order: .reverse)] + // + // `propertiesToFetch` matters: without it, a wallet with + // ~1.5k transactions stalls the navigation push for several + // seconds because SwiftData hydrates the full row — including + // the `transactionData` raw-bytes blob — for every match on + // the main thread before the List can render. Restricting + // the fetch to the columns the row actually reads + // (`TransactionRowView` only touches txid / netAmount / + // firstSeen / context / fee, plus walletId for the predicate) + // keeps SQLite on an index-only scan against the + // `(walletId, firstSeen)` compound index and skips the blob. + var descriptor = FetchDescriptor( + predicate: #Predicate { tx in tx.walletId == walletId }, + sortBy: [SortDescriptor(\PersistentTransaction.firstSeen, order: .reverse)] ) + descriptor.propertiesToFetch = [ + \.txid, + \.netAmount, + \.firstSeen, + \.context, + \.fee, + \.walletId, + ] + _transactions = Query(descriptor) } var body: some View { @@ -58,15 +76,19 @@ struct TransactionListView: View { } private var transactionsList: some View { - List { - ForEach(transactions, id: \.txid) { transaction in - Button { - selectedTransaction = transaction - } label: { - TransactionRowView(transaction: transaction) - } - .buttonStyle(.plain) + // Use SwiftData's built-in PersistentIdentifier as the row + // identity (via `List(transactions)`) instead of `id: \.txid`. + // The Storage Explorer's transaction list uses the same shape + // and renders instantly; keying on `txid` forces SwiftUI to + // read the unique-string column from every row for diffing + // even when SwiftData already has a stable identity for free. + List(transactions) { transaction in + Button { + selectedTransaction = transaction + } label: { + TransactionRowView(transaction: transaction) } + .buttonStyle(.plain) } .listStyle(.insetGrouped) } From 5d549c7411bee6682e58516d4dc9283a0d10c31e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 29 Apr 2026 00:18:36 +0800 Subject: [PATCH 2/4] refactor(swift-sdk): redesign Persistent* tx schema and fix per-wallet tx push stall Restructures the Core SwiftData persistence around a single shared transaction record (multi-account, multi-wallet) and replaces the broken per-wallet TransactionListView push. Schema changes (all under DashSchemaV1, dev stores get rebuilt): - PersistentTransaction.txid: String hex -> raw 32-byte Data; drops walletId and account fields (a tx can land in several accounts/wallets); renames `utxos` -> `outputs`; adds inputs relationship paired with PersistentTxo.spendingTransaction. - PersistentUtxo renamed to PersistentTxo (it represents any output, spent or unspent). outpoint switched to raw 36-byte Data, gains walletId denorm, coreAddress link, and spendingTransaction back- reference. - PersistentCoreAddress gains txos: [PersistentTxo] reverse relationship (.nullify) so detail views can navigate to the outputs at an address; persistence handler now links the TXO to its address row on upsert. - PersistentAccount drops the cascade-owned outputs collection (per-account TXOs derive through coreAddresses.flatMap(\.txos)); wallet tightened from optional to non-optional; isWatchOnly removed (FFI-side state, never persisted). - PersistentWallet drops isWatchOnly for the same reason. - PlatformWalletPersistenceHandler updated for every site touched by the above. Storage Explorer updates to match the new graph: TXO detail shows the canonical coreAddress?.account path with the one-way account field as fallback, plus Created By / Spent By rows. Core address detail surfaces txos.count. Account detail derives distinct-tx count via coreAddresses.flatMap(\.txos) (transactions are no longer account-scoped, so the count must be computed in Swift). Per-wallet "View All Transactions" push was stalling on iOS 26 even on an empty wallet. Root cause was closure-based NavigationLink { Destination } re-running the destination's init (and its @Query registration) on every parent body invocation, which fired hundreds of times during sync. Switched both pushes in the wallets stack to value-based navigation: NavigationLink(value:) plus two .navigationDestination(for:) modifiers on WalletsContentView (the stack root). Mixing paradigms (closure outer + value inner) was producing animate-then-pop, so both layers go value-based now. TransactionListView now queries PersistentTransaction directly with a relationship-traversal predicate (tx.outputs.contains { \$0.walletId == walletId } || tx.inputs.contains { ... }), sorted in SQLite, no Swift dedupe and no fault chain. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Persistence/DashModelContainer.swift | 39 ++++- .../Models/PersistentAccount.swift | 41 ++--- .../Models/PersistentCoreAddress.swift | 7 + .../Models/PersistentTransaction.swift | 78 +++++---- .../Persistence/Models/PersistentTxo.swift | 162 ++++++++++++++++++ .../Persistence/Models/PersistentUtxo.swift | 85 --------- .../Persistence/Models/PersistentWallet.swift | 8 - .../PlatformWalletPersistenceHandler.swift | 118 ++++++++----- .../Core/Views/AccountDetailView.swift | 45 +++-- .../Core/Views/AccountListView.swift | 2 +- .../Core/Views/CoreContentView.swift | 6 - .../Core/Views/ReceiveAddressView.swift | 2 +- .../Core/Views/TransactionDetailView.swift | 4 +- .../Core/Views/TransactionListView.swift | 61 +++---- .../Core/Views/WalletDetailView.swift | 53 ++++-- .../Core/Views/WalletsContentView.swift | 19 +- .../Views/CreateIdentityView.swift | 4 +- .../Views/StorageExplorerView.swift | 6 +- .../Views/StorageModelListViews.swift | 43 +++-- .../Views/StorageRecordDetailViews.swift | 81 +++++++-- .../Views/TopUpIdentityView.swift | 2 +- 21 files changed, 565 insertions(+), 301 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift delete mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentUtxo.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index e20ac770c1..a4e7551ad4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -23,7 +23,7 @@ public enum DashModelContainer { PersistentAccount.self, PersistentCoreAddress.self, PersistentTransaction.self, - PersistentUtxo.self, + PersistentTxo.self, PersistentWalletManagerMetadata.self ] } @@ -92,6 +92,43 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// Includes `PersistentCoreAddress` to match the example app's former container schema. /// The model is additive with optional relationships, so existing narrower stores can /// use SwiftData's lightweight migration path. +/// +/// Note: this V1 identifier has accumulated several destructive +/// dev-only changes that cannot be expressed via the lightweight +/// migration path: +/// - `PersistentTransaction.txid` and the renamed +/// `PersistentTxo.outpoint` switched from `String` to raw `Data` +/// (unique-attribute retype). +/// - The `PersistentUtxo` model was renamed to `PersistentTxo`, +/// gained `walletId` + `spendingTransaction`, and the schema +/// topology shifted: `PersistentTransaction` lost both +/// `walletId` and `account` and now hangs on transactions purely +/// through the `outputs` / `inputs` TXO relationships. +/// - `PersistentAccount.outputs` (the cascade-owned +/// `[PersistentTxo]` collection paired with +/// `PersistentTxo.account`) was removed. Per-account TXOs are +/// now derived through `coreAddresses.flatMap(\.txos)` — +/// `PersistentTxo.account` survives as a one-way fallback +/// pointer with no inverse. Removing the inverse changes the +/// relationship topology for the underlying SQLite store, so +/// existing dev stores can't be opened with the new schema. +/// - `PersistentAccount.wallet` was tightened from +/// `PersistentWallet?` to non-optional `PersistentWallet`. Every +/// account currently belongs to a wallet; the type system now +/// reflects that invariant. Switching the optionality of a +/// relationship column rewrites the SQLite schema, so existing +/// dev stores can't be reused. +/// - `PersistentWallet.isWatchOnly` and +/// `PersistentAccount.isWatchOnly` were removed. The runtime +/// watch-only state lives on the native `Wallet` / +/// `ManagedAccount` (FFI-backed); persisting it on the SwiftData +/// side was redundant and the persister never wrote it. +/// Each of those is a destructive change to a unique-attribute +/// column or to relationship topology, so any pre-existing dev +/// store will fail to open and get rebuilt from scratch on next +/// sync. Bumping the version isn't useful without a real +/// `MigrationStage` (and there's nothing worth preserving in dev +/// databases at this point), so we let the container recreate. public enum DashSchemaV1: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(1, 0, 0) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift index 3fc4dd7ee6..05a08a3804 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift @@ -4,9 +4,18 @@ import SwiftData /// SwiftData model for persisting a wallet account. /// /// Each account represents an HD derivation path (BIP44, CoinJoin, -/// Identity, Platform Payment, etc.) with its own address pools, -/// transactions, and UTXOs. Cascade-deletes transactions and UTXOs -/// when the account is removed. +/// Identity, Platform Payment, etc.) with its own address pools. +/// +/// Note: an account does **not** own a list of transactions or TXOs +/// directly anymore. A single transaction can produce outputs into +/// several accounts (or even several wallets), and TXOs hang off the +/// per-address `PersistentCoreAddress.txos` collection so the +/// account ↔ TXO link flows naturally through the address pool. +/// Per-account TXOs are derived as +/// `coreAddresses.flatMap(\.txos)`; per-account transactions are +/// the union of those TXOs' `transaction` (creating tx) and +/// `spendingTransaction` (spending tx). Account scope flows +/// through addresses; nothing is denormalized on this side. @Model public final class PersistentAccount { /// Account type identifier — matches the `AccountTypeTagFFI` @@ -22,8 +31,6 @@ public final class PersistentAccount { public var accountIndex: UInt32 /// Human-readable account type name. public var accountTypeName: String - /// Whether this is a watch-only account. - public var isWatchOnly: Bool /// Per-account confirmed balance in duffs. public var balanceConfirmed: UInt64 /// Per-account unconfirmed balance in duffs. @@ -55,21 +62,17 @@ public final class PersistentAccount { public var createdAt: Date public var lastUpdated: Date - /// Parent wallet. - public var wallet: PersistentWallet? - - /// Transactions in this account. - @Relationship(deleteRule: .cascade, inverse: \PersistentTransaction.account) - public var transactions: [PersistentTransaction] - - /// Unspent transaction outputs in this account. - @Relationship(deleteRule: .cascade, inverse: \PersistentUtxo.account) - public var utxos: [PersistentUtxo] + /// Parent wallet. Every account currently belongs to a wallet. If + /// standalone non-wallet accounts are introduced later, this + /// becomes optional again. + public var wallet: PersistentWallet /// Addresses from this account's address pools (external + /// internal, or a single Absent pool for degenerate types). Holds /// Core-chain (base58check) addresses only — PlatformPayment /// accounts keep their addresses in `platformAddresses`. + /// Per-account TXOs flow through this collection + /// (`coreAddresses.flatMap(\.txos)`). @Relationship(deleteRule: .cascade, inverse: \PersistentCoreAddress.account) public var coreAddresses: [PersistentCoreAddress] @@ -80,15 +83,15 @@ public final class PersistentAccount { public var platformAddresses: [PersistentPlatformAddress] public init( + wallet: PersistentWallet, accountType: UInt32, accountIndex: UInt32, - accountTypeName: String, - isWatchOnly: Bool = false + accountTypeName: String ) { + self.wallet = wallet self.accountType = accountType self.accountIndex = accountIndex self.accountTypeName = accountTypeName - self.isWatchOnly = isWatchOnly self.balanceConfirmed = 0 self.balanceUnconfirmed = 0 self.externalHighestUsed = -1 @@ -101,8 +104,6 @@ public final class PersistentAccount { self.accountExtendedPubKeyBytes = Data() self.createdAt = Date() self.lastUpdated = Date() - self.transactions = [] - self.utxos = [] self.coreAddresses = [] self.platformAddresses = [] } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift index c83f31f171..14ad954021 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift @@ -46,6 +46,13 @@ public final class PersistentCoreAddress { /// Parent account. public var account: PersistentAccount? + /// TXOs paid to this address. `.nullify` on delete so dropping + /// an address row (e.g. pool rebuild) doesn't take its + /// historical TXOs with it — `PersistentTxo.address` (the + /// Base58Check string) remains the authoritative identifier. + @Relationship(deleteRule: .nullify, inverse: \PersistentTxo.coreAddress) + public var txos: [PersistentTxo] = [] + public init( address: String, publicKey: Data = Data(), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index a4cd12cef5..3c79b5c19e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -5,20 +5,30 @@ import SwiftData /// /// Stores the full transaction record including context (mempool, /// confirmed, chain-locked), direction, amounts, and fee. +/// +/// A transaction is intentionally **not** scoped to a single wallet +/// or account. The same on-chain tx can have outputs into account A +/// and account B inside one wallet (or — for cross-wallet flows — +/// into accounts under different wallets), and the transaction row +/// is shared across all of them. Per-wallet membership is recovered +/// by joining through the TXOs (`outputs` + `inputs`); see +/// `PersistentTxo.walletId` for the per-row denorm that makes those +/// joins index-friendly. @Model public final class PersistentTransaction { - /// Compound index covering `TransactionListView`'s per-wallet - /// query: `walletId == ?` predicate + `firstSeen` descending - /// sort. Putting `walletId` first lets SQLite descend the - /// index straight to the matching segment; the trailing - /// `firstSeen` column delivers the sort order for free. Without - /// this index the filter degrades to a full-table scan and the - /// sort to an in-memory O(N log N) pass, both on the main - /// thread. - #Index([\.walletId, \.firstSeen]) + /// Index on `firstSeen` so per-wallet queries — which fetch + /// `PersistentTxo` rows by `walletId` then sort their parent + /// transactions by `firstSeen` — get a sorted scan instead of + /// an in-memory O(N log N) pass. The unique `txid` index covers + /// point-lookups; this one covers the timeline. + #Index([\.firstSeen]) - /// Transaction ID (32-byte hash, stored as hex for indexing). - @Attribute(.unique) public var txid: String + /// Transaction ID (32-byte hash, raw little-endian wire bytes — + /// the same orientation Rust hands us via the FFI `[u8; 32]`). + /// Stored as raw `Data` so the unique index covers 32 bytes + /// instead of a 64-char hex string, and the persistence + /// handler avoids a hex round-trip on every write. + @Attribute(.unique) public var txid: Data /// Raw transaction bytes. public var transactionData: Data? /// Context: 0=mempool, 1=instantSend, 2=inBlock, 3=inChainLockedBlock. @@ -45,32 +55,29 @@ public final class PersistentTransaction { public var createdAt: Date public var lastUpdated: Date - /// 32-byte wallet ID that owns this transaction. Denormalized - /// from `account?.wallet?.walletId` so per-wallet `@Query` - /// predicates can filter with a single equality check instead - /// of chaining two optional relationships — SwiftData's - /// predicate compiler can't translate that nested chain into - /// SQLite and crashes with - /// `Unsupported function expression TERNARY(...).walletId`. - /// Empty `Data()` for rows migrated from older schema; the - /// next sync pass will populate it. - public var walletId: Data = Data() - - /// Parent account. - public var account: PersistentAccount? - - /// UTXOs created by this transaction's outputs. + /// Transaction outputs created by this transaction. /// - /// Cascade-deletes the matching `PersistentUtxo` rows when the - /// transaction is removed — UTXOs cannot meaningfully exist + /// Cascade-deletes the matching `PersistentTxo` rows when the + /// transaction is removed — outputs cannot meaningfully exist /// without their containing transaction (the outpoint, script, /// amount, and address are all derived from it). - @Relationship(deleteRule: .cascade, inverse: \PersistentUtxo.transaction) - public var utxos: [PersistentUtxo] = [] + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.transaction) + public var outputs: [PersistentTxo] = [] + + /// Transaction outputs spent *by* this transaction. + /// + /// Inverse of `PersistentTxo.spendingTransaction`. Default + /// `.nullify` delete rule (do not pass `.cascade`!) — those TXOs + /// are owned by their *creating* transaction, not this one. + /// Cascading from the spending side would let a recent tx wipe + /// outputs of an older tx on delete: a data-loss bug. Removing + /// this transaction merely detaches the spend-link and the TXOs + /// flip back to "unspent" until something else claims them. + @Relationship(inverse: \PersistentTxo.spendingTransaction) + public var inputs: [PersistentTxo] = [] public init( - txid: String, - walletId: Data = Data(), + txid: Data, context: UInt32 = 0, blockHeight: UInt32 = 0, direction: UInt32 = 0, @@ -79,7 +86,6 @@ public final class PersistentTransaction { firstSeen: UInt64 = 0 ) { self.txid = txid - self.walletId = walletId self.context = context self.blockHeight = blockHeight self.blockTimestamp = 0 @@ -94,6 +100,12 @@ public final class PersistentTransaction { // MARK: - Display Helpers + /// Hex-encoded txid for UI / log sites. The on-disk row stores + /// raw bytes; this is computed on read. + public var txidHex: String { + txid.map { String(format: "%02x", $0) }.joined() + } + public var contextName: String { switch context { case 0: return "Mempool" diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift new file mode 100644 index 0000000000..3b7171932a --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift @@ -0,0 +1,162 @@ +import Foundation +import SwiftData + +/// SwiftData model for persisting a transaction output (spent or +/// unspent). +/// +/// Each row represents a single TXO produced by some `PersistentTransaction` +/// (`transaction`). When the TXO is later spent, the spending tx is +/// linked via `spendingTransaction` and `isSpent` flips to `true` — +/// the row is kept (rather than deleted) so the wallet's history +/// stays whole. +@Model +public final class PersistentTxo { + /// Outpoint: 36 raw bytes (32-byte txid in wire orientation + + /// 4-byte vout little-endian) — the standard Bitcoin outpoint + /// serialization. Unique identifier stored explicitly so + /// SwiftData predicate fetches can hit a single column without + /// traversing the `transaction` relationship. Always equals + /// `PersistentTxo.makeOutpoint(txid: transaction.txid, vout: vout)`. + @Attribute(.unique) public var outpoint: Data + /// Output index within the transaction. + public var vout: UInt32 + /// Value in duffs. + public var amount: UInt64 + /// Owning address (Base58Check). + public var address: String + /// Script pubkey bytes. + public var scriptPubKey: Data + /// Block height where created. + public var height: UInt32 + /// Whether this is a coinbase output. + public var isCoinbase: Bool + /// Whether confirmed in a block. + public var isConfirmed: Bool + /// Whether locked by InstantSend. + public var isInstantLocked: Bool + /// Whether reserved/locked for a specific purpose. + public var isLocked: Bool + /// Whether this TXO has been spent. + /// + /// Denormalized: should track `spendingTransaction != nil`. Kept + /// as an explicit column because per-row spent/unspent filters + /// are a hot query path, and chasing the optional relationship + /// in a predicate drops SwiftData onto the same nested-optional + /// codepath that crashes elsewhere. The persistence handler is + /// responsible for keeping the two in sync; do not enforce + /// invariants here. + public var isSpent: Bool + /// Record timestamps. + public var createdAt: Date + public var lastUpdated: Date + + /// 32-byte wallet ID this TXO belongs to. Denormalized from + /// `account?.wallet.walletId` so per-wallet `@Query` predicates + /// can filter with a single equality check instead of chaining + /// through the optional `account` relationship — SwiftData's + /// predicate compiler can't translate that chain into SQLite and + /// crashes with `Unsupported function expression TERNARY(...).walletId`. + /// This is the single column callers filter on for "show every + /// TXO (and, by union of `transaction` + `spendingTransaction`, + /// every transaction) that touches wallet W". Empty `Data()` for + /// rows migrated from older schema; the next sync pass will + /// populate it. + public var walletId: Data = Data() + + /// Containing transaction (the one that *created* this output). + /// Cascade-deleted from the parent side (see + /// `PersistentTransaction.outputs`). Optional only because the + /// underlying SwiftData inverse must allow nil during the brief + /// window between row insert and relationship attachment; in + /// steady state every TXO has a non-nil `transaction`. + public var transaction: PersistentTransaction? + + /// The transaction that *spent* this output, or nil if the TXO + /// is unspent. Inverse of `PersistentTransaction.inputs`. Uses + /// the default `.nullify` delete rule from that side — deleting + /// the spending tx must not cascade-delete this row. + public var spendingTransaction: PersistentTransaction? + + /// Parent account. No longer paired with an inverse on the + /// account side — the canonical account path is + /// `coreAddress?.account`. This field is the fallback when the + /// address row isn't yet linked (out-of-order flush, address + /// pool rebuild, etc.). + public var account: PersistentAccount? + + /// Owning `PersistentCoreAddress` row, if it exists in the + /// account's address pool. Linked alongside `address` (the + /// Base58Check string) — the string is the authoritative + /// identifier and survives even when the address pool is rebuilt + /// or the TXO was paid to an address never in our pool (e.g. an + /// outgoing recipient). The relationship is the convenient + /// pointer for navigating to derivation metadata, balance, and + /// pool tag without a separate fetch. Inverse of + /// `PersistentCoreAddress.txos`; `.nullify` on that side so + /// pool rebuilds don't cascade-delete TXOs. + public var coreAddress: PersistentCoreAddress? + + public init( + transaction: PersistentTransaction, + vout: UInt32, + amount: UInt64, + address: String, + scriptPubKey: Data = Data(), + height: UInt32 = 0 + ) { + self.outpoint = Self.makeOutpoint(txid: transaction.txid, vout: vout) + self.vout = vout + self.amount = amount + self.address = address + self.scriptPubKey = scriptPubKey + self.height = height + self.isCoinbase = false + self.isConfirmed = false + self.isInstantLocked = false + self.isLocked = false + self.isSpent = false + self.createdAt = Date() + self.lastUpdated = Date() + self.transaction = transaction + } + + /// Build the 36-byte outpoint key (32-byte txid raw bytes + + /// 4-byte vout little-endian). Exposed so the persistence + /// handler can compose predicates / lookups directly from the + /// FFI's `[u8; 32]` + `u32` without going through string + /// formatting. + public static func makeOutpoint(txid: Data, vout: UInt32) -> Data { + var data = Data(capacity: 36) + data.append(txid) + var v = vout.littleEndian + withUnsafeBytes(of: &v) { data.append(contentsOf: $0) } + return data + } + + /// Convenience accessor for the containing transaction's txid + /// as raw 32-byte `Data`. Returns empty `Data()` if the + /// relationship isn't attached (which should only happen + /// briefly during construction). + public var txid: Data { + transaction?.txid ?? Data() + } + + /// Hex-encoded txid for UI / log sites. + public var txidHex: String { + txid.map { String(format: "%02x", $0) }.joined() + } + + /// Human-readable outpoint (`:`) for UI / log + /// sites. Reconstructs from the parent transaction's txid plus + /// `self.vout` rather than re-decoding the stored 36-byte blob, + /// which avoids one allocation and matches the legacy display + /// format. + public var outpointHex: String { + "\(txidHex):\(vout)" + } + + public var formattedAmount: String { + let dash = Double(amount) / 100_000_000.0 + return String(format: "%.8f DASH", dash) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentUtxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentUtxo.swift deleted file mode 100644 index 7f0f6846f5..0000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentUtxo.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation -import SwiftData - -/// SwiftData model for persisting an unspent transaction output. -/// -/// Represents a single UTXO that can be spent by the wallet. -/// Linked to its containing transaction (cascade-delete) and its -/// parent account. -@Model -public final class PersistentUtxo { - /// Outpoint: `txid hex + ":" + vout index` (unique identifier). - /// Stored explicitly so SwiftData predicate fetches can hit a - /// single column without traversing the `transaction` relationship. - /// Always equals `"\(transaction.txid):\(vout)"`. - @Attribute(.unique) public var outpoint: String - /// Output index within the transaction. - public var vout: UInt32 - /// Value in duffs. - public var amount: UInt64 - /// Owning address (Base58Check). - public var address: String - /// Script pubkey bytes. - public var scriptPubKey: Data - /// Block height where created. - public var height: UInt32 - /// Whether this is a coinbase output. - public var isCoinbase: Bool - /// Whether confirmed in a block. - public var isConfirmed: Bool - /// Whether locked by InstantSend. - public var isInstantLocked: Bool - /// Whether reserved/locked for a specific purpose. - public var isLocked: Bool - /// Whether this UTXO has been spent. - public var isSpent: Bool - /// Record timestamps. - public var createdAt: Date - public var lastUpdated: Date - - /// Containing transaction. Cascade-deleted from the parent side - /// (see `PersistentTransaction.utxos`). Optional only because the - /// underlying SwiftData inverse must allow nil during the brief - /// window between row insert and relationship attachment; in - /// steady state every UTXO has a non-nil `transaction`. - public var transaction: PersistentTransaction? - - /// Parent account. - public var account: PersistentAccount? - - public init( - transaction: PersistentTransaction, - vout: UInt32, - amount: UInt64, - address: String, - scriptPubKey: Data = Data(), - height: UInt32 = 0 - ) { - self.outpoint = "\(transaction.txid):\(vout)" - self.vout = vout - self.amount = amount - self.address = address - self.scriptPubKey = scriptPubKey - self.height = height - self.isCoinbase = false - self.isConfirmed = false - self.isInstantLocked = false - self.isLocked = false - self.isSpent = false - self.createdAt = Date() - self.lastUpdated = Date() - self.transaction = transaction - } - - /// Convenience accessor for the containing transaction's txid. - /// Returns the empty string if the relationship isn't attached - /// (which should only happen briefly during construction). - public var txid: String { - transaction?.txid ?? "" - } - - public var formattedAmount: String { - let dash = Double(amount) / 100_000_000.0 - return String(format: "%.8f DASH", dash) - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 54e0d4874b..948fe7a508 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -45,12 +45,6 @@ public final class PersistentWallet { public var balanceImmature: UInt64 /// Locked balance in duffs. public var balanceLocked: UInt64 - /// Wallet is spend-disabled — either bootstrapped watch-only - /// (no seed) or every account is watch-only. Surfaces as the - /// "👁 Watch-only" badge in the wallets list. Default `false` - /// keeps the schema migration trivial for rows that predate - /// this column. - public var isWatchOnly: Bool = false /// User imported this wallet from an existing mnemonic (as /// opposed to generating a fresh one). Cosmetic flag that /// drives the "📥 Imported" badge; defaulted to `false` for @@ -82,7 +76,6 @@ public final class PersistentWallet { name: String? = nil, birthHeight: UInt32 = 0, syncedHeight: UInt32 = 0, - isWatchOnly: Bool = false, isImported: Bool = false ) { self.walletId = walletId @@ -95,7 +88,6 @@ public final class PersistentWallet { self.balanceUnconfirmed = 0 self.balanceImmature = 0 self.balanceLocked = 0 - self.isWatchOnly = isWatchOnly self.isImported = isImported self.createdAt = Date() self.lastUpdated = Date() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 8afb13d973..b270c1a3fd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -288,7 +288,7 @@ public class PlatformWalletPersistenceHandler { let walletId = walletRecord.walletId let accountDescriptor = FetchDescriptor( predicate: #Predicate { - $0.wallet?.walletId == walletId + $0.wallet.walletId == walletId && $0.accountTypeName == typeName && $0.accountIndex == accountIndex } @@ -299,11 +299,11 @@ public class PlatformWalletPersistenceHandler { account.lastUpdated = Date() } else { account = PersistentAccount( + wallet: walletRecord, accountType: 0, accountIndex: accountIndex, accountTypeName: typeName ) - account.wallet = walletRecord backgroundContext.insert(account) } @@ -345,31 +345,24 @@ public class PlatformWalletPersistenceHandler { } private func upsertTransaction(account: PersistentAccount, tx: TransactionRecordFFI) { - let txidHex = hashHex(tx.txid) + // `account` is intentionally consumed only by the TXO upsert + // pass that follows this method's call site. The transaction + // row itself is account-agnostic — a single tx can land in + // multiple accounts (or wallets), and per-wallet membership + // is recovered through the TXO graph (`outputs` / `inputs`) + // rather than a denormalized column on the transaction. + _ = account + let txidData = hashData(tx.txid) let descriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txidHex } + predicate: #Predicate { $0.txid == txidData } ) - // Pull the denormalized walletId from the account's parent - // wallet relationship. Both hops are optional on the model, - // but in regular (non-predicate) Swift code the chain is - // cheap — and defaulting to `Data()` for orphaned rows is - // harmless because such rows won't be matched by any - // per-wallet query anyway. - let resolvedWalletId: Data = account.wallet?.walletId ?? Data() let record: PersistentTransaction if let existing = try? backgroundContext.fetch(descriptor).first { record = existing - // Backfill for rows created before the `walletId` - // column existed (lightweight migration defaulted them - // to empty Data). - if record.walletId.isEmpty, !resolvedWalletId.isEmpty { - record.walletId = resolvedWalletId - } } else { record = PersistentTransaction( - txid: txidHex, - walletId: resolvedWalletId, + txid: txidData, context: tx.context, blockHeight: tx.block_height, direction: tx.direction, @@ -377,7 +370,6 @@ public class PlatformWalletPersistenceHandler { netAmount: tx.net_amount, firstSeen: tx.first_seen ) - record.account = account backgroundContext.insert(record) } @@ -403,30 +395,44 @@ public class PlatformWalletPersistenceHandler { } private func upsertUtxo(account: PersistentAccount, utxo: UtxoEntryFFI) { - let txidHex = hashHex(utxo.outpoint.txid) - let outpoint = "\(txidHex):\(utxo.outpoint.vout)" - let descriptor = FetchDescriptor( + // Pull the per-account wallet id once. Used both for the new + // `PersistentTxo.walletId` denorm (so per-wallet predicates + // can hit a single column) and for stub-tx routing below. + let resolvedWalletId: Data = account.wallet.walletId + + let txidData = hashData(utxo.outpoint.txid) + let outpoint = PersistentTxo.makeOutpoint(txid: txidData, vout: utxo.outpoint.vout) + let descriptor = FetchDescriptor( predicate: #Predicate { $0.outpoint == outpoint } ) - let record: PersistentUtxo + let record: PersistentTxo if let existing = try? backgroundContext.fetch(descriptor).first { record = existing + // Backfill if the account or wallet linkage is missing — + // the per-wallet query path filters on TXO.walletId, so + // an empty value would silently hide the row. + if record.account == nil { record.account = account } + if record.walletId.isEmpty, !resolvedWalletId.isEmpty { + record.walletId = resolvedWalletId + } } else { // Look up the containing transaction. Upstream sends the - // transaction record before its UTXOs in the same flush, + // transaction record before its TXOs in the same flush, // so it should already be in the context. If not, create // a stub keyed by txid so the cascade-delete invariant - // (UTXO cannot exist without its transaction) holds; the - // real record will overwrite the stub when it arrives. + // (TXO cannot exist without its creating transaction) + // holds; the real record will overwrite the stub when it + // arrives. Note we no longer set `parentTx.account` — + // transactions don't carry account linkage anymore (they + // can span multiple accounts). let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txidHex } + predicate: #Predicate { $0.txid == txidData } ) let parentTx: PersistentTransaction if let existingTx = try? backgroundContext.fetch(txDescriptor).first { parentTx = existingTx } else { - parentTx = PersistentTransaction(txid: txidHex) - parentTx.account = account + parentTx = PersistentTransaction(txid: txidData) backgroundContext.insert(parentTx) } @@ -435,7 +441,7 @@ public class PlatformWalletPersistenceHandler { return Data(bytes: p, count: Int(utxo.script_pubkey_len)) }() let addressStr = utxo.address.map { String(cString: $0) } ?? "" - record = PersistentUtxo( + record = PersistentTxo( transaction: parentTx, vout: utxo.outpoint.vout, amount: utxo.amount, @@ -444,6 +450,7 @@ public class PlatformWalletPersistenceHandler { height: utxo.height ) record.account = account + record.walletId = resolvedWalletId backgroundContext.insert(record) } @@ -454,27 +461,50 @@ public class PlatformWalletPersistenceHandler { record.isInstantLocked = utxo.is_instantlocked record.isLocked = utxo.is_locked record.lastUpdated = Date() + + // Attach the `PersistentCoreAddress` row, if we have one. The + // address-emit pass typically runs ahead of the SPV-utxo pass + // within a flush, so the row should exist; if it doesn't (TXO + // paid to an address outside our pool, or out-of-order flush), + // leave the relationship nil — `record.address` stays as the + // authoritative identifier. + if record.coreAddress == nil, !record.address.isEmpty { + let addressLookup = record.address + let coreAddressDescriptor = FetchDescriptor( + predicate: #Predicate { $0.address == addressLookup } + ) + if let coreAddr = try? backgroundContext.fetch(coreAddressDescriptor).first { + record.coreAddress = coreAddr + } + } } private func markUtxoSpent(_ op: OutPointFFI) { - let outpoint = "\(hashHex(op.txid)):\(op.vout)" - let descriptor = FetchDescriptor( + let outpoint = PersistentTxo.makeOutpoint(txid: hashData(op.txid), vout: op.vout) + let descriptor = FetchDescriptor( predicate: #Predicate { $0.outpoint == outpoint } ) - if let utxo = try? backgroundContext.fetch(descriptor).first { - utxo.isSpent = true - utxo.lastUpdated = Date() + if let txo = try? backgroundContext.fetch(descriptor).first { + txo.isSpent = true + // The FFI's spent-utxo notification only carries the + // outpoint, not the spending tx — so we cannot populate + // `txo.spendingTransaction` here. `isSpent = true` with + // `spendingTransaction == nil` is the steady-state we + // reach for now; future work: have the FFI emit the + // spending txid alongside each spent outpoint and link + // them up here. + txo.lastUpdated = Date() } } private func markUtxoInstantLocked(_ op: OutPointFFI) { - let outpoint = "\(hashHex(op.txid)):\(op.vout)" - let descriptor = FetchDescriptor( + let outpoint = PersistentTxo.makeOutpoint(txid: hashData(op.txid), vout: op.vout) + let descriptor = FetchDescriptor( predicate: #Predicate { $0.outpoint == outpoint } ) - if let utxo = try? backgroundContext.fetch(descriptor).first { - utxo.isInstantLocked = true - utxo.lastUpdated = Date() + if let txo = try? backgroundContext.fetch(descriptor).first { + txo.isInstantLocked = true + txo.lastUpdated = Date() } } @@ -1315,7 +1345,7 @@ public class PlatformWalletPersistenceHandler { let index = key.index let descriptor = FetchDescriptor( predicate: #Predicate { - $0.wallet?.walletId == walletId + $0.wallet.walletId == walletId && $0.accountType == typeTag && $0.accountIndex == index } @@ -1413,7 +1443,7 @@ public class PlatformWalletPersistenceHandler { // and verify the richer fields in Swift. let descriptor = FetchDescriptor( predicate: #Predicate { - $0.wallet?.walletId == walletId + $0.wallet.walletId == walletId && $0.accountType == typeTag && $0.accountIndex == index } @@ -1436,6 +1466,7 @@ public class PlatformWalletPersistenceHandler { account = match } else { account = PersistentAccount( + wallet: wallet, accountType: typeTag, accountIndex: index, accountTypeName: accountTypeName( @@ -1443,7 +1474,6 @@ public class PlatformWalletPersistenceHandler { standardTag: spec.standard_tag ) ) - account.wallet = wallet backgroundContext.insert(account) } account.standardTag = spec.standard_tag diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift index b4a24a9dfd..5b77f67b1b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift @@ -15,6 +15,33 @@ struct AccountDetailView: View { @State private var showingPINPrompt = false @State private var pinInput = "" + /// Distinct on-chain transactions this account participates in: + /// the union of every TXO's creating tx and spending tx. Lives + /// here rather than on the model because `PersistentTransaction` + /// is no longer account-scoped (a single tx can produce outputs + /// into multiple accounts), so the per-account set has to be + /// derived on demand. Walks the address pool — the canonical + /// account → TXO path is `coreAddresses.flatMap(\.txos)` now + /// that `PersistentAccount.outputs` is gone. + private var distinctTransactionCount: Int { + var seen: Set = [] + for address in account.coreAddresses { + for txo in address.txos { + if let tx = txo.transaction { seen.insert(tx.txid) } + if let spending = txo.spendingTransaction { seen.insert(spending.txid) } + } + } + return seen.count + } + + /// Total TXO count for the account, summed across address + /// buckets. Avoids materializing a single big array since the + /// address pool is bounded (~gap limit) and txos-per-address + /// is small. + private var txoCount: Int { + account.coreAddresses.reduce(0) { $0 + $1.txos.count } + } + var body: some View { ScrollView { if let error = errorMessage { @@ -113,14 +140,6 @@ struct AccountDetailView: View { Text(wallet.network?.displayName ?? "Unknown") .fontWeight(.medium) } - - HStack { - Text("Watch Only:") - .foregroundColor(.secondary) - Spacer() - Text(account.isWatchOnly ? "Yes" : "No") - .fontWeight(.medium) - } } } .padding() @@ -277,14 +296,18 @@ struct AccountDetailView: View { Text("Transactions:") .foregroundColor(.secondary) Spacer() - Text("\(account.transactions.count)") + // Per-account transaction count = distinct creating + // and spending txs across this account's TXOs. The + // per-tx wallet/account columns are gone; the union + // is computed in Swift each time the card renders. + Text("\(distinctTransactionCount)") .fontWeight(.medium) } HStack { - Text("UTXOs:") + Text("TXOs:") .foregroundColor(.secondary) Spacer() - Text("\(account.utxos.count)") + Text("\(txoCount)") .fontWeight(.medium) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift index cfe7aa105b..f295349be5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift @@ -13,7 +13,7 @@ struct AccountListView: View { let walletId = wallet.walletId _accounts = Query( filter: #Predicate { acc in - acc.wallet?.walletId == walletId + acc.wallet.walletId == walletId } ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 4101a5e8c2..343d5cc38d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -770,12 +770,6 @@ struct WalletRowView: View { HStack(spacing: 6) { Text(wallet.label) .font(.headline) - if wallet.isWatchOnly { - Image(systemName: "eye") - .font(.caption) - .foregroundColor(.orange) - .help("Watch-only") - } if wallet.isImported { Image(systemName: "tray.and.arrow.down") .font(.caption) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift index 46b5591f4c..0415c906cf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ReceiveAddressView.swift @@ -75,7 +75,7 @@ struct ReceiveAddressView: View { private func findAccount(accountType: UInt32, accountIndex: UInt32) -> PersistentAccount? { let walletId = wallet.walletId for account in allAccounts { - if account.wallet?.walletId != walletId { continue } + if account.wallet.walletId != walletId { continue } if account.accountType != accountType { continue } if account.accountIndex != accountIndex { continue } return account diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift index 3d4568fc34..7ef455cfdc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift @@ -111,10 +111,10 @@ struct TransactionDetailView: View { .foregroundColor(.secondary) Button { - copyToClipboard(transaction.txid) + copyToClipboard(transaction.txidHex) } label: { HStack { - Text(transaction.txid) + Text(transaction.txidHex) .font(.system(.footnote, design: .monospaced)) .foregroundColor(.primary) .lineLimit(nil) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index 7121b738f7..0a483fd439 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -3,42 +3,33 @@ import SwiftData import SwiftDashSDK struct TransactionListView: View { - let wallet: PersistentWallet - + /// Per-wallet transaction list. Queries `PersistentTransaction` + /// directly with a relationship-traversal predicate — a tx + /// belongs to the wallet iff one of its `outputs` or `inputs` + /// has `walletId == walletId`. SwiftData lowers `.contains` to + /// SQLite, so the fetch is a single sorted query, no Swift + /// dedupe, no fault chain. + /// + /// Reached via value-based navigation (see + /// `WalletsContentView`'s `.navigationDestination` modifiers). + /// Closure-based `NavigationLink { Destination }` is unusable on + /// iOS 26 here — the eager destination construction stalls the + /// push when the destination has any meaningful `init` or + /// `@Query`. Value-based push only constructs the destination + /// at navigate time. + let walletId: Data @Query private var transactions: [PersistentTransaction] @State private var selectedTransaction: PersistentTransaction? - init(wallet: PersistentWallet) { - self.wallet = wallet - let walletId = wallet.walletId - // Use the denormalized `PersistentTransaction.walletId` - // column rather than chaining `tx.account?.wallet?.walletId`. - // SwiftData's predicate compiler can't lower a double - // optional-relationship chain to SQLite and crashes with - // `Unsupported function expression TERNARY(...).walletId`. - // - // `propertiesToFetch` matters: without it, a wallet with - // ~1.5k transactions stalls the navigation push for several - // seconds because SwiftData hydrates the full row — including - // the `transactionData` raw-bytes blob — for every match on - // the main thread before the List can render. Restricting - // the fetch to the columns the row actually reads - // (`TransactionRowView` only touches txid / netAmount / - // firstSeen / context / fee, plus walletId for the predicate) - // keeps SQLite on an index-only scan against the - // `(walletId, firstSeen)` compound index and skips the blob. - var descriptor = FetchDescriptor( - predicate: #Predicate { tx in tx.walletId == walletId }, + init(walletId: Data) { + self.walletId = walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { tx in + tx.outputs.contains { $0.walletId == walletId } + || tx.inputs.contains { $0.walletId == walletId } + }, sortBy: [SortDescriptor(\PersistentTransaction.firstSeen, order: .reverse)] ) - descriptor.propertiesToFetch = [ - \.txid, - \.netAmount, - \.firstSeen, - \.context, - \.fee, - \.walletId, - ] _transactions = Query(descriptor) } @@ -76,12 +67,6 @@ struct TransactionListView: View { } private var transactionsList: some View { - // Use SwiftData's built-in PersistentIdentifier as the row - // identity (via `List(transactions)`) instead of `id: \.txid`. - // The Storage Explorer's transaction list uses the same shape - // and renders instantly; keying on `txid` forces SwiftUI to - // read the unique-string column from every row for diffing - // even when SwiftData already has a stable identity for free. List(transactions) { transaction in Button { selectedTransaction = transaction @@ -127,7 +112,7 @@ struct TransactionRowView: View { } private var truncatedTxid: String { - let txid = transaction.txid + let txid = transaction.txidHex guard txid.count > 16 else { return txid } return "\(txid.prefix(8))…\(txid.suffix(8))" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 618247e351..0ee9faa677 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -4,6 +4,20 @@ import SwiftData import DashSDKFFI import LocalAuthentication +/// Routes for value-based navigation from the wallets tab. All +/// pushes go through `.navigationDestination(for:)` modifiers +/// on the stack root (see `WalletsContentView`) — this avoids +/// closure-based `NavigationLink { Destination }` which on iOS 26 +/// (a) eagerly constructs the destination on every parent body +/// invocation, stalling the click when the destination has any +/// meaningful `init`, and (b) when mixed with value-based pushes +/// further down the stack, makes SwiftUI animate-then-pop the +/// inner destination because the stack identity is split across +/// paradigms. Going value-based all the way fixes both. +struct TransactionsRoute: Hashable { + let walletId: Data +} + struct WalletDetailView: View { @EnvironmentObject var walletManager: PlatformWalletManager @EnvironmentObject var platformState: AppState @@ -14,30 +28,33 @@ struct WalletDetailView: View { @State private var showSendTransaction = false @State private var showWalletInfo = false - // Badge count for "View All Transactions". Backed by a - // bounded FetchDescriptor against the `(walletId, firstSeen)` - // compound index on `PersistentTransaction` — SQLite resolves - // it as an index-only scan. `propertiesToFetch = [\.walletId]` - // keeps SwiftData from hydrating `transactionData` / `label` / - // etc. just to produce a count; we only ever read `.count`. - // - // Previous approach queried `PersistentAccount` and reduced - // `accounts.reduce(0) { $0 + $1.transactions.count }`, which - // fault-loaded every transaction across every account just to - // count them — O(N) main-thread work on every render. - @Query private var walletTransactions: [PersistentTransaction] + // Badge count for "View All Transactions". Transactions are no + // longer wallet-scoped (the same on-chain tx can land in + // multiple accounts / wallets), so we can't filter + // `PersistentTransaction` by walletId directly. We query the + // wallet's TXOs instead and count the distinct creating-or- + // spending transactions in the body — same union the list view + // uses. + @Query private var walletTxos: [PersistentTxo] init(wallet: PersistentWallet) { self.wallet = wallet let walletId = wallet.walletId - var descriptor = FetchDescriptor( + var descriptor = FetchDescriptor( predicate: #Predicate { $0.walletId == walletId } ) descriptor.propertiesToFetch = [\.walletId] - _walletTransactions = Query(descriptor) + _walletTxos = Query(descriptor) } - private var transactionCount: Int { walletTransactions.count } + private var transactionCount: Int { + var seen: Set = [] + for txo in walletTxos { + if let tx = txo.transaction { seen.insert(tx.txid) } + if let spending = txo.spendingTransaction { seen.insert(spending.txid) } + } + return seen.count + } var body: some View { VStack(spacing: 0) { @@ -91,9 +108,7 @@ struct WalletDetailView: View { } .padding(.horizontal) - NavigationLink { - TransactionListView(wallet: wallet) - } label: { + NavigationLink(value: TransactionsRoute(walletId: wallet.walletId)) { HStack { Label("View All Transactions", systemImage: "list.bullet.rectangle") .font(.subheadline) @@ -199,7 +214,7 @@ struct WalletInfoView: View { self.wallet = wallet self.onWalletDeleted = onWalletDeleted let walletId = wallet.walletId - _accounts = Query(filter: #Predicate { $0.wallet?.walletId == walletId }) + _accounts = Query(filter: #Predicate { $0.wallet.walletId == walletId }) } var body: some View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletsContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletsContentView.swift index ec351da329..ddc2349257 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletsContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletsContentView.swift @@ -49,9 +49,7 @@ struct WalletsContentView: View { .padding(.vertical, 20) } else { ForEach(wallets) { wallet in - NavigationLink { - WalletDetailView(wallet: wallet) - } label: { + NavigationLink(value: wallet) { WalletRowView(wallet: wallet) } .accessibilityIdentifier("wallets.walletRow.\(wallet.walletId.toHexString())") @@ -61,6 +59,21 @@ struct WalletsContentView: View { } } .accessibilityIdentifier("wallets.screen") + // All navigation pushes from this stack are value-based — + // closure-based pushes eagerly construct their destination on + // every parent body invocation (which fires constantly during + // sync) and stall the click on iOS 26. Value-based push only + // builds the destination on actual navigate. Both routes have + // to be declared on the stack root (here) — declaring on a + // pushed view is unreliable and mixing paradigms (closure + // outer + value inner) makes the inner destination + // animate-then-pop. + .navigationDestination(for: PersistentWallet.self) { wallet in + WalletDetailView(wallet: wallet) + } + .navigationDestination(for: TransactionsRoute.self) { route in + TransactionListView(walletId: route.walletId) + } .navigationTitle("Wallets") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index eb4aa51557..4f5a558254 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -689,7 +689,7 @@ struct CreateIdentityView: View { private func accountOptions(for walletId: Data) -> [FundingAccountOption] { allAccounts .filter { account in - guard account.wallet?.walletId == walletId else { return false } + guard account.wallet.walletId == walletId else { return false } guard CreateIdentityView.isFundingAccount(account) else { return false } return CreateIdentityView.accountBalanceSummary(account).hasBalance } @@ -746,7 +746,7 @@ struct CreateIdentityView: View { private func identityRegistrationAccount(for walletId: Data) -> PersistentAccount? { allAccounts.first { account in - account.wallet?.walletId == walletId && account.accountType == 2 + account.wallet.walletId == walletId && account.accountType == 2 } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index 3260a162f8..a2680ec8ba 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -67,8 +67,8 @@ struct StorageExplorerView: View { modelRow("Transactions", icon: "arrow.left.arrow.right.circle", type: PersistentTransaction.self) { TransactionStorageListView() } - modelRow("UTXOs", icon: "bitcoinsign.circle", type: PersistentUtxo.self) { - UtxoStorageListView() + modelRow("TXOs", icon: "bitcoinsign.circle", type: PersistentTxo.self) { + TxoStorageListView() } modelRow("Manager Metadata", icon: "gearshape.2", type: PersistentWalletManagerMetadata.self) { WalletManagerMetadataStorageListView() @@ -146,7 +146,7 @@ struct StorageExplorerView: View { count(PersistentWallet.self) count(PersistentAccount.self) count(PersistentTransaction.self) - count(PersistentUtxo.self) + count(PersistentTxo.self) count(PersistentWalletManagerMetadata.self) // Core and Platform address rows live in separate models now // (PersistentCoreAddress vs PersistentPlatformAddress), so diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index b0ed966773..b7de739a4c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -567,14 +567,37 @@ struct AccountStorageListView: View { } } + /// Per-account transaction count = distinct creating + spending + /// txs across this account's TXOs. Derived because the direct + /// `account.transactions` relationship is gone — a single tx can + /// span multiple accounts and is no longer account-scoped on the + /// model side. Walks the address pool now that + /// `PersistentAccount.outputs` is gone; the canonical + /// account → TXO path is `coreAddresses.flatMap(\.txos)`. + private func distinctTxCount(_ record: PersistentAccount) -> Int { + var seen: Set = [] + for address in record.coreAddresses { + for txo in address.txos { + if let tx = txo.transaction { seen.insert(tx.txid) } + if let spending = txo.spendingTransaction { seen.insert(spending.txid) } + } + } + return seen.count + } + + /// TXO count per account, summed across the address pool. + private func txoCount(_ record: PersistentAccount) -> Int { + record.coreAddresses.reduce(0) { $0 + $1.txos.count } + } + @ViewBuilder private func accountRow(_ record: PersistentAccount) -> some View { VStack(alignment: .leading, spacing: 4) { Text(record.accountTypeName).font(.body).lineLimit(1) Text( "Index \(record.accountIndex) · " - + "\(record.transactions.count) txs · " - + "\(record.utxos.count) utxos" + + "\(distinctTxCount(record)) txs · " + + "\(txoCount(record)) txos" ) .font(.caption).foregroundColor(.secondary) } @@ -591,7 +614,7 @@ struct TransactionStorageListView: View { List(records) { record in NavigationLink(destination: TransactionStorageDetailView(record: record)) { VStack(alignment: .leading, spacing: 4) { - Text(record.txid) + Text(record.txidHex) .font(.system(.caption, design: .monospaced)) .lineLimit(1).truncationMode(.middle) HStack { @@ -846,17 +869,17 @@ struct PlatformAddressStorageListView: View { } } -// MARK: - PersistentUtxo +// MARK: - PersistentTxo -struct UtxoStorageListView: View { - @Query(sort: \PersistentUtxo.createdAt, order: .reverse) - private var records: [PersistentUtxo] +struct TxoStorageListView: View { + @Query(sort: \PersistentTxo.createdAt, order: .reverse) + private var records: [PersistentTxo] var body: some View { List(records) { record in - NavigationLink(destination: UtxoStorageDetailView(record: record)) { + NavigationLink(destination: TxoStorageDetailView(record: record)) { VStack(alignment: .leading, spacing: 4) { - Text(record.outpoint) + Text(record.outpointHex) .font(.system(.caption, design: .monospaced)) .lineLimit(1).truncationMode(.middle) HStack { @@ -871,7 +894,7 @@ struct UtxoStorageListView: View { } } } - .navigationTitle("UTXOs (\(records.count))") + .navigationTitle("TXOs (\(records.count))") .overlay { if records.isEmpty { ContentUnavailableView("No Records", systemImage: "bitcoinsign.circle") } } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 94539585a8..0697af865b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -555,13 +555,37 @@ struct AccountStorageDetailView: View { ) } + /// Distinct transactions this account participates in: union of + /// every TXO's creating tx (`transaction`) and spending tx + /// (`spendingTransaction`). Mirrors the AccountDetailView helper + /// — `PersistentTransaction` no longer carries an account link, + /// so the per-account set has to be derived on read. Walks the + /// address pool because `PersistentAccount.outputs` is gone; + /// the canonical account → TXO path is now + /// `coreAddresses.flatMap(\.txos)`. + private var distinctTransactionCount: Int { + var seen: Set = [] + for address in record.coreAddresses { + for txo in address.txos { + if let tx = txo.transaction { seen.insert(tx.txid) } + if let spending = txo.spendingTransaction { seen.insert(spending.txid) } + } + } + return seen.count + } + + /// Total TXO count for the account, summed across the address + /// pool. Cheap because the pool is bounded (~gap limit). + private var txoCount: Int { + record.coreAddresses.reduce(0) { $0 + $1.txos.count } + } + var body: some View { Form { Section("Core") { FieldRow(label: "Type", value: record.accountTypeName) FieldRow(label: "Type ID", value: "\(record.accountType)") FieldRow(label: "Index", value: "\(record.accountIndex)") - FieldRow(label: "Watch Only", value: record.isWatchOnly ? "Yes" : "No") FieldRow( label: "Extended Public Key", value: accountXpubString ?? "—" @@ -576,10 +600,14 @@ struct AccountStorageDetailView: View { FieldRow(label: "Internal Highest Used", value: "\(record.internalHighestUsed)") } Section("Relationships") { - FieldRow(label: "Transactions", value: "\(record.transactions.count)") - FieldRow(label: "UTXOs", value: "\(record.utxos.count)") + // Per-account transaction count = union of creating + // and spending txs across this account's TXOs. + // `PersistentTransaction` is no longer + // account-scoped, so this has to be derived in Swift. + FieldRow(label: "Transactions", value: "\(distinctTransactionCount)") + FieldRow(label: "TXOs", value: "\(txoCount)") FieldRow(label: "Addresses", value: "\(record.coreAddresses.count)") - FieldRow(label: "Wallet", value: record.wallet?.name ?? record.wallet.map { hexString($0.walletId) } ?? "None") + FieldRow(label: "Wallet", value: record.wallet.name ?? hexString(record.wallet.walletId)) } ForEach(addressSections(), id: \.0) { poolName, addresses in Section("\(poolName) Addresses (\(addresses.count))") { @@ -667,6 +695,10 @@ struct CoreAddressDetailView: View { value: record.lastSeenHeight == 0 ? "—" : "\(record.lastSeenHeight)" ) } + Section("Relationships") { + FieldRow(label: "Account", value: record.account?.accountTypeName ?? "—") + FieldRow(label: "TXOs", value: "\(record.txos.count)") + } Section("Timestamps") { FieldRow(label: "Created", value: dateString(record.createdAt)) FieldRow(label: "Updated", value: dateString(record.lastUpdated)) @@ -685,7 +717,7 @@ struct TransactionStorageDetailView: View { var body: some View { Form { Section("Core") { - FieldRow(label: "TXID", value: record.txid) + FieldRow(label: "TXID", value: record.txidHex) FieldRow(label: "Direction", value: record.directionName) FieldRow(label: "Type", value: record.transactionType) FieldRow(label: "Net Amount", value: record.formattedAmount) @@ -709,7 +741,11 @@ struct TransactionStorageDetailView: View { } } Section("Relationships") { - FieldRow(label: "Account", value: record.account?.accountTypeName ?? "None") + // Transactions are no longer account-scoped. We + // surface the participating accounts (if any) + // indirectly via the output / input TXOs. + FieldRow(label: "Outputs", value: "\(record.outputs.count)") + FieldRow(label: "Inputs", value: "\(record.inputs.count)") } Section("Timestamps") { FieldRow(label: "Created", value: dateString(record.createdAt)) @@ -721,16 +757,16 @@ struct TransactionStorageDetailView: View { } } -// MARK: - PersistentUtxo +// MARK: - PersistentTxo -struct UtxoStorageDetailView: View { - let record: PersistentUtxo +struct TxoStorageDetailView: View { + let record: PersistentTxo var body: some View { Form { Section("Core") { - FieldRow(label: "Outpoint", value: record.outpoint) - FieldRow(label: "TXID", value: record.txid) + FieldRow(label: "Outpoint", value: record.outpointHex) + FieldRow(label: "TXID", value: record.txidHex) FieldRow(label: "Vout", value: "\(record.vout)") FieldRow(label: "Amount", value: record.formattedAmount) FieldRow(label: "Address", value: record.address) @@ -744,14 +780,33 @@ struct UtxoStorageDetailView: View { FieldRow(label: "Spent", value: record.isSpent ? "Yes" : "No") } Section("Relationships") { - FieldRow(label: "Account", value: record.account?.accountTypeName ?? "None") + // Prefer the canonical `coreAddress.account` path; + // fall back to the one-way `account` field for TXOs + // whose address row hasn't been linked yet. + FieldRow( + label: "Account", + value: (record.coreAddress?.account ?? record.account)?.accountTypeName ?? "—" + ) + FieldRow( + label: "Address Row", + value: record.coreAddress?.address ?? "—" + ) + FieldRow(label: "Wallet ID", value: record.walletId.isEmpty ? "—" : hexString(record.walletId)) + FieldRow( + label: "Created By", + value: record.transaction?.txidHex ?? "—" + ) + FieldRow( + label: "Spent By", + value: record.spendingTransaction?.txidHex ?? "—" + ) } Section("Timestamps") { FieldRow(label: "Created", value: dateString(record.createdAt)) FieldRow(label: "Updated", value: dateString(record.lastUpdated)) } } - .navigationTitle("UTXO") + .navigationTitle("TXO") .navigationBarTitleDisplayMode(.inline) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpIdentityView.swift index 3efbe81d86..492b096799 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TopUpIdentityView.swift @@ -373,7 +373,7 @@ struct TopUpIdentityView: View { private func accountOptions(for walletId: Data) -> [FundingAccountOption] { allAccounts .filter { account in - guard account.wallet?.walletId == walletId else { return false } + guard account.wallet.walletId == walletId else { return false } guard account.accountType == 14 else { return false } return account.platformAddresses.contains { $0.balance > 0 } } From 72afd0ee176c10d811a17f8db3bf9aa904aaa746 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 29 Apr 2026 01:29:20 +0800 Subject: [PATCH 3/4] fix(swift-sdk): drop dead orphan-account filter and reverse txidHex bytes for canonical display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the schema redesign: 1. StorageModelListViews.swift had a leftover orphanAccounts filter (allAccounts.filter { \$0.wallet == nil }) and an "Unlinked" section, both predicated on PersistentAccount.wallet being optional. With wallet now non-optional the filter is dead code and the warnings-as-errors CI build was rejecting the nil comparison. 2. PersistentTransaction.txidHex (and PersistentTxo.txidHex which now delegates to it) was hex-encoding the raw 32 bytes in storage/wire order. Bitcoin/Dash convention reverses those bytes for display — dashcore::Txid: Display does the same flip — so the previous output was the reverse of what users see in block explorers. Fix reverses bytes only on the read side; storage stays unflipped so existing predicate fetches keep working without re-encoding. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/PersistentTransaction.swift | 10 +++++++-- .../Persistence/Models/PersistentTxo.swift | 7 +++++-- .../Views/StorageModelListViews.swift | 21 +------------------ 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index 3c79b5c19e..79493e9fc5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -101,9 +101,15 @@ public final class PersistentTransaction { // MARK: - Display Helpers /// Hex-encoded txid for UI / log sites. The on-disk row stores - /// raw bytes; this is computed on read. + /// the raw 32 bytes in wire/internal order (matches what + /// `dashcore::Txid::as_ref()` hands the FFI). The canonical + /// Bitcoin/Dash display convention is the *reverse* of those + /// bytes (the `Txid: Display` impl in dashcore-rust does the + /// same flip), so block-explorer hex matches what users see + /// here. Storage stays unflipped — predicate fetches compare + /// wire-order `Data` directly without re-encoding. public var txidHex: String { - txid.map { String(format: "%02x", $0) }.joined() + txid.reversed().map { String(format: "%02x", $0) }.joined() } public var contextName: String { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift index 3b7171932a..eea6cdacaf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift @@ -141,9 +141,12 @@ public final class PersistentTxo { transaction?.txid ?? Data() } - /// Hex-encoded txid for UI / log sites. + /// Hex-encoded txid for UI / log sites. Delegates to + /// `PersistentTransaction.txidHex` (which reverses bytes for + /// canonical block-explorer display) — keeping that flip in + /// one place avoids the two sides drifting out of sync. public var txidHex: String { - txid.map { String(format: "%02x", $0) }.joined() + transaction?.txidHex ?? "" } /// Human-readable outpoint (`:`) for UI / log diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index b7de739a4c..8823bf7c7a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -502,18 +502,8 @@ struct AccountStorageListView: View { @Query(sort: \PersistentWallet.createdAt, order: .reverse) private var wallets: [PersistentWallet] - /// Catch any accounts whose `wallet` inverse is nil (shouldn't - /// happen in steady state — the write path always links them — - /// but shown so the explorer doesn't silently hide them). - @Query(sort: \PersistentAccount.createdAt, order: .reverse) - private var allAccounts: [PersistentAccount] - - private var orphanAccounts: [PersistentAccount] { - allAccounts.filter { $0.wallet == nil } - } - private var totalAccountCount: Int { - wallets.reduce(0) { $0 + $1.accounts.count } + orphanAccounts.count + wallets.reduce(0) { $0 + $1.accounts.count } } var body: some View { @@ -532,15 +522,6 @@ struct AccountStorageListView: View { } } } - if !orphanAccounts.isEmpty { - Section(header: Text("Unlinked")) { - ForEach(orphanAccounts) { account in - NavigationLink(destination: AccountStorageDetailView(record: account)) { - accountRow(account) - } - } - } - } } .navigationTitle("Accounts (\(totalAccountCount))") .overlay { From 1f71165c464d798d67ad974d16a7f0129adf6bc9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 29 Apr 2026 01:37:10 +0800 Subject: [PATCH 4/4] fix(swift-sdk): derive PersistentTxo txid helpers from outpoint when transaction is unattached The 36-byte outpoint already carries the txid in its first 32 bytes, so falling back to that when the `transaction` inverse is briefly nil during insert keeps storage-explorer rows from collapsing to empty `:vout` strings. `txidHex` now mirrors `PersistentTransaction.txidHex`'s byte-reversal directly (rather than forwarding) so the canonical block-explorer flip is applied in both the attached and unattached cases. `outpointHex` flows through `txidHex`, picking up the same fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Persistence/Models/PersistentTxo.swift | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift index eea6cdacaf..0aaeb57014 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift @@ -134,28 +134,36 @@ public final class PersistentTxo { } /// Convenience accessor for the containing transaction's txid - /// as raw 32-byte `Data`. Returns empty `Data()` if the - /// relationship isn't attached (which should only happen - /// briefly during construction). + /// as raw 32-byte `Data`. Prefers the `transaction` relationship; + /// falls back to the first 32 bytes of `outpoint` when the + /// inverse is briefly nil during insert (so storage-explorer + /// rows still render a stable identifier rather than collapsing + /// to empty). public var txid: Data { - transaction?.txid ?? Data() + if let transaction { + return transaction.txid + } + return outpoint.count >= 32 ? Data(outpoint.prefix(32)) : Data() } - /// Hex-encoded txid for UI / log sites. Delegates to - /// `PersistentTransaction.txidHex` (which reverses bytes for - /// canonical block-explorer display) — keeping that flip in - /// one place avoids the two sides drifting out of sync. + /// Hex-encoded txid for UI / log sites. Reverses bytes to match + /// the canonical block-explorer display (same flip as + /// `dashcore::Txid: Display`). Mirrors + /// `PersistentTransaction.txidHex` directly so the two stay in + /// sync; can't simply forward to it because we want the same + /// hex even when `transaction` is briefly unattached. public var txidHex: String { - transaction?.txidHex ?? "" + let rawTxid = txid + guard rawTxid.count == 32 else { return "" } + return rawTxid.reversed().map { String(format: "%02x", $0) }.joined() } /// Human-readable outpoint (`:`) for UI / log - /// sites. Reconstructs from the parent transaction's txid plus - /// `self.vout` rather than re-decoding the stored 36-byte blob, - /// which avoids one allocation and matches the legacy display - /// format. + /// sites. Reconstructs from `txidHex` so the byte-flip stays + /// consistent across all display surfaces. public var outpointHex: String { - "\(txidHex):\(vout)" + let hex = txidHex + return hex.isEmpty ? "" : "\(hex):\(vout)" } public var formattedAmount: String {