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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -190,5 +221,6 @@ public final class PersistentAccount {
self.lastUpdated = Date()
self.coreAddresses = []
self.platformAddresses = []
self.involvedTransactions = []
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3730,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<PersistentTransaction>())
for tx in txRows where tx.outputs.isEmpty &&
tx.inputs.isEmpty &&
tx.pendingInputs.isEmpty {
tx.pendingInputs.isEmpty &&
tx.involvedAccounts.isEmpty {
backgroundContext.delete(tx)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Comment thread
QuantumExplorer marked this conversation as resolved.

/// 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<Data> = []
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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -118,13 +149,18 @@ struct AccountDetailView: View {
emptyAddressesCard()
}
}

transactionsCard()
}
}
.padding()
}
}
.navigationTitle(account.accountTypeName)
.navigationBarTitleDisplayMode(.large)
.sheet(item: $selectedTransaction) { transaction in
TransactionDetailView(transaction: transaction)
}
.sheet(isPresented: $showingPINPrompt) {
PINPromptView(
pinInput: $pinInput,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -48,6 +56,9 @@ struct TransactionListView: View {
predicate: #Predicate { $0.walletId == walletId }
)
_walletTxos = Query(txoDescriptor)
_walletAccounts = Query(
filter: #Predicate<PersistentAccount> { $0.wallet.walletId == walletId }
)
let assetLockDescriptor = FetchDescriptor<PersistentAssetLock>(
predicate: PersistentAssetLock.predicate(walletId: walletId)
)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading