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 @@ -23,7 +23,7 @@ public enum DashModelContainer {
PersistentAccount.self,
PersistentCoreAddress.self,
PersistentTransaction.self,
PersistentUtxo.self,
PersistentTxo.self,
PersistentWalletManagerMetadata.self
]
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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.
Expand Down Expand Up @@ -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
Comment on lines +65 to +68
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

This non-optional relationship still has nil-based callers.

After this change, PersistentAccount.wallet can no longer be compared against nil, but the provided example app code still does allAccounts.filter { $0.wallet == nil } and renders an “Unlinked” section in AccountStorageListView. That leaves this PR in a compile-broken state unless those callers are removed or rewritten.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift`
around lines 65 - 68, PersistentAccount.wallet was made non-optional but callers
still perform nil checks (e.g., allAccounts.filter { $0.wallet == nil } and
rendering an “Unlinked” section in AccountStorageListView); update the call
sites to stop checking for nil (remove or rewrite allAccounts.filter { $0.wallet
== nil } usages) and adjust AccountStorageListView to no longer render an
“Unlinked” section or to compute its content from a different criterion, or
alternatively revert wallet to Optional if the semantic requirement remains;
locate references to PersistentAccount.wallet and the AccountStorageListView /
allAccounts.filter usages and make the corresponding cleanup so the codebase
compiles without nil comparisons.


/// 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]

Expand All @@ -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
Expand All @@ -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 = []
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PersistentTransaction>([\.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<PersistentTransaction>([\.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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// Raw transaction bytes.
public var transactionData: Data?
/// Context: 0=mempool, 1=instantSend, 2=inBlock, 3=inChainLockedBlock.
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -94,6 +100,18 @@ public final class PersistentTransaction {

// MARK: - Display Helpers

/// Hex-encoded txid for UI / log sites. The on-disk row stores
/// 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.reversed().map { String(format: "%02x", $0) }.joined()
}

public var contextName: String {
switch context {
case 0: return "Mempool"
Expand Down
Loading
Loading