From c9e96d1db8c769c52ca782975d813925daa63267 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 13 Jul 2026 15:03:26 +0700 Subject: [PATCH 1/3] feat(swift-sdk): persist account involvement for payload-only special txs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A special transaction can involve an account purely through its payload — a ProRegTx whose owner / voting key hash matches a Provider Owner / Voting Keys pool address — creating no TXO in that account. Per-account transaction membership was recovered exclusively through the TXO graph, so those txs never appeared in the account's transaction count (AccountDetailView), the wallet-level count (WalletDetailView), or the wallet transaction list (TransactionListView). - PersistentTransaction.involvedAccounts <-> PersistentAccount .involvedTransactions: explicit many-to-many recording every account whose changeset bucket carried the tx record (a superset of the TXO-derived membership; .nullify on both ends — participation, not value ownership). - PlatformWalletPersistenceHandler.upsertTransaction appends the matched account idempotently (by persistentModelID) — it is already invoked once per matched account since the Rust changeset buckets records by account_type, including payload-only matches. - AccountDetailView unions involvedTransactions with the TXO-derived set for the pool-summary count and a new Transactions card that lists the account's txs (opens TransactionDetailView). - TransactionListView / WalletDetailView union the same join so payload-only txs show consistently at wallet level. Verified on a mainnet wallet: after a compact-filter rescan from height 1030600, ProRegTx 5076f9ed…d605da85 (height 1030673, zero TXOs in the account) is counted and listed by the Provider Owner Keys account detail view. Co-Authored-By: Claude Fable 5 --- .../Models/PersistentAccount.swift | 52 ++++++++-- .../Models/PersistentTransaction.swift | 37 +++++++ .../PlatformWalletPersistenceHandler.swift | 34 ++++++- .../Core/Views/AccountDetailView.swift | 97 ++++++++++++++++--- .../Core/Views/TransactionListView.swift | 22 ++++- .../Core/Views/WalletDetailView.swift | 16 ++- 6 files changed, 230 insertions(+), 28 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift index 6833297f87f..3939467dced 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift @@ -31,16 +31,23 @@ public struct DerivedPlatformNodeKey: Codable, Equatable, Hashable, Sendable { /// Each account represents an HD derivation path (BIP44, CoinJoin, /// 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. +/// Note: an account does **not** own a list of TXOs directly. 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 with funds are the union of those TXOs' `transaction` +/// (creating tx) and `spendingTransaction` (spending tx). Account +/// scope for funds flows through addresses. +/// +/// The one thing the address / TXO graph cannot express is +/// **payload-only** involvement — a special tx (e.g. a ProRegTx) +/// that matched this account through a Provider Owner / Voting key +/// address in its payload while creating no TXO here. Those txs are +/// carried by the explicit `involvedTransactions` many-to-many, the +/// only denormalized account ↔ transaction link on this side; see +/// `PersistentTransaction.involvedAccounts` for the full semantics. @Model public final class PersistentAccount { /// Compound uniqueness on the full account-identity tuple: @@ -165,6 +172,30 @@ public final class PersistentAccount { @Relationship(deleteRule: .cascade, inverse: \PersistentPlatformAddress.account) public var platformAddresses: [PersistentPlatformAddress] + /// Transactions this account participates in that the TXO graph + /// cannot recover — the payload-only involvement described in the + /// type doc above. Populated by the persistence handler, which + /// appends this account whenever it upserts a tx record the + /// changeset bucketed under this account, even when the record + /// produced no TXO here (special-tx payloads matching provider + /// owner / voting key addresses). + /// + /// A superset that overlaps the TXO-derived set for ordinary funded + /// txs (the handler appends there too), so consumers computing a + /// per-account transaction list must **union** this with the + /// TXO-derived txids and de-dup — see `AccountDetailView`. + /// + /// The `inverse:` for this many-to-many lives on + /// `PersistentTransaction.involvedAccounts`; this side carries the + /// plain declaration. Default `.nullify` delete rule — deleting + /// this account detaches it from each tx without removing the + /// (shared) tx rows. That matters for the wallet-wipe path + /// (`deleteWalletData`), which deletes accounts before the wallet: + /// `.nullify` on a to-many inverse has no "default value" fatal + /// (unlike the non-optional `wallet` back-reference), so no extra + /// pre-delete pass is needed. + public var involvedTransactions: [PersistentTransaction] = [] + public init( wallet: PersistentWallet, accountType: UInt32, @@ -190,5 +221,6 @@ public final class PersistentAccount { self.lastUpdated = Date() self.coreAddresses = [] self.platformAddresses = [] + self.involvedTransactions = [] } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index 5e4b3c687a4..a71e50fd150 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -14,6 +14,15 @@ import SwiftData /// by joining through the TXOs (`outputs` + `inputs`); see /// `PersistentTxo.walletId` for the per-row denorm that makes those /// joins index-friendly. +/// +/// The TXO join is the canonical membership path for funds, but it +/// is empty for **payload-only** involvement: a special transaction +/// (e.g. a ProRegTx) can match an account purely through its payload +/// — a Provider Owner / Voting key address baked into the special-tx +/// fields — while creating **no** TXO in that account. Such a tx is +/// invisible to the TXO join yet is genuinely "this account's". The +/// explicit `involvedAccounts` many-to-many below is the only place +/// that involvement is representable; see it for the full semantics. @Model public final class PersistentTransaction { /// Index on `firstSeen` so per-wallet queries — which fetch @@ -112,6 +121,34 @@ public final class PersistentTransaction { @Relationship(deleteRule: .cascade, inverse: \PersistentPendingInput.spendingTransaction) public var pendingInputs: [PersistentPendingInput] = [] + /// Every account whose changeset bucket carried this tx record. + /// + /// This is a **superset** of the TXO-derived membership: it + /// includes payload-only involvement (special-tx payloads whose + /// Provider Owner / Voting key addresses matched an account) where + /// no `PersistentTxo` exists in the account, so the TXO join can + /// never surface it. The persistence handler appends the matched + /// account here for every record it upserts, mirroring how + /// `WalletChangeSetFFI::from_changeset` buckets `cs.records` by + /// `record.account_type` on the Rust side. + /// + /// The TXO join (`outputs` / `inputs` → `PersistentTxo.account`) + /// remains the canonical path for **funds** — balances, spend + /// tracking, per-address history all flow through it. This join + /// exists only so payload-only involvement is representable at + /// all; treat it as "account participation," not "account owns + /// value in this tx." + /// + /// Inverse of `PersistentAccount.involvedTransactions`, declared + /// on this side only (SwiftData needs the `inverse:` on exactly + /// one end of a many-to-many pair). Default `.nullify` delete rule + /// on both sides — deleting an account merely detaches it from the + /// tx (and vice versa); neither end cascades, since the tx row is + /// shared across accounts / wallets and the account outlives any + /// single tx. + @Relationship(inverse: \PersistentAccount.involvedTransactions) + public var involvedAccounts: [PersistentAccount] = [] + public init( txid: Data, transactionData: Data, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index e30fa12e5e6..4a69e6082bb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -592,11 +592,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func upsertTransaction(account: PersistentAccount, tx: TransactionRecordFFI) { // The `account` parameter scopes the wallet-id used for the - // input-reconciliation pass at the bottom of this method. - // The transaction row itself stays account-agnostic — a + // input-reconciliation pass at the bottom of this method, and + // records this account's participation in the tx via the + // `involvedAccounts` join appended below. + // + // The transaction row's *funds* stay 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. + // per-wallet fund membership is recovered through the TXO + // graph (`outputs` / `inputs`) rather than a denormalized + // column. But this handler is invoked once per matched account + // (the Rust changeset buckets `cs.records` by + // `record.account_type`), including for payload-only matches — + // a special-tx payload matching this account's provider owner / + // voting key address with no TXO in the account. The TXO join + // is blind to those, so we append `account` to + // `record.involvedAccounts` to keep the involvement + // representable at all. // let resolvedWalletId: Data = account.wallet.walletId let txidData = hashData(tx.txid) @@ -667,6 +678,21 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.transactionData = transactionData record.lastUpdated = Date() + // Record this account's participation in the tx. Idempotent: + // SPV re-upserts the same (account, tx) pair on every touch, so + // append only when the account isn't already linked. Compare by + // `persistentModelID` — object identity isn't stable across + // fetches within a context, but the model id is. This is the + // sole carrier of payload-only involvement (no TXO in the + // account); for ordinary funded txs it harmlessly duplicates + // the TXO-derived membership, which the per-account union + // de-dups. + if !record.involvedAccounts.contains(where: { + $0.persistentModelID == account.persistentModelID + }) { + record.involvedAccounts.append(account) + } + // Walk every input in this transaction and reconcile it // against the `PersistentTxo` table. The FFI populates // `input_outpoints` from `tx.input.iter()` directly, so the diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift index 77c8953ab3e..77780817346 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift @@ -38,23 +38,49 @@ struct AccountDetailView: View { /// "Copied" confirmation. @State private var derivedCopiedKey: String? - /// 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 { + /// The transaction sheet currently presented from the Transactions + /// card (`nil` = none). Mirrors `TransactionListView`'s pattern. + @State private var selectedTransaction: PersistentTransaction? + + /// Distinct transactions this account participates in, as display + /// rows: the **union** of the TXO-derived set (every TXO's creating + /// tx + spending tx) and the payload-only `involvedTransactions` + /// join. Lives here rather than on the model because + /// `PersistentTransaction` is not account-scoped (a single tx can + /// produce outputs into multiple accounts), so the per-account set + /// is derived on demand. + /// + /// The TXO walk covers funded involvement (canonical path: + /// `coreAddresses.flatMap(\.txos)`); `involvedTransactions` adds the + /// payload-only special txs that produced no TXO here and are + /// therefore invisible to that walk. De-dup is by `txid` so a tx + /// that is both funded and payload-linked appears once. + /// + /// Sorted to match `TransactionListView`: unconfirmed (context 0) + /// first, then by `firstSeen` descending. + private var distinctTransactions: [PersistentTransaction] { var seen: Set = [] + var result: [PersistentTransaction] = [] + func add(_ tx: PersistentTransaction) { + if seen.insert(tx.txid).inserted { result.append(tx) } + } 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) } + if let tx = txo.transaction { add(tx) } + if let spending = txo.spendingTransaction { add(spending) } } } - return seen.count + for tx in account.involvedTransactions { add(tx) } + return result.sorted { lhs, rhs in + if (lhs.context == 0) != (rhs.context == 0) { + return lhs.context == 0 + } + return lhs.firstSeen > rhs.firstSeen + } + } + + private var distinctTransactionCount: Int { + distinctTransactions.count } /// Total TXO count for the account, summed across address @@ -86,6 +112,11 @@ struct AccountDetailView: View { // ...and the per-index keys derived from it (the // actual operator / platform-node keys). derivedKeysCard() + // Operator-key accounts hold no TXOs, but a + // ProRegTx can still match them payload-only + // (provider owner / voting key address), so + // surface those txs here too. + transactionsCard() } else { if shouldShowBalance { balanceCard() @@ -118,6 +149,8 @@ struct AccountDetailView: View { emptyAddressesCard() } } + + transactionsCard() } } .padding() @@ -125,6 +158,9 @@ struct AccountDetailView: View { } .navigationTitle(account.accountTypeName) .navigationBarTitleDisplayMode(.large) + .sheet(item: $selectedTransaction) { transaction in + TransactionDetailView(transaction: transaction) + } .sheet(isPresented: $showingPINPrompt) { PINPromptView( pinInput: $pinInput, @@ -402,6 +438,43 @@ struct AccountDetailView: View { .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) } + /// Lists this account's distinct transactions (see + /// `distinctTransactions`) — the union of TXO-derived and + /// payload-only involvement — each tappable to open its detail + /// sheet. Rendered in both the provider-key and address-pool + /// branches since operator-key accounts can be payload-matched + /// too; skipped for PlatformPayment (tag 14), which has no core + /// txs, and hidden entirely when the list is empty. + @ViewBuilder + private func transactionsCard() -> some View { + let txs = distinctTransactions + if account.accountType != 14, !txs.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Label("Transactions (\(txs.count))", systemImage: "list.bullet.rectangle") + .font(.headline) + .foregroundColor(.primary) + + Divider() + + ForEach(Array(txs.enumerated()), id: \.element.txid) { idx, tx in + Button { + selectedTransaction = tx + } label: { + TransactionRowView(transaction: tx) + } + .buttonStyle(.plain) + if idx < txs.count - 1 { + Divider() + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) + } + } + /// Group this account's persisted addresses by pool tag, in a /// stable display order (External → Internal → Absent → Absent /// Hardened). Empty pools are skipped. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index 3a016017a94..ed24dc2f707 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -5,7 +5,10 @@ import SwiftDashSDK struct TransactionListView: View { /// Per-wallet transaction list. Queries `PersistentTxo` flat by /// the denormalized `walletId` column and resolves distinct - /// creating-or-spending `PersistentTransaction`s in the body — + /// creating-or-spending `PersistentTransaction`s in the body, + /// then unions in each account's payload-only + /// `involvedTransactions` (special txs that matched an account by + /// payload and produced no TXO, so the TXO join misses them) — /// same union `WalletDetailView`'s count uses. /// /// Reached via value-based navigation (see @@ -19,6 +22,11 @@ struct TransactionListView: View { /// Membership: which txids belong to this wallet, via the /// denormalized `walletId` on TXOs. @Query private var walletTxos: [PersistentTxo] + /// This wallet's accounts, for the payload-only + /// `involvedTransactions` union below — special txs that matched an + /// account by payload with no TXO in the wallet, invisible to the + /// `walletTxos` join. + @Query private var walletAccounts: [PersistentAccount] @Query private var transactionObservation: [PersistentTransaction] /// Per-wallet asset-lock rows. Used to look up the *locked* amount /// for each asset-lock tx — `PersistentTransaction.netAmount` is @@ -48,6 +56,9 @@ struct TransactionListView: View { predicate: #Predicate { $0.walletId == walletId } ) _walletTxos = Query(txoDescriptor) + _walletAccounts = Query( + filter: #Predicate { $0.wallet.walletId == walletId } + ) let assetLockDescriptor = FetchDescriptor( predicate: PersistentAssetLock.predicate(walletId: walletId) ) @@ -128,6 +139,15 @@ struct TransactionListView: View { result.append(spending) } } + // Payload-only involvement: special txs that matched one of + // this wallet's accounts by payload (provider owner / voting + // key address) with no TXO, so the `walletTxos` join above + // never sees them. De-dup by txid against the funded set. + for account in walletAccounts { + for tx in account.involvedTransactions where seen.insert(tx.txid).inserted { + result.append(tx) + } + } return result.sorted { lhs, rhs in if (lhs.context == 0) != (rhs.context == 0) { return lhs.context == 0 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 06bb2c6578c..07ecf9786ac 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -44,9 +44,15 @@ struct WalletDetailView: View { // 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 + // spending transactions in the body, then union in each account's + // payload-only `involvedTransactions` — same union the list view // uses. @Query private var walletTxos: [PersistentTxo] + /// This wallet's accounts, for the payload-only + /// `involvedTransactions` contribution to `transactionCount` — + /// special txs that matched an account by payload with no TXO, + /// which the `walletTxos` join can't see. + @Query private var walletAccounts: [PersistentAccount] init(wallet: PersistentWallet) { self.wallet = wallet @@ -56,6 +62,9 @@ struct WalletDetailView: View { ) descriptor.propertiesToFetch = [\.walletId] _walletTxos = Query(descriptor) + _walletAccounts = Query( + filter: #Predicate { $0.wallet.walletId == walletId } + ) _walletAssetLocks = Query( filter: PersistentAssetLock.predicate(walletId: walletId), sort: [SortDescriptor(\PersistentAssetLock.updatedAt, order: .reverse)] @@ -68,6 +77,11 @@ struct WalletDetailView: View { if let tx = txo.transaction { seen.insert(tx.txid) } if let spending = txo.spendingTransaction { seen.insert(spending.txid) } } + // Payload-only involvement: special txs matched by payload with + // no TXO in the wallet, invisible to the `walletTxos` join. + for account in walletAccounts { + for tx in account.involvedTransactions { seen.insert(tx.txid) } + } return seen.count } From 2ca8827f1f958ad2cc5b77d4a5634e4070819266 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 13 Jul 2026 15:29:30 +0700 Subject: [PATCH 2/3] fix(swift-sdk): keep sibling wallets' payload-only txs on wallet delete deleteWalletData's post-delete orphan sweep matched any PersistentTransaction with empty outputs / inputs / pendingInputs. Payload-only special txs have no TXOs anywhere yet belong to a live account through involvedAccounts, so deleting an unrelated wallet swept the other wallet's payload-only history. Include involvedAccounts.isEmpty in the sweep predicate; the deleted wallet's own payload-only rows are still collected because its account deletion nullifies their join links before the sweep runs. Adds testDeleteWalletDataPreservesSiblingPayloadOnlyTransactions covering the unrelated-wallet deletion path. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.swift | 14 +++- .../WalletDeletionTests.swift | 66 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 4a69e6082bb..a4ecdffbd42 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3756,10 +3756,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { try backgroundContext.save() + // Orphan sweep: drop tx rows no longer referenced by any + // wallet. A row is referenced through the TXO graph + // (outputs / inputs / pendingInputs) OR through the + // `involvedAccounts` join — payload-only special txs + // (e.g. a ProRegTx matching a provider owner key) have + // no TXOs anywhere yet legitimately belong to a live + // account, so sweeping on the TXO relations alone would + // erase another wallet's payload-only history. The + // deleted wallet's own payload-only rows still qualify: + // its accounts were deleted (and their join links + // nullified) in the earlier save above. let txRows = try backgroundContext.fetch(FetchDescriptor()) for tx in txRows where tx.outputs.isEmpty && tx.inputs.isEmpty && - tx.pendingInputs.isEmpty { + tx.pendingInputs.isEmpty && + tx.involvedAccounts.isEmpty { backgroundContext.delete(tx) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift index 52263ce3b9d..fd151ba4a96 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift @@ -136,6 +136,72 @@ final class WalletDeletionTests: XCTestCase { XCTAssertEqual(remaining.first?.outPointHex, "cafebabe:1") } + func testDeleteWalletDataPreservesSiblingPayloadOnlyTransactions() throws { + // Regression test for the thepastaclaw blocking finding on + // dashpay/platform#4108: the post-delete orphan sweep matched + // any `PersistentTransaction` with empty outputs / inputs / + // pendingInputs. Payload-only special txs (a ProRegTx matching + // a provider owner key) have no TXOs anywhere yet belong to a + // live account through `involvedAccounts`, so deleting an + // UNRELATED wallet swept the other wallet's payload-only + // history. The deleted wallet's own payload-only rows must + // still be swept — its account deletion nullifies their join + // links before the sweep runs. + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + let doomedId = Data(repeating: 0xc3, count: 32) + let siblingId = Data(repeating: 0xd4, count: 32) + + let doomedWallet = PersistentWallet(walletId: doomedId, network: .testnet) + let siblingWallet = PersistentWallet(walletId: siblingId, network: .testnet) + context.insert(doomedWallet) + context.insert(siblingWallet) + + // Provider Owner Keys account (tag 9) under each wallet. + let doomedAccount = PersistentAccount( + wallet: doomedWallet, + accountType: 9, + accountIndex: 0, + accountTypeName: "Provider Owner Keys" + ) + let siblingAccount = PersistentAccount( + wallet: siblingWallet, + accountType: 9, + accountIndex: 0, + accountTypeName: "Provider Owner Keys" + ) + context.insert(doomedAccount) + context.insert(siblingAccount) + + // Payload-only txs: no TXOs / pending inputs anywhere, linked + // to their wallet solely through `involvedAccounts`. + let doomedProRegTx = PersistentTransaction( + txid: Data(repeating: 0xe5, count: 32), + transactionData: Data([0x01]) + ) + doomedProRegTx.involvedAccounts.append(doomedAccount) + let siblingProRegTx = PersistentTransaction( + txid: Data(repeating: 0xf6, count: 32), + transactionData: Data([0x02]) + ) + siblingProRegTx.involvedAccounts.append(siblingAccount) + context.insert(doomedProRegTx) + context.insert(siblingProRegTx) + try context.save() + + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try handler.deleteWalletData(walletId: doomedId) + + // The sibling's payload-only tx survives with its join intact; + // the deleted wallet's own payload-only tx is swept. + let transactions = try fetch(PersistentTransaction.self, in: container) + XCTAssertEqual(transactions.map(\.txid), [siblingProRegTx.txid]) + XCTAssertEqual( + transactions.first?.involvedAccounts.map(\.wallet.walletId), + [siblingId] + ) + } + func testDeleteWalletDataKeepsNetworkSyncStateWhenSiblingWalletRemains() throws { let container = try DashModelContainer.createInMemory() let context = ModelContext(container) From e1e3dcf04335cf3c46b27a1b4bcd574b37977c7a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 13 Jul 2026 15:48:36 +0700 Subject: [PATCH 3/3] fix(swift-sdk): scope wallet-detail account query to the wallet's network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same 32-byte walletId legitimately exists once per network (same mnemonic imported on mainnet and testnet), so matching the payload- only involvement query on walletId alone folded the sibling network's accounts — and their payload-only txs — into this wallet row's transaction badge. Add wallet.networkRaw to the predicate. Co-Authored-By: Claude Fable 5 --- .../Core/Views/WalletDetailView.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 07ecf9786ac..279e10da6f2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -51,19 +51,28 @@ struct WalletDetailView: View { /// This wallet's accounts, for the payload-only /// `involvedTransactions` contribution to `transactionCount` — /// special txs that matched an account by payload with no TXO, - /// which the `walletTxos` join can't see. + /// which the `walletTxos` join can't see. Scoped to this wallet + /// row's network as well as its walletId: the same 32-byte + /// walletId legitimately exists once per network (same mnemonic + /// imported on mainnet and testnet), and matching on walletId + /// alone would fold the sibling network's payload-only txs into + /// this wallet's badge. @Query private var walletAccounts: [PersistentAccount] init(wallet: PersistentWallet) { self.wallet = wallet let walletId = wallet.walletId + let networkRaw = wallet.networkRaw var descriptor = FetchDescriptor( predicate: #Predicate { $0.walletId == walletId } ) descriptor.propertiesToFetch = [\.walletId] _walletTxos = Query(descriptor) _walletAccounts = Query( - filter: #Predicate { $0.wallet.walletId == walletId } + filter: #Predicate { + $0.wallet.walletId == walletId + && $0.wallet.networkRaw == networkRaw + } ) _walletAssetLocks = Query( filter: PersistentAssetLock.predicate(walletId: walletId),