From 27c7c0810c28df8a31e13183ba597e2f564ed580 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:50:51 +0300 Subject: [PATCH] fix(swift-sdk): act on swept transactions in the SwiftData store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SwiftData mirror of the storage contract, complicated by two things SQLite does not have: rows shared across wallets, and a round that now spans two callbacks. Shared rows are why a sweep marks rather than deletes. A transaction row can belong to several wallets, so the first wallet's callback cannot remove it — it sets `isGloballySwept`, which excludes the row and its outputs from every restore and enumeration path, and the physical delete is left to housekeeping once every wallet's scoped cleanup has landed. A tombstone must likewise outlive its loser: detach it and the consumed coin reads unspent again. Held inputs become pending-input tombstones carrying the winner and, when it was mined, its height; a chained sweep repoints an earlier tombstone at the new winner rather than stacking a second hold. The release pass is outpoint-keyed, the drain gives tombstones precedence over ordinary observations, and `isSpent` stays monotonic against them: a hold the sweep proved consumed is never downgraded by a later record — not even the winner's own, which can arrive IS-locked, a context below in-block. `autosaveEnabled` goes off on the round context. Sweeps travel in their own callback, so the round spans two calls, and an autosave landing between them would make the watermark and the additive rows durable while the removal is still unstaged — with `rollback()` unable to take back a save that already happened. The handler attests `ATOMIC_CHANGESETS`, and Rust now relies on that to trust the split transport, so the guarantee has to be real. The handler declares `CORE_SWEEP_REMOVAL` and `DASHPAY_PAYMENTS`; before this commit it published the legacy `struct_size`, the negotiated slot read `None`, and Rust fail-closed. The four models that gain a column — `PersistentTransaction`, `PersistentTxo`, `PersistentPendingInput`, `PersistentWallet` — were still referenced live by `DashSchemaV1/V2/V3`. Adding a property to a live model mutates those released versions' checksums in place, so a store written by a shipped binary would match no registered schema and fail to open with Cocoa 134504 instead of migrating. That is exactly the defect `DashSchemaFrozenModels.swift` was introduced to prevent, and its instruction is to freeze the model you change. Freezing those four alone is not possible: a frozen model declares its relationships against frozen counterparts (an `inverse:` key path is typed on the destination model), and following relationships in both directions closes over 24 of the 35 models — one type per entity name is all a schema can hold, so the component travels together. All 24 are frozen here at their V3 shape, shared by V1, V2 and V3, none of which changed any of them. The eleven models outside the component are still live-referenced and still carry the latent defect, unchanged by this. `DashSchemaV4` then registers the live models with a lightweight V3→V4 stage: every new column is additive with a default or optional, so existing rows migrate as not-swept, unsuperseded, ordinary unstamped claims, and a wallet with no chainlock boundary yet. `reconcileSpendObservation` stays the single spend verdict, extended with one sweep term — a stamped hold outranks any observation — and its oldest-first pending-row reconciliation stays, under a tombstone-precedence branch. One correction the merge forced: the "never displace confirmed evidence" rule refused the link when `isSpent` was true with NO spender linked, which is precisely the sweep-hold shape, so the winner's own record could never supply the attribution the hold lacked. With no link there is nothing to displace, so it is adopted. One gap neither PR covered is closed here: `buildUnresolvedAssetLockTxRecordBuffer` now skips globally-swept rows, so the double-spend screen can never be handed a swept loser as the settled spender of a lock's input. Also carries the `ChangesetRoundIndex` per-round fetch cache — the reviewed-but-untested fix for the quadratic SwiftData fetch that put ~99% of CPU on the serial queue. Sweep paths deliberately opt out of it, since they key on mutable columns the index cannot answer stale. Tests: `SweptTransactionPersistTests` (38) — shared losers, detached tombstones with a missing winner row, chained tombstones, cross-round reinstatement, released-pending deadlock, co-swept twins, the throwing-lookup round failure, and the winner's late record against a stamped hold. `DashModelMigrationTests` gains the V3→V4 stage and reads V1/V2 rows through the frozen types. Full suite: 437 tests, the only failures being two `KeychainSignerAdditionalSigningKeysTests` cases that fail identically on an unmodified checkout (the test host cannot write to the keychain). --- .../Persistence/DashModelContainer.swift | 113 +- .../Persistence/DashSchemaFrozenModels.swift | 3272 +++++++++++++++++ .../Models/PersistentPendingInput.swift | 44 + .../Models/PersistentTransaction.swift | 17 + .../Persistence/Models/PersistentTxo.swift | 21 + .../Persistence/Models/PersistentWallet.swift | 13 + .../PlatformWalletManager.swift | 20 + .../PlatformWalletPersistenceHandler.swift | 1491 +++++++- .../DashModelMigrationTests.swift | 63 +- .../InvitationPersistenceTests.swift | 21 +- .../SweptTransactionPersistTests.swift | 2815 ++++++++++++++ 11 files changed, 7742 insertions(+), 148 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 0126c3d65be..a9ebbba890f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -51,12 +51,61 @@ public enum DashModelContainer { ] } + + /// The V1/V2/V3 model set: frozen copies for every model in the + /// relationship component (see `DashSchemaFrozenModels.swift`), live + /// types for the eleven models outside it, and `assetLock` for the one + /// model whose shape differs between V2 and V3. + /// + /// Positionally identical to `allModelTypes` — a released version's + /// list must describe exactly the entities that version shipped. + private static func componentFrozenModelTypes( + assetLock: any PersistentModel.Type + ) -> [any PersistentModel.Type] { + [ + DashSchemaV1.PersistentIdentity.self, + DashSchemaV1.PersistentDPNSName.self, + DashSchemaV1.PersistentDashpayProfile.self, + DashSchemaV1.PersistentDashpayContactProfile.self, + DashSchemaV1.PersistentDashpayContactRequest.self, + DashSchemaV1.PersistentDashpayPayment.self, + DashSchemaV1.PersistentDashpayIgnoredSender.self, + DashSchemaV1.PersistentDocument.self, + DashSchemaV1.PersistentDataContract.self, + DashSchemaV1.PersistentPublicKey.self, + DashSchemaV1.PersistentTokenBalance.self, + DashSchemaV1.PersistentKeyword.self, + DashSchemaV1.PersistentToken.self, + DashSchemaV1.PersistentDocumentType.self, + DashSchemaV1.PersistentIndex.self, + DashSchemaV1.PersistentProperty.self, + DashSchemaV1.PersistentTokenHistoryEvent.self, + DashSchemaV1.PersistentPlatformAddress.self, + PersistentPlatformAddressesSyncState.self, + DashSchemaV1.PersistentWallet.self, + DashSchemaV1.PersistentAccount.self, + DashSchemaV1.PersistentCoreAddress.self, + DashSchemaV1.PersistentTransaction.self, + DashSchemaV1.PersistentTxo.self, + DashSchemaV1.PersistentPendingInput.self, + PersistentWalletManagerMetadata.self, + PersistentShieldedNote.self, + PersistentShieldedOutgoingNote.self, + PersistentShieldedSyncState.self, + PersistentShieldedActivity.self, + PersistentShieldedViewingKey.self, + assetLock, + PersistentInvitation.self, + PersistentMasternode.self + ] + } + /// The exact model set registered as schema V1. Keep frozen: staged /// migration identifies an existing store by this schema's checksum, so /// this list may only reference models whose shape is frozen (see /// `DashSchemaFrozenModels.swift`). fileprivate static var v1ModelTypes: [any PersistentModel.Type] { - allModelTypes(assetLock: DashSchemaV1.PersistentAssetLock.self) + componentFrozenModelTypes(assetLock: DashSchemaV1.PersistentAssetLock.self) } /// The exact model set registered as schema V2 — V1 plus @@ -66,17 +115,25 @@ public enum DashModelContainer { v1ModelTypes + [PersistentTrackedMasternode.self] } - /// All persistent model types in the current Dash SDK schema (V3). - /// Unlike `v1ModelTypes` / `v2ModelTypes` this list tracks the LIVE - /// models, so it moves whenever a model gains a property — which is - /// exactly why the released versions above must not. + /// The exact model set registered as schema V3 — V2's frozen component + /// with the LIVE `PersistentAssetLock`, which is the only model V3 + /// changed. Frozen for the same reason as `v1ModelTypes`. + fileprivate static var v3ModelTypes: [any PersistentModel.Type] { + componentFrozenModelTypes(assetLock: PersistentAssetLock.self) + + [PersistentTrackedMasternode.self] + } + + /// All persistent model types in the current Dash SDK schema (V4). + /// Unlike the lists above this one tracks the LIVE models, so it moves + /// whenever a model gains a property — which is exactly why the + /// released versions must not. public static var modelTypes: [any PersistentModel.Type] { allModelTypes(assetLock: PersistentAssetLock.self) + [PersistentTrackedMasternode.self] } /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV3.self) + Schema(versionedSchema: DashSchemaV4.self) } /// Create a persistent model container for storing data @@ -124,13 +181,14 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self] + [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] } public static var stages: [MigrationStage] { [ .lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self), - .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self) + .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) ] } } @@ -251,6 +309,24 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// migrate with a nil `documentIdBase58`, which is the documented /// "no marketplace state tracked" signal — the next marketplace /// sync pass fills them in. +/// - `PersistentTxo` gained the optional `supersededByTxid`, and +/// `PersistentPendingInput` gained `isSweptTombstone` (defaulted +/// `false`). Together they let a sweep's claim on an input whose +/// funding TXO hasn't arrived yet survive the loser transaction's +/// deletion — previously that claim lived only on the doomed row's +/// `PersistentPendingInput`, which cascades away with it. Both +/// additive with defaults ⇒ lightweight migration; existing rows +/// migrate as ordinary (non-tombstone, non-superseded) entries. +/// - `PersistentPendingInput` gained the optional `winnerMinedHeight` +/// (a block-context sweep tombstone's finality stamp — the winner's +/// own mined height) and `PersistentWallet` gained the optional +/// `lastAppliedChainLockHeight` (the numeric chainlock watermark +/// delivered by `on_persist_wallet_changeset_chain_lock_height_fn`, +/// stored monotonic-max). Together they drive the bounded tombstone +/// lifetime: a tombstone is collected exactly when +/// `min(chainlockHeight, syncedHeight)` reaches its stamp. Both +/// optional ⇒ lightweight migration; pre-existing rows read as +/// unstamped (held forever) over a wallet with no boundary yet. /// 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 @@ -296,6 +372,27 @@ public enum DashSchemaV3: VersionedSchema { Schema.Version(3, 0, 0) } + public static var models: [any PersistentModel.Type] { + DashModelContainer.v3ModelTypes + } +} + +/// Version 4 adds the sweep columns: `isGloballySwept` on +/// `PersistentTransaction`, `supersededByTxid` on `PersistentTxo`, +/// `isSweptTombstone` / `winnerMinedHeight` on `PersistentPendingInput`, +/// and `lastAppliedChainLockHeight` on `PersistentWallet`. Every one is +/// additive with a default or optional, so a lightweight migration +/// preserves each existing row: transactions read as not swept, TXOs as +/// unsuperseded, pending inputs as ordinary unstamped claims, and a wallet +/// as having no chainlock boundary yet. +/// +/// Registering it required freezing the whole relationship component those +/// four models sit in — see `DashSchemaFrozenModels.swift`. +public enum DashSchemaV4: VersionedSchema { + public static var versionIdentifier: Schema.Version { + Schema.Version(4, 0, 0) + } + public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift index 72df796e36c..b1417c2fa90 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift @@ -100,3 +100,3275 @@ extension DashSchemaV1 { } } } + +// MARK: - The rest of the relationship component, frozen at the V3 shape +// +// The four models the sweep persistence changes — `PersistentTransaction`, +// `PersistentTxo`, `PersistentPendingInput` and `PersistentWallet` — each +// gain a property, so each needs a frozen copy for the same reason +// `PersistentAssetLock` did. Freezing them alone is not possible: a frozen +// model must declare its relationships against frozen counterparts (an +// `inverse:` key path is typed on the destination model), and following +// those relationships in both directions closes over 24 of the 35 models. +// Registering a frozen copy beside a live one for the SAME entity name is +// what the schema cannot express, so the whole component travels together. +// +// These copies are the shape as of V3 — i.e. everything the live models had +// before the sweep columns — and are shared by V1, V2 and V3, none of which +// changed any model in this component. The eleven models outside the +// component (shielded storage, invitations, masternodes, the asset-lock +// pair above, wallet-manager metadata) are still referenced live and still +// carry the latent defect this file exists to fix; freezing them is the +// same mechanical exercise, for whichever change next touches one. +// +// Do not edit these copies to match the live models. Every attribute, its +// optionality, its default, each `@Attribute` marker, each `#Index` and +// each relationship is an input to the V1/V2/V3 checksums, and changing one +// re-breaks the stores these types exist to keep openable. + +extension DashSchemaV1 { + @Model + final class PersistentAccount { + /// Compound uniqueness on the full account-identity tuple: + /// `(wallet, accountType, accountIndex, standardTag, + /// registrationIndex, keyClass, userIdentityId, + /// friendIdentityId)`. Mirrors the persister's match logic + /// exactly — the variant disambiguators (`standardTag` for + /// BIP44 vs BIP32, `registrationIndex` for top-ups, `keyClass` + /// for PlatformPayment) are part of the key so legitimate + /// sibling accounts can coexist (e.g. BIP44 #0 and BIP32 #0, + /// or multiple top-up accounts on the same identity). + #Unique([ + \.wallet, + \.accountType, + \.accountIndex, + \.standardTag, + \.registrationIndex, + \.keyClass, + \.userIdentityId, + \.friendIdentityId, + ]) + + /// Account type identifier — matches the `AccountTypeTagFFI` + /// discriminant from the Rust side (0 = Standard, 1 = CoinJoin, + /// … 14 = PlatformPayment, 15 = IdentityAuthenticationEcdsa, + /// 16 = IdentityAuthenticationBls). Stable across releases. + var accountType: UInt32 + /// Account index within the type (for indexed account types). For + /// `PlatformPayment` this is the `account` field; for + /// `DashpayReceivingFunds` / `DashpayExternalAccount` it's the + /// account-level selector; for + /// `IdentityAuthentication{Ecdsa,Bls}` it's the identity index. + var accountIndex: UInt32 + /// Human-readable account type name. + var accountTypeName: String + /// Per-account confirmed balance in duffs. + var balanceConfirmed: UInt64 + /// Per-account unconfirmed balance in duffs. + var balanceUnconfirmed: UInt64 + /// External address pool: highest used index (-1 = none). + var externalHighestUsed: Int32 + /// Internal (change) address pool: highest used index. + var internalHighestUsed: Int32 + /// `StandardAccountTypeTagFFI` value. Meaningful only when + /// `accountType == 0` (Standard): 0 = BIP44, 1 = BIP32. + var standardTag: UInt8 + /// `IdentityTopUp.registration_index`. Zero for other variants. + var registrationIndex: UInt32 + /// `PlatformPayment.key_class`. Zero for other variants. + var keyClass: UInt32 + /// `Dashpay*`.user_identity_id (32 bytes). Empty `Data` for other + /// variants. + var userIdentityId: Data + /// `Dashpay*`.friend_identity_id (32 bytes). Empty `Data` for + /// other variants. + var friendIdentityId: Data + /// Bincode-encoded extended public key for this account. For ECDSA + /// accounts it's an `ExtendedPubKey`; for the two provider + /// key-material accounts (`accountType == 10` operator = BLS, + /// `accountType == 11` platform node = Ed25519) it's the extended + /// BLS / Ed25519 public key instead. Populated by + /// `on_persist_account_registrations_fn`, consumed by + /// `on_load_wallet_list_fn` to reconstruct a watch-only account + /// (`Account::from_xpub` for ECDSA, `BLSAccount`/`EdDSAAccount` for + /// the provider accounts). `nil` means "not yet persisted" — + /// account cannot be restored silently. Unique because two + /// accounts can't legitimately share an xpub (would imply a key + /// reuse / derivation collision); SQL UNIQUE allows multiple + /// `nil` values, so freshly-inserted unhydrated rows don't + /// conflict. + @Attribute(.unique) var accountExtendedPubKeyBytes: Data? + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Parent wallet. Every account currently belongs to a wallet. If + /// standalone non-wallet accounts are introduced later, this + /// becomes optional again. + /// + /// Kept non-optional. SwiftData would otherwise fatal during + /// the `save()` phase of a wallet delete + /// (`Cannot remove PersistentWallet from relationship wallet on + /// PersistentAccount because an appropriate default value is + /// not configured`); the workaround is in + /// `PlatformWalletPersistenceHandler.deleteWalletData`, which + /// deletes all of the wallet's accounts in a separate + /// `save()` BEFORE deleting the wallet itself. By the time the + /// wallet row is deleted, its `accounts` collection is empty + /// and SwiftData has no inverse to null out. This costs + /// atomicity (two saves instead of one) — acceptable for a + /// user-initiated wipe. + 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) + var coreAddresses: [PersistentCoreAddress] + + /// DIP-17 Platform Payment addresses for this account, keyed on + /// DIP-0018 bech32m encoding. Populated only when + /// `accountType == 14` (PlatformPayment). + @Relationship(deleteRule: .cascade, inverse: \PersistentPlatformAddress.account) + 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. + var involvedTransactions: [PersistentTransaction] = [] + + init( + wallet: PersistentWallet, + accountType: UInt32, + accountIndex: UInt32, + accountTypeName: String + ) { + self.wallet = wallet + self.accountType = accountType + self.accountIndex = accountIndex + self.accountTypeName = accountTypeName + self.balanceConfirmed = 0 + self.balanceUnconfirmed = 0 + self.externalHighestUsed = -1 + self.internalHighestUsed = -1 + self.standardTag = 0 + self.registrationIndex = 0 + self.keyClass = 0 + self.userIdentityId = Data() + self.friendIdentityId = Data() + self.accountExtendedPubKeyBytes = nil + self.createdAt = Date() + self.lastUpdated = Date() + self.coreAddresses = [] + self.platformAddresses = [] + self.involvedTransactions = [] + } + } + + @Model + final class PersistentCoreAddress { + /// Base58check-encoded address. Unique across the SwiftData store + /// because the same address can't validly exist under two accounts + /// (collision would imply a wallet-id hash collision). + @Attribute(.unique) var address: String + /// Typed public key bytes, or empty Data when the Rust side couldn't + /// produce one (e.g. a pool entry that stored only a script). The + /// curve is given by `keyType`: 33-byte compressed secp256k1 (ECDSA), + /// 48-byte BLS operator key, or 32-byte Ed25519 platform-node key. + var publicKey: Data + /// `KeyTypeTagFFI` raw value identifying the curve of `publicKey`: + /// 0 ECDSA / 1 BLS / 2 EdDSA. Meaningful only when `publicKey` is + /// non-empty. The stored default (NOT just the init-parameter + /// default, which SwiftData migration never consults) keeps + /// pre-column stores openable: without it, lightweight migration + /// fails with "missing attribute values on mandatory destination + /// attribute" and the container refuses to load — a launch crash on + /// every device that has existing rows. Defaulted legacy rows read + /// as ECDSA with an empty `publicKey` until the next Rust + /// address-pool persist pulse (pool extension / address-used / + /// registration — NOT plain load, which only reads the snapshot) + /// re-emits them with typed keys. On load, Rust's + /// `restore_address_pool` keeps the pre-derived typed key when a + /// legacy row arrives key-less, so in-memory BLS operator matching + /// is unaffected; legacy Ed25519 platform-node keys are hardened-only + /// and re-derivable only via delete+re-import (pre-release + /// convention). + var keyType: UInt8 = 0 + /// `AddressPoolTypeTagFFI` raw value — 0 External, 1 Internal, + /// 2 Absent, 3 AbsentHardened. + var poolTypeTag: UInt8 + /// Derivation index within this pool. + var addressIndex: UInt32 + /// BIP32 derivation path (e.g. `"m/44'/1'/0'/0/3"`). + var derivationPath: String + /// Marked used by the Rust address pool (first-seen tx or explicit + /// `mark_used`). + var isUsed: Bool + /// SPV height where this address first appeared in a transaction. + /// Zero until the address is seen on-chain. + var firstSeenHeight: UInt32 + /// SPV height of the most recent transaction touching this address. + var lastSeenHeight: UInt32 + /// Cached balance in duffs from `AddressInfo.balance`. Updated by + /// subsequent `on_persist_account_address_pools_fn` pulses. + var balance: UInt64 + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Parent account. + var account: PersistentAccount? + + /// TXOs paid to this address. Cascade-delete: dropping the + /// address row takes its TXOs with it. The address is the + /// canonical owning record — no meaningful render path for an + /// address-less TXO. Pool rebuilds therefore need to reuse + /// existing rows (the persister upserts by Base58Check string, + /// which it already does) rather than wholesale-replace, or + /// the historical TXO chain gets wiped. + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.coreAddress) + var txos: [PersistentTxo] = [] + + init( + address: String, + publicKey: Data = Data(), + keyType: UInt8 = 0, + poolTypeTag: UInt8, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0 + ) { + self.address = address + self.publicKey = publicKey + self.keyType = keyType + self.poolTypeTag = poolTypeTag + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.balance = balance + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDPNSName { + /// Compound uniqueness on `(networkRaw, normalizedParentDomainName, + /// normalizedLabel)`. Mirrors the DPNS contract's `domain` + /// document index `parentNameAndLabel` + /// (`normalizedParentDomainName + normalizedLabel`, `unique: true`) + /// and adds the network scope so two networks don't collide in a + /// shared local store. A label is only unique within a domain + /// on a given chain. + #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Stays in sync with `identity.networkRaw` + /// via the init; identities don't migrate between networks. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts — matches + /// `PersistentIdentity.network`. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Display label — the original case-and-letters form the user + /// registered, e.g. "Alice". Maps to the DPNS document's + /// `label` property. + var label: String + + /// Homograph-safe lowercase form of `label` used for lookups + /// (e.g. "Alice" → "a11ce"; `o`/`O`→`0`, `i`/`I`→`1`, + /// `l`/`L`→`1`, everything else lowercased). Maps to the DPNS + /// document's `normalizedLabel` property and participates in the + /// per-domain uniqueness above. Computed once on insert from + /// `label` via `Self.normalize(_:)`. + var normalizedLabel: String + + /// Display parent domain — e.g. "dash". Maps to the DPNS + /// document's `parentDomainName` property. DPNS today only + /// supports the single top-level domain "dash", so the persister + /// stamps that as the default; the field exists so subdomain + /// support (when/if DPNS gains it) lands without a schema bump. + var parentDomainName: String + + /// Homograph-safe form of `parentDomainName` used for lookups. + /// Maps to the DPNS document's `normalizedParentDomainName` + /// property and participates in the per-domain uniqueness above. + var normalizedParentDomainName: String + + /// Unix-millis timestamp when the wallet first observed this + /// label belonging to the identity. Mirrors + /// `DpnsNameInfo.acquired_at`. `0` when unknown. + var acquiredAt: UInt64 + + /// Whether the latest canonical identity snapshot still includes this + /// name. Marketplace callbacks never overwrite this value. A name that + /// leaves the wallet keeps its row on the departed identity with `false`; + /// a same-wallet transfer rebinds the unique row to the current identity + /// with `true`. + var isOwned: Bool = true + + // MARK: - Username marketplace + // + // Fed by the `on_persist_dpns_name_states_fn` persister callback + // (`DpnsNameStateFFI`), NOT by the identity label snapshot that + // populates the fields above. All of them are optional or defaulted + // so an existing store migrates in place (SwiftData lightweight + // migration). + // + // READ CONTRACT: every field in this section is meaningful only + // while `documentIdBase58` is non-nil. A nil document id means the + // wallet is not tracking this name's marketplace state — it does NOT + // mean the name is owned and unlisted. Gate any marketplace UI on + // `documentIdBase58 != nil` before reading `saleStatus` or + // `priceCredits`. + + /// Base58 id of the DPNS `domain` document behind this label — the + /// handle every trade transition needs, stable across transfers and + /// purchases. `nil` while no marketplace state has been mirrored (or + /// after the row was dropped from marketplace tracking). + var documentIdBase58: String? + + /// Listed sale price in **credits** (1 duff = 1000 credits), stored + /// as `Int64(bitPattern:)` like `PersistentIdentity.balance` because + /// SwiftData has no unsigned 64-bit column. `nil` = the name is not + /// listed for sale, which is distinct from a 0-credit listing. + var priceCredits: Int64? + + /// Raw ``DpnsNameSaleStatus`` discriminant: 0 = owned, 1 = sold, + /// 2 = transferred. Defaults to 0 so existing rows migrate, so read + /// it through ``saleStatus`` rather than directly. + var saleStatusRaw: Int16 = 0 + + /// Base58 id of the counterparty a departed name went to — the buyer + /// when `saleStatusRaw == 1`, the recipient when it is 2. `nil` while + /// the name is still owned (or the counterparty is unknown). + var counterpartyIdBase58: String? + + /// Domain document `$createdAt` in Unix milliseconds. `nil` when + /// Platform did not carry the timestamp. + var documentCreatedAtMs: UInt64? + + /// Domain document `$updatedAt` in Unix milliseconds. `nil` when + /// Platform did not carry the timestamp. + var documentUpdatedAtMs: UInt64? + + /// Domain document `$transferredAt` in Unix milliseconds. `nil` when + /// Platform did not carry the timestamp. + var documentTransferredAtMs: UInt64? + + /// Unix-millis timestamp of the sync pass / confirmed transition + /// that last wrote the marketplace fields. `0` = never written. + var marketplaceUpdatedAt: UInt64 = 0 + + // MARK: - Relationships + + /// Owning identity. Cascade-deleted from the parent — losing the + /// identity row should drop its label cache too. The `inverse` + /// declaration on `PersistentIdentity.dpnsNames` is the source of + /// truth for this association. + /// + /// Non-optional: every DPNS-label row exists *because* of an + /// identity. The persister wires it at construction time + /// (before insert) so SwiftData's non-optional relationship + /// contract is honored. + var identity: PersistentIdentity + + // MARK: - Timestamps + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + identity: PersistentIdentity, + label: String, + parentDomainName: String = "dash", + acquiredAt: UInt64 = 0, + isOwned: Bool = true + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.label = label + self.normalizedLabel = label.lowercased() + self.parentDomainName = parentDomainName + self.normalizedParentDomainName = parentDomainName.lowercased() + self.acquiredAt = acquiredAt + self.isOwned = isOwned + // A freshly inserted row carries no marketplace state until the + // marketplace persister callback writes it — hence a nil document + // id, which is the "not tracked" signal the read contract above + // documents. + self.documentIdBase58 = nil + self.priceCredits = nil + self.saleStatusRaw = 0 + self.counterpartyIdBase58 = nil + self.documentCreatedAtMs = nil + self.documentUpdatedAtMs = nil + self.documentTransferredAtMs = nil + self.marketplaceUpdatedAt = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayContactProfile { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, + /// contactIdentityId)`. Mirrors the per-owner, per-contact keying of + /// the Rust `contact_profiles` map. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Kept in sync with `owner.networkRaw` by the + /// init. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id, denormalized so + /// `#Predicate` filters can match without a relationship traversal + /// through the `owner` join. Always equal to `owner.identityId` — + /// kept in sync by the persister. + var ownerIdentityId: Data + + /// The contact's 32-byte identity id — the `contact_profiles` map + /// key. Part of the compound unique key above. + var contactIdentityId: Data + + // MARK: - Profile fields + // + // All optional — every `dashpay.profile` document field is optional + // in the contract schema except the implicit `$ownerId`. We mirror + // that so partial profiles (only an `avatarUrl` set, only a + // `displayName` set, etc.) round-trip without forcing placeholders. + + /// `displayName` field on the contact's DashPay `profile` document. + var displayName: String? + + /// `publicMessage` field on the contact's `profile` document. + var publicMessage: String? + + /// `bio` field. Carried for forwards-compat with future contract + /// revisions; reserved here so adding it later doesn't trigger a + /// destructive schema change. + var bio: String? + + /// `avatarUrl` field — URL the consumer fetches + caches locally. + /// The binary asset itself is never persisted. Treated as untrusted + /// (attacker-controlled public data): the Rust side caches and + /// restores it only when it is a bounded `https://` URL. + var avatarUrl: String? + + /// `avatarHash` field — 32-byte hash of the avatar binary, so + /// consumers can verify a fetched asset matches what the contact + /// published. `nil` when the underlying `avatar_hash` was absent. + var avatarHash: Data? + + /// `avatarFingerprint` field — 8-byte perceptual hash for quick + /// equality checks on cached avatars. `nil` when absent. + var avatarFingerprint: Data? + + /// Wall-clock ms of the last fetch attempt on the Rust side + /// (`ContactProfileEntry.checked_at_ms`) — drives the self-heal + /// backoff. Round-tripped verbatim so the restored cache keeps the + /// same re-query schedule it had before relaunch. Stored as the + /// scalar so the predicate engine compares it directly. + var checkedAtMs: UInt64 + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity whose cached + /// contact profiles this row belongs to. Non-optional: every contact + /// profile exists *because of* an owner identity. Cascade-deleted + /// from `PersistentIdentity.contactProfiles`. + var owner: PersistentIdentity + + // MARK: - Timestamps (local row bookkeeping) + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + checkedAtMs: UInt64, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.checkedAtMs = checkedAtMs + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayContactRequest { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, + /// contactIdentityId, isOutgoing)`. Mirrors the per-direction + /// keying the Rust changeset uses on + /// `ContactChangeSet::sent_requests` / + /// `incoming_requests`, scoped by network so two networks don't + /// collide in a shared local store. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId, \.isOutgoing + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Kept in sync with `owner.networkRaw` by the + /// init. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id, denormalized so + /// `#Predicate` filters can match without a relationship traversal + /// through the optional `owner` join. Always equal to + /// `owner.identityId` — kept in sync by the persister. + var ownerIdentityId: Data + + /// Other party's 32-byte identity id. For outgoing rows this is + /// the recipient (`ContactRequest::recipient_id`); for incoming + /// rows this is the sender (`ContactRequest::sender_id`). The + /// `isOutgoing` bit disambiguates which direction this row + /// represents. + var contactIdentityId: Data + + /// Direction bit. `true` ⇒ owner sent this request to contact; + /// `false` ⇒ contact sent this request to owner. Same shape as + /// the Rust `ContactRequestFFI::is_outgoing` field. + var isOutgoing: Bool + + // MARK: - Payload — round-trips `ContactRequest` verbatim + + /// `ContactRequest::sender_key_index` — index of the sender's + /// identity public key used for the ECDH that encrypted the + /// payload. + var senderKeyIndex: UInt32 + + /// `ContactRequest::recipient_key_index`. + var recipientKeyIndex: UInt32 + + /// `ContactRequest::account_reference` — DashPay account derivation + /// hint the sender encoded in the request. + var accountReference: UInt32 + + /// `ContactRequest::encrypted_public_key` bytes. Always non-empty + /// — every contact-request document carries an encrypted key. + var encryptedPublicKey: Data + + /// `ContactRequest::encrypted_account_label` bytes, when present. + /// `nil` mirrors the source `Option` being `None`. + var encryptedAccountLabel: Data? + + /// `ContactRequest::auto_accept_proof` bytes, when present. `nil` + /// mirrors the source `Option` being `None`. + var autoAcceptProof: Data? + + /// `ContactRequest::core_height_created_at` — the Core block + /// height at which the request landed on Platform. + var coreHeightCreatedAt: UInt32 + + /// `ContactRequest::created_at` — Unix-millis timestamp the + /// request document was created. + var createdAtMillis: UInt64 + + /// Whether the established relationship this row belongs to has a + /// **permanently broken** payment channel. Mirrors + /// `ContactRequestFFI::payment_channel_broken`: only meaningful + /// for rows projected from the `established` map — both + /// directions of an established pair carry the same flag (it's a + /// property of the relationship, not of one direction). Always + /// `false` for pending rows. The UI reads it to disable "Send + /// Dash" and surface "payment channel broken — ask the contact to + /// send a new request". + /// + /// Defaulted so existing rows ride SwiftData's lightweight + /// migration (additive column, non-destructive). + var paymentChannelBroken: Bool = false + + /// Owner-private alias for the contact — `contactInfo`-backed, + /// synced across devices via Platform. Mirrors + /// `ContactRequestFFI::alias`; established rows only, replicated + /// onto both directions like `paymentChannelBroken`. Optional so + /// existing rows ride the lightweight migration. + var contactAlias: String? + + /// Owner-private note — same conventions as `contactAlias`. + var contactNote: String? + + /// `contactInfo.displayHidden` — whether the owner hid this + /// contact from the list. Defaulted for lightweight migration. + var contactHidden: Bool = false + + /// The contact's decrypted DIP-15 `encryptedAccountLabel` — the label + /// the contact chose for the account they shared (a payment-routing + /// hint, e.g. "Main wallet"). **System-derived and read-only**, unlike + /// the owner-private `contactAlias`/`contactNote`: it is decrypted in + /// Rust from the contact's incoming request, so it is populated only on + /// the incoming-direction row (the outgoing row carries a label *we* + /// sent, which is not surfaced). Optional so existing rows ride the + /// lightweight migration. + var contactAccountLabel: String? + + /// `EstablishedContact::accepted_accounts` — the DIP-15 + /// rotated-account acceptances for this relationship. Mirrors + /// `ContactRequestFFI::accepted_accounts`: a property of the + /// relationship (not one direction), so it is replicated onto + /// both directions like `paymentChannelBroken`; always empty for + /// pending rows. Defaulted to an empty array so existing rows + /// ride SwiftData's lightweight migration. + var contactAcceptedAccounts: [UInt32] = [] + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity this row's + /// `ownerIdentityId` denormalizes. Non-optional: every + /// contact-request row exists *because of* an owner identity. + /// Cascade-deleted from `PersistentIdentity.contactRequests`. + var owner: PersistentIdentity + + // MARK: - Timestamps + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + isOutgoing: Bool, + senderKeyIndex: UInt32, + recipientKeyIndex: UInt32, + accountReference: UInt32, + encryptedPublicKey: Data, + encryptedAccountLabel: Data? = nil, + autoAcceptProof: Data? = nil, + coreHeightCreatedAt: UInt32, + createdAtMillis: UInt64, + paymentChannelBroken: Bool = false + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.isOutgoing = isOutgoing + self.senderKeyIndex = senderKeyIndex + self.recipientKeyIndex = recipientKeyIndex + self.accountReference = accountReference + self.encryptedPublicKey = encryptedPublicKey + self.encryptedAccountLabel = encryptedAccountLabel + self.autoAcceptProof = autoAcceptProof + self.coreHeightCreatedAt = coreHeightCreatedAt + self.createdAtMillis = createdAtMillis + self.paymentChannelBroken = paymentChannelBroken + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayIgnoredSender { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, + /// ignoredSenderId)` — the Rust per-sender suppression key, scoped by + /// network so two networks don't collide in a shared store. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.ignoredSenderId + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue`, kept + /// in sync with `owner.networkRaw` by the init. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` if + /// the stored raw value drifts. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id — the recipient that + /// ignored the sender. Denormalized so `#Predicate` filters match + /// without a relationship traversal. Always equal to + /// `owner.identityId`. + var ownerIdentityId: Data + + /// The 32-byte id of the ignored sender. The per-sender suppression + /// key — no `accountReference`, so ALL of this sender's requests are + /// suppressed. + var ignoredSenderId: Data + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity that ignored the + /// sender. Non-optional: an ignore exists *because of* an owner + /// identity. Cascade-deleted from + /// `PersistentIdentity.dashpayIgnoredSenders`. + var owner: PersistentIdentity + + // MARK: - Timestamps (local row bookkeeping) + + var ignoredAt: Date + + // MARK: - Initialization + + init( + owner: PersistentIdentity, + ignoredSenderId: Data + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.ignoredSenderId = ignoredSenderId + self.ignoredAt = Date() + } + } + + @Model + final class PersistentDashpayPayment { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, txid)`. + /// Mirrors the per-identity txid keying of the Rust + /// `dashpay_payments` map. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.txid + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Kept in sync with `owner.networkRaw` by the + /// init. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id, denormalized so + /// `#Predicate` filters can match without a relationship traversal + /// through the `owner` join. Always equal to `owner.identityId` — + /// kept in sync by the refresh path. + var ownerIdentityId: Data + + /// The other identity in this payment + /// (`DashpayPaymentFFI::counterparty_id`). Whether they are the + /// sender or the receiver is encoded in `directionRaw`. + var counterpartyIdentityId: Data + + /// Amount in duffs. Always positive; `directionRaw` carries the + /// sign. + var amountDuffs: UInt64 + + /// Raw `DashPayPaymentDirection` value. Stored as the scalar so + /// the predicate engine compares it directly. + var directionRaw: UInt8 + + /// Type-safe accessor over `directionRaw`. Falls back to `.sent` + /// if the stored raw value drifts. + var direction: DashPayPaymentDirection { + get { DashPayPaymentDirection(rawValue: directionRaw) ?? .sent } + set { directionRaw = newValue.rawValue } + } + + /// Raw `DashPayPaymentStatus` value. + var statusRaw: UInt8 + + /// Type-safe accessor over `statusRaw`. Falls back to `.pending` + /// if the stored raw value drifts. + var status: DashPayPaymentStatus { + get { DashPayPaymentStatus(rawValue: statusRaw) ?? .pending } + set { statusRaw = newValue.rawValue } + } + + /// Transaction id (hex), the Rust `dashpay_payments` map key. + /// Part of the compound unique key above. + var txid: String + + /// Sender memo, when present. `nil` mirrors the source `Option` + /// being `None`. + var memo: String? + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity whose payment + /// history this row belongs to. Non-optional: every payment row + /// exists *because of* an owner identity. Cascade-deleted from + /// `PersistentIdentity.dashpayPayments`. + var owner: PersistentIdentity + + // MARK: - Timestamps (local row bookkeeping, not payment dates) + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + owner: PersistentIdentity, + counterpartyIdentityId: Data, + amountDuffs: UInt64, + direction: DashPayPaymentDirection, + status: DashPayPaymentStatus, + txid: String, + memo: String? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.counterpartyIdentityId = counterpartyIdentityId + self.amountDuffs = amountDuffs + self.directionRaw = direction.rawValue + self.statusRaw = status.rawValue + self.txid = txid + self.memo = memo + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayProfile { + /// Compound uniqueness on `(networkRaw, identity)`. Mirrors the + /// DashPay contract's per-`ownerId` uniqueness on the `profile` + /// document, scoped by network so two networks don't collide in a + /// shared local store. + #Unique([\.networkRaw, \.identity]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Stays in sync with `identity.networkRaw` + /// (set by the init); identities don't migrate between networks. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts — matches + /// `PersistentIdentity.network`. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // MARK: - Profile fields + // + // All optional — every `dashpay.profile` document field is + // optional in the contract schema except the implicit + // `$ownerId`. We mirror that on the row so partial profiles + // (only an `avatarUrl` set, only a `displayName` set, etc.) + // round-trip without forcing placeholder values. + + /// `displayName` field on the DashPay `profile` document. Up to + /// 25 chars per the contract schema. + var displayName: String? + + /// `publicMessage` field on the DashPay `profile` document. Up to + /// 140 chars per the contract schema. + var publicMessage: String? + + /// `bio` field. Not part of the v3 DashPay contract today; the + /// FFI carries the slot for forwards-compat with future contract + /// revisions and the column is reserved here so adding it doesn't + /// trigger a destructive schema change. + var bio: String? + + /// `avatarUrl` field. URL string the consumer is expected to + /// fetch + cache locally; the binary asset itself is never + /// persisted on this row. + var avatarUrl: String? + + /// `avatarHash` field — 32-byte hash of the avatar binary, + /// stored alongside the URL so consumers can verify the fetched + /// asset matches what the profile author published. `nil` when + /// the underlying `avatar_hash` was `None`. + var avatarHash: Data? + + /// `avatarFingerprint` field — 8-byte perceptual hash for + /// quick equality checks on cached avatars without rehashing the + /// full asset. `nil` when the underlying `avatar_fingerprint` + /// was `None`. + var avatarFingerprint: Data? + + // MARK: - Relationships + + /// Owning identity. Non-optional — a profile only exists in the + /// context of an identity. Cascade-deleted from the parent's + /// `dashpayProfile` relationship; the persister wires this up at + /// construction time. + var identity: PersistentIdentity + + // MARK: - Timestamps + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + identity: PersistentIdentity, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDataContract { + /// Index `networkRaw` so the static `predicate(networkRaw:)` and + /// `tokensPredicate(networkRaw:)` helpers — plus every per-network + /// list view — can index-scan instead of table-scan. + #Index([\.networkRaw]) + + @Attribute(.unique) var id: Data + var name: String + var serializedContract: Data + var createdAt: Date + var lastAccessedAt: Date + + // Binary serialization (CBOR format) + var binarySerialization: Data? + + // Version info + var version: Int? + var ownerId: Data? + + // Keywords and description + @Relationship(deleteRule: .cascade, inverse: \PersistentKeyword.dataContract) + var keywordRelations: [PersistentKeyword] + var contractDescription: String? + + // Schema and document types storage + var schemaData: Data + var documentTypesData: Data + + // Groups + var groupsData: Data? + + // Network + /// Stored as the `Network.rawValue` `UInt32` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Setter writes through. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // Timestamps + var lastUpdated: Date + var lastSyncedAt: Date? + + // Contract configuration + var canBeDeleted: Bool + var readonly: Bool + var keepsHistory: Bool + var schemaDefs: Int? + + // Document defaults + var documentsKeepHistoryContractDefault: Bool + var documentsMutableContractDefault: Bool + var documentsCanBeDeletedContractDefault: Bool + + // Relationships with cascade delete + @Relationship(deleteRule: .cascade, inverse: \PersistentToken.dataContract) + var tokens: [PersistentToken]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocumentType.dataContract) + var documentTypes: [PersistentDocumentType]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.dataContract) + var documents: [PersistentDocument] + + // Owner identity — populated when the owner happens to also live in + // the local store. May be nil even when `ownerId` is set, because + // most contracts in the local cache will be owned by identities the + // user doesn't hold. Back-filled lazily by + // `ContractIdentityLinker.linkContractToOwner` when either side is + // inserted. + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.ownedDataContracts) + var ownerIdentity: PersistentIdentity? + + // Token support tracking + var hasTokens: Bool + var tokensData: Data? + + // Computed properties + var idBase58: String { + id.toBase58String() + } + + var ownerIdBase58: String? { + ownerId?.toBase58String() + } + + var parsedContract: [String: Any]? { + try? JSONSerialization.jsonObject(with: serializedContract, options: []) as? [String: Any] + } + + var binarySerializationHex: String? { + binarySerialization?.toHexString() + } + + var keywords: [String] { + keywordRelations.map { $0.keyword } + } + + var schema: [String: Any] { + get { + guard let json = try? JSONSerialization.jsonObject(with: schemaData), + let dict = json as? [String: Any] else { + return [:] + } + return dict + } + set { + schemaData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var documentTypesList: [String] { + get { + guard let json = try? JSONSerialization.jsonObject(with: documentTypesData), + let array = json as? [String] else { + return [] + } + return array + } + set { + documentTypesData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var tokenConfigurations: [String: Any]? { + get { + guard let data = tokensData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + tokensData = try? JSONSerialization.data(withJSONObject: newValue) + hasTokens = true + } else { + tokensData = nil + hasTokens = false + } + lastUpdated = Date() + } + } + + var groups: [String: Any]? { + get { + guard let data = groupsData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + groupsData = try? JSONSerialization.data(withJSONObject: newValue) + } else { + groupsData = nil + } + lastUpdated = Date() + } + } + + init( + id: Data, + name: String, + serializedContract: Data, + version: Int? = 1, + ownerId: Data? = nil, + schema: [String: Any] = [:], + documentTypesList: [String] = [], + keywords: [String] = [], + description: String? = nil, + hasTokens: Bool = false, + network: Network + ) { + self.id = id + self.name = name + self.serializedContract = serializedContract + self.createdAt = Date() + self.lastAccessedAt = Date() + self.version = version + self.ownerId = ownerId + + // Schema and document types + self.schemaData = (try? JSONSerialization.data(withJSONObject: schema)) ?? Data() + self.documentTypesData = (try? JSONSerialization.data(withJSONObject: documentTypesList)) ?? Data() + + // Keywords + self.keywordRelations = keywords.map { PersistentKeyword(keyword: $0, contractId: id.toBase58String()) } + self.contractDescription = description + + // Tokens + self.hasTokens = hasTokens + self.tokensData = nil + + // Groups + self.groupsData = nil + + // Documents + self.documents = [] + + // Owner identity link is back-filled later by + // `ContractIdentityLinker`. Initialise explicitly because + // SwiftData's auto-init of optional relationships has + // historically been flaky enough in this codebase to be + // worth the line. + self.ownerIdentity = nil + + // Network and timestamps + self.networkRaw = network.rawValue + self.lastUpdated = Date() + self.lastSyncedAt = nil + + // Default values for contract configuration + self.canBeDeleted = false + self.readonly = false + self.keepsHistory = false + self.documentsKeepHistoryContractDefault = false + self.documentsMutableContractDefault = true + self.documentsCanBeDeletedContractDefault = true + } + + func updateLastAccessed() { + self.lastAccessedAt = Date() + } + + func updateVersion(_ newVersion: Int) { + self.version = newVersion + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func addDocument(_ document: PersistentDocument) { + documents.append(document) + lastUpdated = Date() + } + + func removeDocument(withId documentId: String) { + if let docIdData = Data.identifier(fromBase58: documentId) { + documents.removeAll { $0.id == docIdData } + } + lastUpdated = Date() + } + } + + @Model + final class PersistentDocument { + /// Index `networkRaw` to keep per-network document scans + /// index-served. The static `predicate(contractId:network:)` helper + /// and every UI list view filter by the active network. + #Index([\.networkRaw]) + + // Primary key + @Attribute(.unique) var documentId: String + + // Core document properties + var documentType: String + var revision: Int32 + var data: Data + + // References (stored as strings for queries) + var contractId: String + var ownerId: String + + // Binary data for efficient operations + var contractIdData: Data + var ownerIdData: Data + + // Timestamps + var createdAt: Date + var updatedAt: Date + var transferredAt: Date? + + // Block heights + var createdAtBlockHeight: Int64? + var updatedAtBlockHeight: Int64? + var transferredAtBlockHeight: Int64? + + // Core block heights + var createdAtCoreBlockHeight: Int64? + var updatedAtCoreBlockHeight: Int64? + var transferredAtCoreBlockHeight: Int64? + + // Network + /// Stored as the `Network.rawValue` `UInt32` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Setter writes through. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // Deletion flag + var isDeleted: Bool = false + + // Local tracking + var localCreatedAt: Date + var localUpdatedAt: Date + + // Relationships + var documentType_relation: PersistentDocumentType? + var dataContract: PersistentDataContract? + + // Optional reference to local identity (if owner is local) + var ownerIdentity: PersistentIdentity? + + // Computed properties + var id: Data { + Data.identifier(fromBase58: documentId) ?? Data() + } + + var idBase58: String { + documentId + } + + var ownerIdBase58: String { + ownerId + } + + var contractIdBase58: String { + contractId + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var displayTitle: String { + guard let props = properties else { return "Document" } + + if let title = props["title"] as? String { return title } + if let name = props["name"] as? String { return name } + if let label = props["label"] as? String { return label } + if let normalizedLabel = props["normalizedLabel"] as? String { return normalizedLabel } + + return documentType + } + + var summary: String { + var parts: [String] = [] + + parts.append("Type: \(documentType)") + parts.append("Rev: \(revision)") + + // Pin to Gregorian so the `createdAt` year stays CE even + // when the device is configured for a non-Gregorian + // calendar (e.g. Thai region → Buddhist era). The SDK + // doesn't depend on the app's `AppDate` helper, so we + // configure the formatter inline. + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateStyle = .short + parts.append("Created: \(formatter.string(from: createdAt))") + + return parts.joined(separator: " • ") + } + + init( + documentId: String, + documentType: String, + revision: Int32, + data: Data, + contractId: String, + ownerId: String, + network: Network + ) { + self.documentId = documentId + self.documentType = documentType + self.revision = revision + self.data = data + self.contractId = contractId + self.ownerId = ownerId + self.contractIdData = Data.identifier(fromBase58: contractId) ?? Data() + self.ownerIdData = Data.identifier(fromBase58: ownerId) ?? Data() + self.networkRaw = network.rawValue + self.createdAt = Date() + self.updatedAt = Date() + self.localCreatedAt = Date() + self.localUpdatedAt = Date() + } + + // MARK: - Methods + func updateProperties(_ newData: Data) { + self.data = newData + self.updatedAt = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = Int32(newRevision) + self.updatedAt = Date() + } + + func markAsDeleted() { + self.isDeleted = true + self.updatedAt = Date() + } + + // MARK: - Static Methods + static func predicate(documentId: String) -> Predicate { + #Predicate { doc in + doc.documentId == documentId && doc.isDeleted == false + } + } + + static func predicate(contractId: String, network: Network) -> Predicate { + // See `PersistentIdentity.predicate(network:)` — Foundation's + // predicate engine can't capture `Network`, so we filter on + // the UInt32-backed `networkRaw` shadow field. + let target = network.rawValue + return #Predicate { doc in + doc.contractId == contractId && doc.networkRaw == target && doc.isDeleted == false + } + } + + static func predicate(ownerId: Data) -> Predicate { + let ownerIdString = ownerId.toBase58String() + return #Predicate { doc in + doc.ownerId == ownerIdString && doc.isDeleted == false + } + } + + // MARK: - Identity Linking + func linkToLocalIdentityIfNeeded(in modelContext: ModelContext) { + guard ownerIdentity == nil else { return } + + let ownerIdToMatch = self.ownerIdData + let identityPredicate = #Predicate { identity in + identity.identityId == ownerIdToMatch && identity.isLocal == true + } + + let descriptor = FetchDescriptor(predicate: identityPredicate) + + do { + if let localIdentity = try modelContext.fetch(descriptor).first { + self.ownerIdentity = localIdentity + self.localUpdatedAt = Date() + } + } catch { + print("Failed to link document to local identity: \(error)") + } + } + } + + @Model + final class PersistentDocumentType { + @Attribute(.unique) var id: Data + var contractId: Data + var name: String + + // Schema stored as JSON + var schemaJSON: Data + var propertiesJSON: Data + + // Document behavior settings + var documentsKeepHistory: Bool + var documentsMutable: Bool + var documentsCanBeDeleted: Bool + var documentsTransferable: Bool + + // indexOnly storage mode (meta-schema v3, protocol version 14): no + // stored rows — the index entries ARE the documents + var indexOnly: Bool = false + + // Required fields + var requiredFieldsJSON: Data? + + // Security + var securityLevel: Int + + // Trade and creation restrictions + var tradeMode: Int + var creationRestrictionMode: Int + + // Identity encryption keys + var requiresIdentityEncryptionBoundedKey: Bool + var requiresIdentityDecryptionBoundedKey: Bool + + // Timestamps + var createdAt: Date + var lastAccessedAt: Date + + // Relationship to data contract + var dataContract: PersistentDataContract? + + // Relationship to documents + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.documentType_relation) + var documents: [PersistentDocument]? + + // Relationship to indices + @Relationship(deleteRule: .cascade, inverse: \PersistentIndex.documentType) + var indices: [PersistentIndex]? + + // Relationship to properties + @Relationship(deleteRule: .cascade, inverse: \PersistentProperty.documentType) + var propertiesList: [PersistentProperty]? + + init(contractId: Data, name: String, schemaJSON: Data, propertiesJSON: Data) { + // Create unique ID by combining contract ID and name + var idData = contractId + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.name = name + self.schemaJSON = schemaJSON + self.propertiesJSON = propertiesJSON + self.documentsKeepHistory = false + self.documentsMutable = true + self.documentsCanBeDeleted = true + self.documentsTransferable = false + self.securityLevel = 0 + self.tradeMode = 0 + self.creationRestrictionMode = 0 + self.requiresIdentityEncryptionBoundedKey = false + self.requiresIdentityDecryptionBoundedKey = false + self.createdAt = Date() + self.lastAccessedAt = Date() + } + } + + @Model + final class PersistentIdentity { + /// Index `networkRaw` so per-network scans (`#Predicate { $0.networkRaw == raw }`) + /// don't degrade to a table scan. Every UI surface that lists + /// identities filters by the active network. + #Index([\.networkRaw]) + + // MARK: - Core Properties + @Attribute(.unique) var identityId: Data + var balance: Int64 + var revision: Int64 + /// `true` iff this identity is YOURS or deliberately tracked on + /// this device, two ways in: + /// - wallet-derived: identities of a wallet on this device are + /// ALWAYS local — the persister promotes the flag when it + /// attaches the `wallet` relationship, and the startup heal + /// repairs rows persisted before that rule existed; + /// - manually added: the user loaded/watched the identity via a + /// UI flow (LoadIdentityView by id/name), which marks its own + /// row (the initializer default `true` matches — a directly + /// constructed row is a manual add). + /// + /// `false` only for incidental rows — observed foreign + /// identities materialized by sync that nobody asked to track. + /// The flag is PROMOTE-ONLY: no sync path ever writes `false` + /// over a `true` (a manual mark must survive Platform data + /// flowing over the row, and losing a wallet link doesn't + /// un-track an identity). + /// + /// It makes no claim about signing capability — compute that + /// live where needed; wallet-owned filtering has + /// `walletOwnedIdentitiesPredicate`. + var isLocal: Bool + var alias: String? + /// User's chosen primary display label (the one rendered on + /// list rows and avatars). Populated only when the user selects a + /// main name from `mainDpnsName` selection or as the fallback set + /// during initial registration. The full label collection lives on + /// the `dpnsNames` relationship below; this scalar is just the + /// "show this one in the cell" hint. + var dpnsName: String? + var mainDpnsName: String? + var identityType: String + + // MARK: - Special Key Storage (stored in keychain) + var votingPrivateKeyIdentifier: String? + var ownerPrivateKeyIdentifier: String? + var payoutPrivateKeyIdentifier: String? + + // MARK: - Public Keys + @Relationship(deleteRule: .cascade) var publicKeys: [PersistentPublicKey] + + // MARK: - Timestamps + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + // MARK: - Network + /// Stored as the `Network.rawValue` `UInt32` so SwiftData + /// `#Predicate` expressions can evaluate it directly. Foundation's + /// predicate engine rejects captured non-primitive types — even + /// Codable raw-value enums crash at evaluation with + /// "Unsupported Predicate: Captured/constant values of type + /// 'Network' are not supported". The `network` computed + /// accessor below keeps the public API type-safe; only predicates + /// that need to filter by network reach for `networkRaw`. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Reads fall back to + /// `.testnet` if the stored raw value ever drifts out of the + /// `Network` range (shouldn't happen — writers only go through + /// this setter which uses `Network.rawValue`). + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // MARK: - Wallet Association + // + // Cardinality: an identity belongs to 0 or 1 wallet. A wallet + // holds N identities (see `PersistentWallet.identities`). When + // the wallet is deleted, `wallet` nulls out (deleteRule: + // `.nullify`) and the identity row survives orphaned. + // + // The `wallet` reference is the single source of truth — there + // is no denormalized scalar `walletId`. Callers that want the + // 32-byte wallet id read `identity.wallet?.walletId`; + // predicates filter with `$0.wallet?.walletId == target`. + // `@Relationship` is declared on the `PersistentWallet` side + // (`identities`, with `inverse: \PersistentIdentity.wallet`), + // so this is a plain stored property. + var wallet: PersistentWallet? + /// DIP-9 identity index within the owning wallet. Mirrors the + /// `identity_index` carried on `IdentityEntryFFI` from Rust. + /// Only meaningful when `wallet != nil`; defaults to 0 + /// otherwise. Used to stable-sort identities within a wallet + /// (e.g. when grouping public keys by identity). + var identityIndex: UInt32 = 0 + + // MARK: - Relationships + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.ownerIdentity) var documents: [PersistentDocument] + @Relationship(deleteRule: .nullify) var tokenBalances: [PersistentTokenBalance] + + /// Confirmed DPNS labels observed for this identity. Cascade-deleted from + /// the parent — losing the identity row drops the label cache and retained + /// marketplace history too. A name that leaves this wallet remains related + /// to its departed identity for history with + /// `PersistentDPNSName.isOwned == false`. A transfer to another identity in + /// the same wallet instead rebinds the schema's single unique-name row to + /// the current owner. Owned-name surfaces use + /// `PersistentDPNSName.predicate(identityId:)`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDPNSName.identity) + var dpnsNames: [PersistentDPNSName] = [] + + /// DashPay profile cache for this identity — at most one row per + /// (network, identity) per the contract's per-`ownerId` + /// uniqueness on the `profile` document. Cascade-deleted from the + /// parent. Optional because not every identity has published a + /// profile (and the FFI changeset's `dashpay_profile: None` + /// semantics mean "no update", not "delete" — the persister never + /// nils this out from a flush). Inserted / refreshed by + /// `PlatformWalletPersistenceHandler.upsertDashpayProfile(...)`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayProfile.identity) + var dashpayProfile: PersistentDashpayProfile? + + /// DashPay contact-request rows owned by this identity (both + /// outgoing and incoming). Cascade-deleted from the parent. Same + /// query-by-denormalized-id pattern as `dpnsNames`: filters use + /// `PersistentDashpayContactRequest.predicate(ownerIdentityId:)` + /// rather than walking this collection from a SwiftUI view. + /// Append / overwrite / delete on the write path: the persister + /// callback applies upserts (per `(owner, contact, isOutgoing)`) + /// and tombstones (`removed_sent` / `removed_incoming`) directly. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactRequest.owner) + var contactRequests: [PersistentDashpayContactRequest] = [] + + /// DashPay payment-history rows owned by this identity. + /// Cascade-deleted from the parent. Same + /// query-by-denormalized-id pattern as `contactRequests`: filters + /// use `PersistentDashpayPayment.predicate(ownerIdentityId:)` + /// rather than walking this collection from a SwiftUI view. + /// Populated by `PlatformWalletManager.refreshDashPayPayments` + /// (FFI getter → upsert), not by the persister callback. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayPayment.owner) + var dashpayPayments: [PersistentDashpayPayment] = [] + + /// DashPay ignored senders (per-sender mute, = block, reversible, + /// local-only) owned by this identity. Cascade-deleted from the parent. + /// Persisted from the `ignored` changeset array by `persistContacts` + /// and read back at load to rebuild the Rust `ignored_senders` set — + /// without them an ignored sender resurfaces on relaunch. Filters use + /// `PersistentDashpayIgnoredSender.predicate(ownerIdentityId:)`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayIgnoredSender.owner) + var dashpayIgnoredSenders: [PersistentDashpayIgnoredSender] = [] + + /// Cached DashPay **contact** profiles owned by this identity (one + /// per contact whose public profile has been fetched). Cascade-deleted + /// from the parent. Same query-by-denormalized-id pattern as + /// `contactRequests`: filters use + /// `PersistentDashpayContactProfile.predicate(ownerIdentityId:)` rather + /// than walking this collection from a SwiftUI view. Populated by the + /// persister callback (`IdentityEntryFFI.contact_profiles` rows) and + /// read back at load to rebuild the Rust `contact_profiles` map. + /// Distinct from the owner's own `dashpayProfile`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactProfile.owner) + var contactProfiles: [PersistentDashpayContactProfile] = [] + + // Contracts in the local store that name this identity as their + // owner. `.nullify` so deleting the identity leaves the contract + // rows alive (with `ownerIdentity` nulled) — matches the user's + // intent that contracts persist independently of whether the owner + // identity happens to be loaded. + // The `@Relationship` macro is declared on the contract side + // (`PersistentDataContract.ownerIdentity`) so this is a plain + // stored property — see `wallet` above for the same pattern. + var ownedDataContracts: [PersistentDataContract] + + // MARK: - Initialization + init( + identityId: Data, + balance: Int64 = 0, + revision: Int64 = 0, + isLocal: Bool = true, + alias: String? = nil, + dpnsName: String? = nil, + mainDpnsName: String? = nil, + identityType: IdentityType = .user, + votingPrivateKeyIdentifier: String? = nil, + ownerPrivateKeyIdentifier: String? = nil, + payoutPrivateKeyIdentifier: String? = nil, + network: Network, + identityIndex: UInt32 = 0 + ) { + self.identityId = identityId + self.balance = balance + self.revision = revision + self.isLocal = isLocal + self.alias = alias + self.dpnsName = dpnsName + self.mainDpnsName = mainDpnsName + self.identityType = identityType.rawValue + self.votingPrivateKeyIdentifier = votingPrivateKeyIdentifier + self.ownerPrivateKeyIdentifier = ownerPrivateKeyIdentifier + self.payoutPrivateKeyIdentifier = payoutPrivateKeyIdentifier + self.networkRaw = network.rawValue + self.identityIndex = identityIndex + self.publicKeys = [] + self.documents = [] + self.tokenBalances = [] + self.dpnsNames = [] + self.dashpayProfile = nil + self.contactRequests = [] + self.dashpayPayments = [] + self.dashpayIgnoredSenders = [] + self.contactProfiles = [] + self.ownedDataContracts = [] + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + } + + // MARK: - Computed Properties + var identityIdString: String { + identityId.toHexString() + } + + var identityIdBase58: String { + identityId.toBase58String() + } + + var formattedBalance: String { + let dashAmount = Double(balance) / 100_000_000_000 + return String(format: "%.8f DASH", dashAmount) + } + + /// User-facing short name. Priority: `alias` → `mainDpnsName` + /// → `dpnsName` → truncated hex id. Mirrors the old + /// `IdentityModel.displayName` extension so views that read + /// this don't change behavior post-migration. + var displayName: String { + if let alias = alias, !alias.isEmpty { + return alias + } + if let mainDpnsName = mainDpnsName, !mainDpnsName.isEmpty { + return mainDpnsName + } + if let dpnsName = dpnsName, !dpnsName.isEmpty { + return dpnsName + } + return String(identityIdString.prefix(12)) + "..." + } + + var identityTypeEnum: IdentityType { + IdentityType(rawValue: identityType) ?? .user + } + + // MARK: - Methods + func updateBalance(_ newBalance: Int64) { + self.balance = newBalance + self.lastUpdated = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = newRevision + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func updateDPNSName(_ name: String?) { + self.dpnsName = name + self.lastUpdated = Date() + } + + func addPublicKey(_ key: PersistentPublicKey) { + publicKeys.append(key) + lastUpdated = Date() + } + + func removePublicKey(withId keyId: Int32) { + publicKeys.removeAll { $0.keyId == keyId } + lastUpdated = Date() + } + } + + @Model + final class PersistentIndex { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + // Index configuration + var unique: Bool + var nullSearchable: Bool + var contested: Bool + + // Count / sum axes (meta-schema v3, protocol version 14). Every + // keyword is persisted VERBATIM as authored in the contract JSON — + // `countable` keeps its boolean-or-string spelling ("true" / + // "countable" / "countableAllowingOffset"), and the `averageable` / + // `rangeAverageable` sugar is stored as-is rather than desugared. + // Interpreting the spellings (DPP's normalization rules) is protocol + // logic and stays out of the SDK; display layers map them for + // presentation. + var countable: String? + var rangeCountable: Bool = false + var summable: String? + var rangeSummable: Bool = false + var averageable: String? + var rangeAverageable: Bool = false + + // Ranking axes (each adds one ordered secondary tree) + var rankedCountable: Bool = false + var rankedSummable: Bool = false + var rankedAverageable: Bool = false + + // indexOnly member key (the property whose value keys each entry). + // Persisted only when declared; an omitted terminal on an indexOnly + // type means $ownerId per DPP, a default display layers apply. + var terminal: String? + + // Preallocation: creating the refersTo-referenced document also + // creates this index's trees, and deleting the last entry keeps them + var preallocated: Bool = false + + // Time-range bucketing transform ({on, range, step, phase}), if any + var timeRangeJSON: Data? + + // Properties in the index with sorting + var propertiesJSON: Data + + // Contested details (if contested) + var contestedDetailsJSON: Data? + + // Timestamps + var createdAt: Date + + // Relationship to document type + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, properties: [String]) { + // Create unique ID by combining contract ID, document type name, and index name + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.unique = false + self.nullSearchable = false + self.contested = false + + // Store properties as JSON array + if let jsonData = try? JSONSerialization.data(withJSONObject: properties, options: []) { + self.propertiesJSON = jsonData + } else { + self.propertiesJSON = Data() + } + + self.createdAt = Date() + } + } + + @Model + final class PersistentKeyword { + @Attribute(.unique) var id: String + var keyword: String + var contractId: String + + // Relationship + var dataContract: PersistentDataContract? + + init(keyword: String, contractId: String) { + self.id = "\(contractId)_\(keyword)" + self.keyword = keyword + self.contractId = contractId + } + } + + @Model + final class PersistentPendingInput { + /// Two single-column indexes: + /// * `outpoint` — the per-outpoint reconciliation lookup that + /// runs on every `upsertUtxo`. + /// * `walletId` — per-wallet pending-input scans (cleanup when + /// a wallet is removed, the storage explorer's network + /// scope, "long-lived non-zero pending count" diagnostics). + /// + /// SwiftData allows only a single `#Index` macro per model; + /// passing multiple key-path arrays declares multiple separate + /// indexes from one macro call. + #Index([\.outpoint], [\.walletId]) + var outpoint: Data + + /// Position of this input in the spending transaction's input + /// list. Carried so a future UI surface can render the input + /// index correctly without re-deriving from the raw tx bytes; + /// the resolution flow itself only uses `outpoint`. + var inputIndex: UInt32 + + /// 32-byte canonical txid of the spending transaction. Stored + /// in addition to the relationship below so the entry remains + /// usable if the parent `PersistentTransaction` isn't yet in the + /// background context (re-upsert ordering, fault-in lag, …). + var spendingTxid: Data + + /// The transaction this input belongs to. Cascade-deleted from + /// the parent side via `PersistentTransaction.pendingInputs` so + /// removing a tx doesn't leave dangling pending rows. + var spendingTransaction: PersistentTransaction? + + /// Wallet id (`PersistentTxo.walletId` denorm) so cleanup / + /// per-wallet diagnostics can scope without joining through the + /// transaction relationship. + var walletId: Data + + /// Insertion timestamp — useful for spotting stale entries that + /// never resolved (orphans whose previous output isn't ours). + var createdAt: Date + + init( + outpoint: Data, + inputIndex: UInt32, + spendingTxid: Data, + spendingTransaction: PersistentTransaction?, + walletId: Data + ) { + self.outpoint = outpoint + self.inputIndex = inputIndex + self.spendingTxid = spendingTxid + self.spendingTransaction = spendingTransaction + self.walletId = walletId + self.createdAt = Date() + } + } + + @Model + final class PersistentPlatformAddress { + /// Index `walletId` so per-wallet platform-address scans — + /// `predicate(walletId:)`, the storage explorer's network scope + /// fallback, BLAST-sync re-upsert paths — hit an index instead + /// of scanning the whole table. + #Index([\.walletId]) + + /// DIP-0018 bech32m-encoded address (`dash1…` / `tdash1…`). Unique + /// across the SwiftData store — a collision would imply a wallet- + /// id / derivation path collision. + @Attribute(.unique) var address: String + /// `PlatformAddress` type byte: 0 = P2PKH, 1 = P2SH. Matches the + /// discriminant emitted by the Rust-side FFI. + var addressType: UInt8 + /// 20-byte address hash. Kept denormalized so the BLAST balance + /// callback (which gets hashes, not full addresses) can upsert in + /// one fetch. + @Attribute(.unique) var addressHash: Data + /// 33-byte compressed secp256k1 public key, or empty Data if the + /// Rust side couldn't produce one (pool entries that stored only + /// a script, etc.). + var publicKey: Data + /// DIP-17 account index (field `account` in `PlatformPayment`). + var accountIndex: UInt32 + /// DIP-17 derivation index within the account. + var addressIndex: UInt32 + /// BIP32 derivation path (e.g. `"m/9'/5'/17'/0'/0'/0"`). + var derivationPath: String + /// Marked used by the Rust address pool (first-seen tx or explicit + /// `mark_used`), or auto-flipped by BLAST when a non-zero + /// balance / nonce first arrives. + var isUsed: Bool + /// Credit balance in credits (1e11 credits per DASH). + var balance: UInt64 + /// Current anti-replay nonce. + var nonce: UInt32 + /// Platform block height where this address first appeared in a + /// balance changeset. Zero until the address is seen on-chain. + var firstSeenHeight: UInt32 + /// Platform block height this row's `balance` is current **as of** + /// — the balance height pin (`AddressFunds::as_of_height` in Rust). + /// Round-tripped verbatim through the persistence callbacks so the + /// sync's delta-replay gate survives restarts. Zero means "unknown + /// provenance" (rows persisted before the pin existed). + var lastSeenHeight: UInt64 + /// 32-byte wallet ID that owns this address. Denormalized from + /// `account.wallet.walletId` so per-wallet `@Query` filters don't + /// have to traverse two optional relationships. + var walletId: Data + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Parent account (PlatformPayment, type tag 14). + var account: PersistentAccount? + + init( + address: String, + addressType: UInt8, + addressHash: Data, + publicKey: Data = Data(), + accountIndex: UInt32, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0, + nonce: UInt32 = 0, + walletId: Data + ) { + self.address = address + self.addressType = addressType + self.addressHash = addressHash + self.publicKey = publicKey + self.accountIndex = accountIndex + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.balance = balance + self.nonce = nonce + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.walletId = walletId + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentProperty { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + // Property type and constraints + var type: String + var format: String? + var contentMediaType: String? + var byteArray: Bool + var minItems: Int? + var maxItems: Int? + var pattern: String? + var minLength: Int? + var maxLength: Int? + var minValue: Int? + var maxValue: Int? + var fieldDescription: String? + + // Property attributes + var transient: Bool + var isRequired: Bool + + // Timestamps + var createdAt: Date + + // Relationship to document type + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, type: String) { + // Create unique ID by combining contract ID, document type name, and property name + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.type = type + self.byteArray = false + self.transient = false + self.isRequired = false + self.createdAt = Date() + } + } + + @Model + final class PersistentPublicKey { + // MARK: - Core Properties + var keyId: Int32 + var purpose: String + var securityLevel: String + var keyType: String + var readOnly: Bool + var disabledAt: Int64? + + // MARK: - Key Data + var publicKeyData: Data + + // MARK: - Contract Bounds + /// JSON-encoded `[base64(contractId)]` — legacy storage shape + /// that only retains the contract id, never the document-type + /// name. New code paths still write here for the id portion; + /// `contractBoundsDocumentTypeName` carries the doc-type so + /// the `SingleContractDocumentType` variant round-trips + /// faithfully. Keeping the field shape lets old SwiftData + /// stores that predate the doc-type column continue to load + /// without migration (the doc-type column is just `nil`). + var contractBoundsData: Data? + + /// When set, the key's bounds are + /// `.singleContractDocumentType(id: contractBoundsData[0], + /// documentTypeName: contractBoundsDocumentTypeName)`. When + /// `nil`, the key is either unbounded (when `contractBoundsData` + /// is also nil) or bounded to a whole contract via + /// `.singleContract(id:)`. Optional so old stores load cleanly. + var contractBoundsDocumentTypeName: String? + + // MARK: - Private Key Reference (optional) + var privateKeyKeychainIdentifier: String? + + // MARK: - Derivation breadcrumb (derive-sign-destroy) + /// 32-byte wallet id that owns this identity key, denormalized from the + /// discovery breadcrumb. Paired with `identityDerivationPath`, it lets the + /// signer derive this key on demand from the Keychain-held seed instead of + /// reading a stored scalar. `nil` for rows persisted before this column + /// existed and for keys with no wallet association; such rows fall back to + /// the stored scalar until the backfill populates them. Additive optional + /// column => SwiftData lightweight migration. + var walletId: Data? + + /// Full DIP-9 identity-authentication path + /// `m/9'/coin'/5'/0'/ECDSA'/identityIndex'/keyIndex'` the signer feeds to + /// the mnemonic resolver to derive this key's private scalar at sign time. + /// The authoritative breadcrumb; `nil` until written on persist or + /// backfilled from the key's Keychain metadata. + var identityDerivationPath: String? + + // MARK: - Metadata + var identityId: String + var createdAt: Date + var lastAccessed: Date? + + // MARK: - Relationships + @Relationship(inverse: \PersistentIdentity.publicKeys) + var identity: PersistentIdentity? + + // MARK: - Initialization + init( + keyId: Int32, + purpose: KeyPurpose, + securityLevel: SecurityLevel, + keyType: KeyType, + publicKeyData: Data, + readOnly: Bool = false, + disabledAt: Int64? = nil, + contractBounds: [Data]? = nil, + contractBoundsDocumentTypeName: String? = nil, + identityId: String + ) { + self.keyId = keyId + self.purpose = String(purpose.rawValue) + self.securityLevel = String(securityLevel.rawValue) + self.keyType = String(keyType.rawValue) + self.publicKeyData = publicKeyData + self.readOnly = readOnly + self.disabledAt = disabledAt + if let contractBounds = contractBounds { + self.contractBoundsData = try? JSONSerialization.data(withJSONObject: contractBounds.map { $0.base64EncodedString() }) + } else { + self.contractBoundsData = nil + } + self.contractBoundsDocumentTypeName = contractBoundsDocumentTypeName + self.identityId = identityId + self.createdAt = Date() + } + + // MARK: - Computed Properties + var contractBounds: [Data]? { + get { + guard let data = contractBoundsData, + let json = try? JSONSerialization.jsonObject(with: data), + let strings = json as? [String] else { + return nil + } + return strings.compactMap { Data(base64Encoded: $0) } + } + set { + // Always clear the doc-type column when the contract- + // bounds ids change through this setter. The + // `documentTypeName` is paired with a SPECIFIC id, so + // mutating ids without explicitly carrying the doc- + // type would leave the columns inconsistent and make + // `toIdentityPublicKey()` reconstruct a stale variant. + // Callers that want the full `.singleContractDocumentType` + // round-trip should write `contractBoundsDocumentTypeName` + // explicitly after this setter, or go through + // `PersistentPublicKey.from(IdentityPublicKey, identityId:)` + // which sets both columns atomically. + contractBoundsDocumentTypeName = nil + if let newValue = newValue { + contractBoundsData = try? JSONSerialization.data(withJSONObject: newValue.map { $0.base64EncodedString() }) + } else { + contractBoundsData = nil + } + } + } + + var purposeEnum: KeyPurpose? { + guard let purposeInt = UInt8(purpose) else { return nil } + return KeyPurpose(rawValue: purposeInt) + } + + var securityLevelEnum: SecurityLevel? { + guard let levelInt = UInt8(securityLevel) else { return nil } + return SecurityLevel(rawValue: levelInt) + } + + var keyTypeEnum: KeyType? { + guard let typeInt = UInt8(keyType) else { return nil } + return KeyType(rawValue: typeInt) + } + + var isDisabled: Bool { + disabledAt != nil + } + + /// Check if this public key has an associated private key identifier + var hasPrivateKeyIdentifier: Bool { + privateKeyKeychainIdentifier != nil + } + } + + @Model + final class PersistentToken { + @Attribute(.unique) var id: Data + var contractId: Data + var position: Int + var name: String + + // Basic token supply info + var baseSupply: String + var maxSupply: String? + var decimals: Int + + // Token conventions + var localizations: [String: TokenLocalization]? + + // Status flags + var isPaused: Bool + var allowTransferToFrozenBalance: Bool + + // History keeping rules + var keepsTransferHistory: Bool + var keepsFreezingHistory: Bool + var keepsMintingHistory: Bool + var keepsBurningHistory: Bool + var keepsDirectPricingHistory: Bool + var keepsDirectPurchaseHistory: Bool + + // Control rules + var conventionsChangeRules: ChangeControlRules? + var maxSupplyChangeRules: ChangeControlRules? + var manualMintingRules: ChangeControlRules? + var manualBurningRules: ChangeControlRules? + var freezeRules: ChangeControlRules? + var unfreezeRules: ChangeControlRules? + var destroyFrozenFundsRules: ChangeControlRules? + var emergencyActionRules: ChangeControlRules? + + // Distribution rules + var perpetualDistribution: TokenPerpetualDistribution? + var preProgrammedDistribution: TokenPreProgrammedDistribution? + var newTokensDestinationIdentity: Data? + var mintingAllowChoosingDestination: Bool + var distributionChangeRules: TokenDistributionChangeRules? + + // Marketplace rules + var tradeMode: TokenTradeMode + var tradeModeChangeRules: ChangeControlRules? + + // Main control group + var mainControlGroupPosition: Int? + var mainControlGroupCanBeModified: String? + + // Description + var tokenDescription: String? + + // Timestamps + var createdAt: Date + var lastUpdatedAt: Date + + // Relationships + var dataContract: PersistentDataContract? + + @Relationship(deleteRule: .cascade) + var balances: [PersistentTokenBalance]? + + @Relationship(deleteRule: .cascade) + var historyEvents: [PersistentTokenHistoryEvent]? + + init(contractId: Data, position: Int, name: String, baseSupply: String, decimals: Int = 8) { + // Create unique ID by combining contract ID and position + var idData = contractId + withUnsafeBytes(of: position.bigEndian) { bytes in + idData.append(contentsOf: bytes) + } + self.id = idData + + self.contractId = contractId + self.position = position + self.name = name + self.baseSupply = baseSupply + self.decimals = decimals + + // Default values + self.isPaused = false + self.allowTransferToFrozenBalance = true + self.keepsTransferHistory = true + self.keepsFreezingHistory = true + self.keepsMintingHistory = true + self.keepsBurningHistory = true + self.keepsDirectPricingHistory = true + self.keepsDirectPurchaseHistory = true + self.mintingAllowChoosingDestination = true + self.tradeMode = TokenTradeMode.notTradeable + + self.createdAt = Date() + self.lastUpdatedAt = Date() + } + } + + @Model + final class PersistentTokenBalance { + /// Index `networkRaw` for per-network balance scans. Token-balance + /// rows are aggregated per-identity per-token; UI surfaces always + /// scope to the active network. + #Index([\.networkRaw]) + + // MARK: - Core Properties + var tokenId: String + var identityId: Data + /// Schema-stable signed carrier for the protocol's unsigned balance. + /// SwiftData/SQLite keep the original `balance` Int64 column unchanged; + /// interpret its bits through `unsignedBalance` at every API boundary. + var balance: Int64 + var frozen: Bool + + // MARK: - Timestamps + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + // MARK: - Token Info (Cached) + var tokenName: String? + var tokenSymbol: String? + var tokenDecimals: Int32? + + // MARK: - Network + /// Stored as the `Network.rawValue` `UInt32` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Setter writes through. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // MARK: - Relationships + @Relationship(deleteRule: .nullify) var identity: PersistentIdentity? + @Relationship(inverse: \PersistentToken.balances) var token: PersistentToken? + + // MARK: - Initialization + init( + tokenId: String, + identityId: Data, + balance: Int64 = 0, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.tokenId = tokenId + self.identityId = identityId + self.balance = balance + self.frozen = frozen + self.tokenName = tokenName + self.tokenSymbol = tokenSymbol + self.tokenDecimals = tokenDecimals + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + self.networkRaw = network.rawValue + } + + /// Full-domain unsigned initializer. The distinct argument label preserves + /// the original public `balance: Int64` source API without making integer + /// literals ambiguous between signed and unsigned overloads. + public convenience init( + tokenId: String, + identityId: Data, + unsignedBalance: UInt64, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.init( + tokenId: tokenId, + identityId: identityId, + balance: Int64(bitPattern: unsignedBalance), + frozen: frozen, + tokenName: tokenName, + tokenSymbol: tokenSymbol, + tokenDecimals: tokenDecimals, + network: network + ) + } + + // MARK: - Computed Properties + /// Lossless full-domain view over the schema-stable signed carrier. + var unsignedBalance: UInt64 { + get { UInt64(bitPattern: balance) } + set { balance = Int64(bitPattern: newValue) } + } + + var formattedBalance: String { + let decimals: Int + if let tokenDecimals { + decimals = Int(tokenDecimals) + } else if let tokenDecimals = token?.decimals { + decimals = tokenDecimals + } else { + return "\(unsignedBalance)" + } + + guard decimals > 0 else { return String(unsignedBalance) } + + // Place the decimal point in the exact integer string. A Double + // conversion loses low digits well before UInt64.max. + let digits = String(unsignedBalance) + let scale = decimals + if digits.count <= scale { + return "0." + String(repeating: "0", count: scale - digits.count) + digits + } + let split = digits.index(digits.endIndex, offsetBy: -scale) + return String(digits[.. [String: Any]? { + guard let data = additionalDataJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + } + + @Model + final class PersistentTransaction { + /// 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, 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) var txid: Data + /// Raw transaction bytes (consensus-encoded — the same wire + /// format `dashcore::consensus::encode::serialize` produces and + /// `Transaction::consensus_decode` round-trips). The FFI write + /// path always populates this; the persister-fallback read path + /// (`PlatformWalletPersistence::get_core_tx_record`) hands it + /// back over FFI so Rust can decode a real `Transaction` + /// without a placeholder body. + var transactionData: Data + /// Context: 0=mempool, 1=instantSend, 2=inBlock, 3=inChainLockedBlock. + var context: UInt32 + /// Block height (0 for mempool). + var blockHeight: UInt32 + /// Block hash (nil for mempool). + var blockHash: Data? + /// Block timestamp. + var blockTimestamp: UInt32 + /// The transaction's index within its block (`block.vtx` order), + /// meaningful only when [`hasBlockPosition`]. Pure storage of the + /// Rust-stamped value (rust-dashcore#891): restored provider special + /// transactions hand it back so the masternode aggregation keeps + /// Core's same-block apply order across restarts. `false` on rows + /// persisted before the field existed and on unconfirmed contexts. + var blockPosition: UInt32 = 0 + var hasBlockPosition: Bool = false + /// Direction: 0=incoming, 1=outgoing, 2=internal, 3=coinJoin. + var direction: UInt32 + /// Transaction type name (Standard, CoinJoin, etc.). Sourced + /// from Rust's `Debug` repr of `TransactionType` for human + /// display only — DO NOT use this string as a discriminant; + /// match on [`transactionTypeKind`] instead. The string is + /// not a stable wire contract (a `#[derive(Debug)]` rename on + /// the Rust side would silently change it). + var transactionType: String + /// Typed discriminant of Rust's + /// `key_wallet::transaction_checking::transaction_router::TransactionType`, + /// kept in lockstep with [`TransactionTypeKind`]. Use this byte + /// (via [`typedKind`] / [`isAssetLock`] / [`isAssetUnlock`]) to + /// branch on transaction kind in UI code; the parallel + /// [`transactionType`] string is human-readable only and not + /// stable. + /// + /// Sentinel `0xFF` means "pre-feature row whose discriminant + /// hasn't been populated yet" — SPV's next upsert round + /// replaces it with the real discriminant on touch. Accessors + /// treat the sentinel as unknown (no branch fires). + var transactionTypeKind: UInt8 = 0xFF + /// Net amount in duffs (signed: positive=received, negative=sent). + var netAmount: Int64 + /// Fee in duffs (nil if unknown). + var fee: UInt64? + /// User-assigned label. + var label: String + /// Timestamp when first observed (Unix seconds). + var firstSeen: UInt64 + + // MARK: - Provider (masternode) special-transaction payload + + /// Fields lifted by the Rust FFI from a ProRegTx / ProUpServTx + /// DIP-3 payload (see `provider_payload_fields` in + /// `rs-platform-wallet-ffi`). All optional — populated only when + /// [`typedKind`] is `.providerRegistration` / `.providerUpdateService`. + /// The Swift side never decodes the payload; these are pure storage. + /// + /// Masternode service endpoint as `"ip:port"`. + var providerServiceAddress: String? = nil + /// ProUpServTx `proTxHash` (32 raw wire bytes) linking the update to + /// its registration. `nil` for ProRegTx (whose own txid is the + /// proTxHash). + var providerProTxHash: Data? = nil + /// ProRegTx collateral outpoint txid (32 raw wire bytes); pair with + /// [`providerCollateralVout`]. `nil` when not a ProRegTx. + var providerCollateralTxid: Data? = nil + var providerCollateralVout: UInt32 = 0 + /// ProRegTx owner / voting key hashes (hash160, 20 bytes each). + var providerOwnerKeyHash: Data? = nil + var providerVotingKeyHash: Data? = nil + + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Transaction outputs created by this transaction. + /// + /// 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: \PersistentTxo.transaction) + 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) + var inputs: [PersistentTxo] = [] + + /// Pending input outpoints — entries this transaction's input + /// list references but for which no `PersistentTxo` has been + /// upserted yet. Filled by `PlatformWalletPersistenceHandler. + /// upsertTransaction` via the FFI's `input_outpoints` slice; + /// each entry is consumed (deleted) by `upsertUtxo` when the + /// matching previous-output finally arrives. See + /// `PersistentPendingInput` for the full reconciliation flow. + /// Cascade-delete: removing the spending tx drops every pending + /// row that hasn't resolved yet. + @Relationship(deleteRule: .cascade, inverse: \PersistentPendingInput.spendingTransaction) + 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) + var involvedAccounts: [PersistentAccount] = [] + + init( + txid: Data, + transactionData: Data, + context: UInt32 = 0, + blockHeight: UInt32 = 0, + direction: UInt32 = 0, + transactionType: String = "Standard", + netAmount: Int64 = 0, + firstSeen: UInt64 = 0 + ) { + self.txid = txid + self.transactionData = transactionData + self.context = context + self.blockHeight = blockHeight + self.blockTimestamp = 0 + self.direction = direction + self.transactionType = transactionType + self.netAmount = netAmount + self.firstSeen = firstSeen + self.label = "" + self.createdAt = Date() + self.lastUpdated = Date() + } + + // 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. + var txidHex: String { + txid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var contextName: String { + switch context { + case 0: return "Mempool" + case 1: return "InstantSend" + case 2: return "In Block" + case 3: return "Chain Locked" + default: return "Unknown" + } + } + + var directionName: String { + switch direction { + case 0: return "Incoming" + case 1: return "Outgoing" + case 2: return "Internal" + case 3: return "CoinJoin" + default: return "Unknown" + } + } + + /// Typed view onto [`transactionTypeKind`]. `nil` only for the + /// `0xFF` sentinel (pre-feature row not yet re-persisted by SPV) + /// or for a future Rust-side variant addition Swift hasn't + /// learned about yet — both treated as "unknown" by the + /// `isAssetLock` / `isAssetUnlock` accessors so an unexpected + /// byte never silently fires the wrong branch. + var typedKind: TransactionTypeKind? { + TransactionTypeKind(rawValue: transactionTypeKind) + } + + /// `true` when this transaction is a Dash Platform asset-lock + /// funding tx — a Layer-1 burn that mints Layer-2 credits. The + /// wallet's `direction` classifier reports `Internal` because the + /// credit output is derived from this wallet's identity-funding + /// account, but the *intent* is conversion to L2 credits, not + /// "transaction to myself." + var isAssetLock: Bool { + typedKind == .assetLock + } + + /// Companion to [`isAssetLock`] — withdrawal back to L1. + var isAssetUnlock: Bool { + typedKind == .assetUnlock + } + + /// `true` for a masternode provider-registration (ProRegTx). + var isProviderRegistration: Bool { + typedKind == .providerRegistration + } + + /// `true` for a masternode provider-update-service (ProUpServTx). + var isProviderUpdateService: Bool { + typedKind == .providerUpdateService + } + + /// ProUpServTx proTxHash in block-explorer (reversed) hex, or `nil`. + /// Matches [`txidHex`]'s display-order convention. + var providerProTxHashHex: String? { + providerProTxHash.map { $0.reversed().map { String(format: "%02x", $0) }.joined() } + } + + /// ProRegTx collateral outpoint as `"txidHex:vout"` in display order, + /// or `nil` when there's no collateral field. + var providerCollateralDisplay: String? { + guard let txid = providerCollateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(providerCollateralVout)" + } + + /// ProRegTx owner key hash (hash160) in hex — key hashes are shown + /// in their natural forward byte order, unlike txids. + var providerOwnerKeyHashHex: String? { + providerOwnerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + /// ProRegTx voting key hash (hash160) in forward-order hex. + var providerVotingKeyHashHex: String? { + providerVotingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + /// `true` for masternode provider special transactions (ProRegTx + /// and the three ProUp*Tx kinds). Like asset locks, these get + /// classified `Internal` by the wallet's direction logic (the + /// wallet only sees its own owner/voting/payout keys referenced + /// in the payload), so direction-derived labels like + /// "Self-Transfer" are misleading for them. + var isProviderSpecial: Bool { + providerSpecialName != nil + } + + /// Human-readable name for provider special transactions, `nil` + /// for every other kind. + var providerSpecialName: String? { + switch typedKind { + case .providerRegistration: return "Provider Registration" + case .providerUpdateRegistrar: return "Provider Update Registrar" + case .providerUpdateService: return "Provider Update Service" + case .providerUpdateRevocation: return "Provider Update Revocation" + default: return nil + } + } + + /// Direction text for UI surfaces, overridden for asset-lock / + /// asset-unlock txs (the L1 DASH isn't going "to myself" — it's + /// being converted to / from L2 platform credits) and for + /// provider special txs (the payload references our keys but no + /// value moves "to myself"). + /// + /// Use this anywhere a human-readable "what happened" label is + /// needed; fall back to [`directionName`] only when the consumer + /// genuinely needs the raw direction (e.g. the filter dropdown). + var displayDirection: String { + if isAssetLock { return "Asset Lock" } + if isAssetUnlock { return "Asset Unlock" } + if let name = providerSpecialName { return name } + return directionName + } + + var formattedAmount: String { + let dash = Double(abs(netAmount)) / 100_000_000.0 + let sign = netAmount >= 0 ? "+" : "-" + return String(format: "%@%.8f DASH", sign, dash) + } + } + + @Model + final class PersistentTxo { + /// Index `walletId` so per-wallet TXO scans — the canonical + /// "show every TXO (and, by union of `transaction` + + /// `spendingTransaction`, every transaction) that touches wallet + /// W" path — hit an index instead of scanning the entire TXO + /// table. The denorm is what makes the predicate translatable + /// to SQL in the first place; this just makes the resulting + /// query fast at scale. + #Index([\.walletId]) + + /// 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) var outpoint: Data + /// Output index within the transaction. + var vout: UInt32 + /// Value in duffs. + var amount: UInt64 + /// Owning address (Base58Check). + var address: String + /// Script pubkey bytes. + var scriptPubKey: Data + /// Block height where created. + var height: UInt32 + /// Whether this is a coinbase output. + var isCoinbase: Bool + /// Whether confirmed in a block. + var isConfirmed: Bool + /// Whether locked by InstantSend. + var isInstantLocked: Bool + /// Whether reserved/locked for a specific purpose. + 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. + var isSpent: Bool + /// Record timestamps. + var createdAt: Date + 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. + 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`. + 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. + var spendingTransaction: PersistentTransaction? + + /// Position of this output within `spendingTransaction.input` + /// (i.e. the canonical "vin index"). Captured at the moment the + /// spend is reconciled — sourced from + /// `TransactionRecordFFI.input_outpoints` index, which itself + /// comes from `tx.input.iter()` on the Rust side, so the value + /// matches the serialized transaction's input ordering exactly. + /// `nil` when the TXO is unspent (no spending tx, no vin index) + /// or when migrated from an older row that predates the column. + /// Surfaced by `TransactionStorageDetailView` so input rows + /// render in serialized vin order with their real positions + /// rather than being re-sorted by outpoint hex (which loses + /// the relationship between row and serialized index). + var spendingInputIndex: UInt32? = nil + + /// 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.). + 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`; `.cascade` on that side so + /// account / wallet teardown drops TXOs cleanly. + var coreAddress: PersistentCoreAddress? + + 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. + 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`. 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). + var txid: Data { + if let transaction { + return transaction.txid + } + return outpoint.count >= 32 ? Data(outpoint.prefix(32)) : Data() + } + + /// 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. + var txidHex: String { + 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 `txidHex` so the byte-flip stays + /// consistent across all display surfaces. + var outpointHex: String { + let hex = txidHex + return hex.isEmpty ? "" : "\(hex):\(vout)" + } + + var formattedAmount: String { + let dash = Double(amount) / 100_000_000.0 + return String(format: "%.8f DASH", dash) + } + } + + @Model + final class PersistentWallet { + /// Index `networkRaw` so per-network wallet scans (used everywhere + /// from the network-scoped storage explorer to the per-network + /// "is there a wallet on this chain yet" lookups) don't degrade + /// to a table scan. Also index `walletGroupId` so the Wallet Info + /// "Networks" lookup — which fetches every sibling-network row for + /// a seed by its group id — stays a keyed scan. + #Index([\.networkRaw], [\.walletGroupId]) + #Unique([\.walletId]) + + /// 32-byte NETWORK-SCOPED wallet ID, and the row's primary + /// uniqueness key. Since the network-scoping change the same seed + /// yields a DISTINCT `walletId` per network (a domain-tagged network + /// byte is folded into the digest), so a wallet that exists on + /// multiple chains has one row per network, each with its own id — + /// the network is already baked into the id, so `walletId` alone is + /// globally unique (an earlier `(walletId, networkRaw)` composite + /// was a leftover from the pre-scoping model, where one seed shared + /// a single id across networks and `networkRaw` was the only + /// distinguishing column). To gather a seed's sibling-network rows, + /// group by `walletGroupId` (which is the same across networks), + /// not by this id. + var walletId: Data + /// 32-byte NETWORK-INDEPENDENT group id shared by every network's + /// wallet derived from the same seed (Rust computes it as the + /// no-network digest of the root key). Distinct from `walletId`, + /// which is network-scoped. Used to group a seed's sibling-network + /// rows in the Wallet Info "Networks" section. Defaults to empty + /// for rows written before this column existed (pre-release, no + /// migration); consumers treat empty as "legacy — this single row + /// only". + var walletGroupId: Data = Data() + /// Network this wallet belongs to. `nil` means "not yet known" — + /// the row was created by a changeset before `persistWalletMetadata` + /// filled the network in. Views treat `nil` as unknown. + /// + /// Stored as the `Network.rawValue` `UInt32?` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + var networkRaw: UInt32? + + /// Type-safe accessor over `networkRaw`. `nil` round-trips as + /// `nil`; non-nil reads fall back to `.testnet` if the stored + /// raw value ever drifts out of the `Network` range. + var network: Network? { + get { + guard let raw = networkRaw else { return nil } + return Network(rawValue: raw) ?? .testnet + } + set { networkRaw = newValue?.rawValue } + } + /// Optional wallet name. + var name: String? + /// Optional free-form user-supplied description. Mirrored into + /// the keychain metadata blob (see `WalletKeychainMetadata`) so + /// it survives a SwiftData wipe / reinstall via the + /// orphan-mnemonic recovery flow. No UI surfaces this yet, but + /// the column is wired so existing rows roll forward without a + /// schema migration when it lands. + var walletDescription: String? + /// Birth height — block height when the wallet was created. + var birthHeight: UInt32 + /// Last synced core block height. + var syncedHeight: UInt32 + /// Timestamp of last sync (Unix seconds). + var lastSynced: UInt64 + /// Bincode-serialised + /// `dashcore::ephemerealdata::chain_lock::ChainLock` carrying the + /// wallet's `WalletMetadata::last_applied_chain_lock` from the + /// previous session. Roundtripped across app launches so the + /// asset-lock-resume CL-from-metadata fallback in Rust's + /// `proof.rs` can fire on catch-up at launch without waiting + /// for SPV to re-apply a fresh ChainLock. `nil` when no + /// ChainLock has ever been observed for this wallet (fresh + /// wallet, or pre-feature row). + var lastAppliedChainLockBytes: Data? + /// 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 + /// rows that predate the column. + var isImported: Bool = false + /// Verified seed-binding marker: the BIP44 account-0 xpub that the + /// Keychain-resolved seed was proven to derive, bound to the mnemonic + /// Keychain item's identity stamp, written after one successful + /// `platform_wallet_verify_seed_binds_to_wallet_cached` run. On later + /// launches the unlock path hands this back to Rust (with the item's + /// current stamp), which skips the mnemonic-resolving derivation when + /// it still matches — and re-verifies when the xpub OR the Keychain + /// item changed. Opaque to Swift — Rust decides match-vs-verify; this + /// column only stores and returns it. `nil` (rows predating the + /// column, or never verified) means the full check runs at the next + /// unlock. + var seedBindingVerifiedMarker: String? + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Accounts belonging to this wallet. + @Relationship(deleteRule: .cascade, inverse: \PersistentAccount.wallet) + var accounts: [PersistentAccount] + + /// Identities registered against this wallet. Cardinality is + /// 0..N — a wallet may have zero identities (freshly created) + /// or many. Deletion semantics: `.nullify` so an identity + /// survives a wallet delete as an orphaned row (useful for + /// post-mortem inspection and possible re-association if the + /// wallet is re-imported from the same seed). + /// + /// Paired with `PersistentIdentity.wallet` (plain stored + /// property; the inverse key lives on this side). + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.wallet) + var identities: [PersistentIdentity] + + init( + walletId: Data, + walletGroupId: Data = Data(), + network: Network? = nil, + name: String? = nil, + walletDescription: String? = nil, + birthHeight: UInt32 = 0, + syncedHeight: UInt32 = 0, + isImported: Bool = false + ) { + self.walletId = walletId + self.walletGroupId = walletGroupId + self.networkRaw = network?.rawValue + self.name = name + self.walletDescription = walletDescription + self.birthHeight = birthHeight + self.syncedHeight = syncedHeight + self.lastSynced = 0 + self.isImported = isImported + self.createdAt = Date() + self.lastUpdated = Date() + self.accounts = [] + self.identities = [] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift index a3e5f5626de..f340cf34b79 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift @@ -78,6 +78,50 @@ public final class PersistentPendingInput { /// never resolved (orphans whose previous output isn't ours). public var createdAt: Date + /// Set when `applySweptTransaction` repurposes this row as a durable + /// claim rather than an ordinary in-flight spend: the original + /// spending transaction turned out to be a loser, this input wasn't in + /// `released`, and the funding `PersistentTxo` still hasn't arrived to + /// hold the claim itself. `spendingTxid` is overwritten to the winner + /// (`superseded_by`) and `spendingTransaction` is detached so the row + /// survives the loser's cascade-delete. `upsertUtxo` checks this flag + /// on resolve: a tombstone forces `PersistentTxo.isSpent = true` + /// unconditionally (a sweep's winner is already final, unlike an + /// ordinary pending spend whose confirmation is still pending) and + /// stamps `PersistentTxo.supersededByTxid` so the mark survives even + /// when the winner's own row never materializes. Defaulted `false` so + /// existing rows migrate as ordinary pending entries. + public var isSweptTombstone: Bool = false + + /// The WINNER'S own mined block height, stamped when a block-context + /// sweep (`SweepBatchFFI.has_winner_mined_height`) repurposes this row + /// into a tombstone — the projection of upstream key-wallet's + /// `observed_spent_outpoints`, which maps each outpoint observed spent + /// in a block to the height of the block that spent it and deliberately + /// records nothing for a mempool/IS-lock spend ("an unconfirmed spend + /// must not invalidate a coin"). Not an observation watermark: the + /// height rides the sweep event itself, so nothing here guesses when + /// the winner mined. It is the row's whole lifetime rule — + /// `collectFinalizedSweptTombstones` deletes the tombstone exactly when + /// the finality boundary `min(chainlockHeight, syncedHeight)` reaches + /// this stamp (upstream's `prune_finalized_observed_spends` condition + /// verbatim, no margin): every BIP158 filter at or below the boundary + /// has been matched with no false negatives, so the funding transaction + /// of the guarded outpoint — necessarily mined at or below the spend's + /// own height — has either been delivered (draining the row) or + /// provably never will be. A mempool-context sweep (IS-locked winner, + /// unmined) writes its tombstone with this NIL on purpose: under + /// DIP-10 the lock alone settles the input, but the winner has no + /// mining deadline, so no boundary can ever prove its funding output + /// delivered-or-never — the collector never touches an unstamped row, + /// and the hold lasts until the funding TXO drains it, a later + /// block-context sweep stamps it, or a release deletes it. + /// Re-pointing an existing tombstone on a mempool-context sweep keeps + /// the earlier block-context stamp untouched (upstream never retracts + /// an observed-spend entry for an unconfirmed conflict). + /// Optional, so existing stores lightweight-migrate. + public var winnerMinedHeight: UInt32? + public init( outpoint: Data, inputIndex: UInt32, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index f0ecd0fce34..654ec7e5c9d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -119,6 +119,23 @@ public final class PersistentTransaction { public var createdAt: Date public var lastUpdated: Date + /// Durable global exclusion for a swept loser. + /// + /// Set by `applySweptTransaction` in EVERY wallet's callback that + /// observes this row's sweep — not only the one whose deletion happens + /// to remove it. `store()` commits once per wallet, independently, so a + /// row `commit_batch` holds back for a second wallet's still-outstanding + /// claim cannot let that hold-back also postpone the parts of the sweep + /// that are true regardless of who else has weighed in: this flag is + /// what stays true the moment the first wallet's callback runs, so a + /// crash or rejection before any other wallet's callback arrives still + /// leaves the row excluded from every restore/enumeration path. `true` + /// means Rust has already proven the transaction can never confirm; + /// callers must treat the row as gone regardless of whether it still + /// physically exists (see `applySweptTransaction`'s doc for why the + /// physical delete is demoted to housekeeping once this is set). + public var isGloballySwept: Bool = false + /// Transaction outputs created by this transaction. /// /// Cascade-deletes the matching `PersistentTxo` rows when the diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift index 1775eda311e..0dae02f814b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift @@ -86,6 +86,27 @@ public final class PersistentTxo { /// the spending tx must not cascade-delete this row. public var spendingTransaction: PersistentTransaction? + /// 32-byte txid of the transaction a sweep's winner is known to have + /// beaten this coin to — the durable carrier of a sweep hold, + /// mirroring the SQLite store's `spent_in_txid`. Two writers set it: + /// `applySweptTransaction` holding an already-materialized input, and + /// `upsertUtxo` resolving a `PersistentPendingInput` tombstone + /// (`isSweptTombstone`) — the funding output arrived only after its + /// loser was already swept and deleted. The winner named here need not + /// have a row of its own (it can pay only outside addresses), which is + /// why the stamp is a bare txid rather than a relationship. + /// + /// `upsertUtxo`'s recovery clear keys on it: a coin the wallet + /// re-delivers as unspent lifts `isSpent` only when both + /// `spendingTransaction` and this are nil — a rescan re-finds the + /// funding output precisely because it is blind to an unconfirmed + /// winner no block carries yet, so re-delivery cannot outrank the + /// sweep's verdict. Cleared only by the sweep release pass, when a + /// later sweep proves the coin came free after all; a pre-stamp row + /// (written before holds named their winner) still frees on + /// re-delivery. + public var supersededByTxid: Data? + /// Position of this output within `spendingTransaction.input` /// (i.e. the canonical "vin index"). Captured at the moment the /// spend is reconciled — sourced from diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 6d6e80644a4..52365db3006 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -88,6 +88,19 @@ public final class PersistentWallet { /// ChainLock has ever been observed for this wallet (fresh /// wallet, or pre-feature row). public var lastAppliedChainLockBytes: Data? + /// NUMERIC block height of the wallet's last applied ChainLock — + /// the same watermark whose bincode blob sits in + /// `lastAppliedChainLockBytes`, which is opaque on this side of the + /// FFI. Delivered separately through the persistence extension's + /// `on_persist_wallet_changeset_chain_lock_height_fn` and stored + /// with monotonic-max semantics (chain locks only move forward). + /// This is one half of the swept-tombstone collection boundary + /// `min(chainlockHeight, syncedHeight)` — see + /// `PersistentPendingInput.winnerMinedHeight`. `nil` (fresh wallet, + /// pre-feature row, or a native library too old to fill the slot) + /// means no finality boundary is known and no tombstone may be + /// collected. Optional, so existing stores lightweight-migrate. + public var lastAppliedChainLockHeight: UInt32? /// 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 8b7025e72b3..838eb567950 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -69,6 +69,26 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// across restarts. Mirrors /// `PersistenceCapabilities::TRACKED_MASTERNODES`. public static let trackedMasternodes: UInt64 = 1 << 10 + /// A round's sweep batches — delivered through the persistence + /// extension's size-negotiated sweep callback — are durably applied + /// batch by batch and in order: swept transactions and their outputs + /// are excluded from every restore and enumeration path (physical + /// deletion or a durable marker alike), released outpoints are freed + /// unless a surviving claim supersedes, and non-released spend claims + /// are retained durably. Mirrors + /// `PersistenceCapabilities::CORE_SWEEP_REMOVAL`; Rust only honours + /// the declaration when the extension actually carries the callback. + public static let coreSweepRemoval: UInt64 = 1 << 11 + /// DashPay payment rows delivered on a store round + /// (`dashpay_payments_overlay`) are durably applied. This is what the + /// wallet-event adapter keys on before coupling a sweep's + /// `Pending → Failed` payment flip to the sweep's own atomic round — + /// a non-attesting host (Android keeps payment recording + /// in-memory-only) gets the in-memory flip with nothing + /// round-coupled. Mirrors `PersistenceCapabilities::DASHPAY_PAYMENTS`; + /// Rust only honours the declaration when the payments callback is + /// actually wired. + public static let dashpayPayments: UInt64 = 1 << 12 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 4c0baa95899..c3c0fea5c7c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -79,6 +79,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: Data, transaction: PersistentTransaction ) -> Bool { + // A globally-swept row is never "owned" for restore purposes, even + // though `involvedAccounts` below can still name this wallet — that + // membership was recorded before the transaction lost the sweep and + // `applySweptTransaction` does not (and should not) rewrite history + // by removing it. Excluding here, at the single call site every + // restore-to-Rust enumeration goes through (`walletCoreTxids`), is + // what keeps a row `isGloballySwept` has already proven dead from + // being handed back as this wallet's transaction after a restart. + guard !transaction.isGloballySwept else { return false } if transaction.involvedAccounts.contains(where: { let wallet: PersistentWallet? = $0.wallet return wallet?.walletId == walletId @@ -146,6 +155,72 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// atomically. private var inChangeset = false + /// In-memory index over the rows the open changeset round has + /// inserted into `backgroundContext` but not yet saved, keyed by the + /// same columns the hot-path fetches filter on. + /// + /// Why it exists: a `FetchDescriptor` with the default + /// `includePendingChanges == true` evaluates its predicate IN MEMORY + /// against every unsaved insert of the target entity — + /// `Predicate.evaluate` walks the key path per row, with a dynamic + /// cast per step. The `#Index`/`.unique` declarations on the models + /// only accelerate the SQL half of the fetch; the pending-changes + /// half is always a linear scan. Because the whole round defers its + /// `save()` to `endChangeset` (the `inChangeset` contract above), a + /// large wallet's initial scan accumulates thousands of unsaved + /// inserts in one round, and every subsequent fetch paid O(inserts + /// so far) — quadratic over the round, and measured as ~99% of CPU + /// on `serialQueue` minutes after the SPV scan itself finished. + /// + /// How it is used: while the index is non-nil, the lookup helpers + /// (`fetchTransactionRow`, `fetchTxoRow`, `pendingInputRows`, + /// `coreAddressRow`) consult it first and run their store fetch with + /// `includePendingChanges = false`, so SQLite answers from its + /// indexes and never triggers the in-memory scan. The single-object + /// maps are READ-THROUGH: they hold both this round's unsaved + /// inserts (registered at the insert site) and every row a store + /// fetch has already resolved this round (registered by the helper). + /// Caching store hits is not an optimization — it is load-bearing + /// for correctness: a store-only fetch that matches an + /// already-registered object REFRESHES that object to its store + /// values, silently discarding the round's unsaved attribute + /// mutations (unlike the default pending-changes fetch, which + /// returns the object with its in-memory state; staged deletions do + /// survive the refresh). Registering every resolution means each + /// key touches the store at most once per round — at first touch, + /// before the round can have mutated the object — so the refresh + /// never has anything to discard. Both sources stay disjoint + /// because `beginChangeset` builds the index only over a clean + /// context. Rows deleted mid-round are filtered by `isDeleted` on + /// both sources (index entries are deliberately never + /// unregistered — `isDeleted` already answers the question, and it + /// also covers deletes on paths that don't know about the index, + /// e.g. wallet removal). + /// + /// Lifecycle: built by `beginChangeset`, discarded in + /// `endChangeset`'s `defer` on both the commit and rollback paths — + /// after a commit the cached rows are ordinary saved rows the store + /// fetch finds on its own, and on rollback the context un-inserts / + /// reverts every one of them, so the index dies with the round + /// either way and never leaks state across rounds. `nil` outside a + /// round (and inside a round that began on a dirty context — see + /// `beginChangeset`), in which case the lookup helpers run the + /// exact pre-index fetch, pending changes included. + private struct ChangesetRoundIndex { + var transactionsByTxid: [Data: PersistentTransaction] = [:] + var txosByOutpoint: [Data: PersistentTxo] = [:] + /// `PersistentPendingInput.outpoint` is deliberately not unique + /// (re-org / double-spend can stack rows on one outpoint — see + /// the model), so this holds only the round's staged inserts + /// per key; saved rows come from the store fetch each time. + /// Pending rows need no read-through registration because + /// nothing mutates their attributes before the sweep pass, and + /// sweeps run last in the round (see `pendingInputRows`). + var pendingInputsByOutpoint: [Data: [PersistentPendingInput]] = [:] + var coreAddressesByAddress: [String: PersistentCoreAddress] = [:] + } + private var roundIndex: ChangesetRoundIndex? + /// Breadcrumb backfills that arrived on the serial queue while a /// changeset round was open. The backfill both mutates /// `backgroundContext` and saves it, so running it mid-round would @@ -192,10 +267,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.network = network self.modelFetcher = modelFetcher self.backgroundContext = ModelContext(modelContainer) - self.backgroundContext.autosaveEnabled = true + // Autosave off: this context is the transaction buffer for the + // begin → changeset → sweeps → end sequence, and autosave can commit + // its pending mutations between those callbacks. Since sweeps moved + // to their own callback the round spans two calls, so an autosave + // landing in between would make the watermark and the additive rows + // durable while the removal is still unstaged — and `rollback()` + // cannot take back a save that already happened. The handler + // attests `ATOMIC_CHANGESETS`, which is what Rust now relies on to + // trust the split transport, so that guarantee has to be real. + // + // Nothing depends on the implicit commits: every path either runs + // inside a round, which `endChangeset` commits with its single + // `save()`, or saves itself when `inChangeset` is clear. + self.backgroundContext.autosaveEnabled = false self.trackedMasternodeContext = ModelContext(modelContainer) - self.trackedMasternodeContext.autosaveEnabled = false - } + self.trackedMasternodeContext.autosaveEnabled = false } /// Synchronously run `body` on `serialQueue`. /// @@ -458,10 +545,17 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if let existing = try? backgroundContext.fetch(descriptor).first { // Same terminal rule as the upsert guard above: a // Consumed (4) row is deliberately retained for - // historical lookup and the only removal emitter - // (`untrack_asset_lock`) targets rejected Built - // rows — a removal reaching a consumed row is by - // construction a stale write. + // historical lookup, and neither removal producer can + // legitimately name one — a Built row rejected at + // broadcast (`untrack_asset_lock`) never got that far, + // and a sweep of the funding transaction only + // tombstones entries still tracked, which a consumed + // lock no longer is. A removal reaching a consumed row + // is by construction a stale write. + // `AssetLockChangeSet::merge` guarantees one call never + // carries an upsert and a removal for the same + // outpoint, so the upserts-then-removals order above is + // layout, not load-bearing sequencing. if existing.statusRaw == 4 { continue } @@ -992,9 +1086,35 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Called from the Rust persister when an SPV round produces core- /// wallet state changes. Upserts PersistentAccount / Transaction / /// Utxo records so views observing via `@Query` update automatically. - func persistWalletChangeset(walletId: Data, changeset: UnsafePointer) { + /// + /// Returns `false` when the round could not be applied, which the C shim + /// forwards to Rust so `store()` rolls the round back instead of treating + /// it as durable. Everything this method itself applies is additive, so + /// only a failed wallet lookup reports it here; the round's subtractive + /// part arrives through `persistWalletChangesetSweeps` below, with its + /// own failure path. + @discardableResult + func persistWalletChangeset( + walletId: Data, + changeset: UnsafePointer + ) -> Bool { onQueue { - guard let wallet = findWalletRecord(walletId: walletId) else { return } + // A stale post-deletion callback is not a failure — there is + // simply nothing left to write to. A fetch that *throws* is a + // different matter: reporting success would let Rust discard the + // round's sweep, and a later callback could then persist a height + // beyond a removal that never landed. + let wallet: PersistentWallet? + do { + wallet = try fetchWalletRecord(walletId: walletId) + } catch { + print( + "⚠️ persistWalletChangeset: wallet lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + guard let wallet else { return true } let cs = changeset.pointee // Chain update. @@ -1024,6 +1144,31 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { wallet.lastUpdated = Date() } + // Bounded tombstone lifetime (the SwiftData mirror of the SQLite + // store's `collect_finalized_tombstones`): once the finality + // boundary reaches a swept tombstone's winner-height stamp, the + // row has provably never drained — a genuine claim's rows are + // deleted by the drain in `upsertUtxo` when its funding TXO + // lands — so what remains is junk from foreign inputs of swept + // incoming payments, previously permanent and attacker-growable. + // The boundary is upstream's verbatim: + // `min(chainlockHeight, syncedHeight)` — the chainlock half + // proves the winner's spend final, the synced half certifies + // BIP158 filter coverage of every block that could have carried + // the funding output. The chainlock height arrives NUMERICALLY + // through the extension's chain-lock-height slot (the bincode + // bytes above are opaque here); until one has been stored no + // finality boundary exists and nothing may be collected — + // present chainlock BYTES prove nothing about how far finality + // reaches, and synced-height progress alone is not finality. + if cs.has_chain, cs.chain.has_synced_height, cs.chain.synced_height > 0, + let clHeight = wallet.lastAppliedChainLockHeight { + collectFinalizedSweptTombstones( + walletId: walletId, + boundary: min(clHeight, cs.chain.synced_height) + ) + } + // Balance delta — Rust still emits per-round deltas, but the // PersistentWallet `balance*` fields they used to update were // removed (canonical source is now the in-memory account @@ -1042,10 +1187,703 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // Swept transactions no longer ride this struct: they arrive + // through `persistWalletChangesetSweeps(walletId:sweeps:count:)` + // below, fired by Rust immediately after this callback in the + // same round. The struct crosses the C ABI by bare pointer, so a + // field appended to it cannot be proven present to a consumer + // built after a producer — the extension callback's negotiated + // `struct_size` is what carries that proof instead. + + // No save() — bracketed by changesetBegin/End. + return true + } + } + + /// Delete this wallet's swept tombstones whose winner-height stamp the + /// finality boundary has reached: `winnerMinedHeight <= boundary`, + /// where the caller computes `boundary = min(chainlockHeight, + /// syncedHeight)` — upstream key-wallet's + /// `prune_finalized_observed_spends` condition verbatim, and the + /// SQLite store's `collect_finalized_tombstones`. No observation-age + /// margin: the stamp IS the winner's own mined height, carried on the + /// sweep event, so nothing here guesses when the winner mined. Rows + /// with no stamp are never collected: a mempool-context sweep + /// (IS-locked winner, unmined) deliberately writes its tombstone + /// unstamped, because such a winner has no mining deadline and no + /// watermark can prove its inputs' funding delivered-or-never — an + /// unstamped row is a live hold, resolved only by the funding TXO + /// draining it, a later block-context sweep stamping it, or a release + /// deleting it. See the property doc on + /// `PersistentPendingInput.winnerMinedHeight`. + /// + /// Housekeeping, not correctness: a pass that cannot run self-heals on + /// the next boundary-carrying round, so a fetch failure logs and + /// returns instead of failing the round the way the sweep path must. + private func collectFinalizedSweptTombstones(walletId: Data, boundary: UInt32) { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + // Same pending-changes + in-memory-filter pattern as the sweep + // path's tombstone scan: rows tombstoned earlier in this round + // exist only as staged state, and `isSweptTombstone` is mutable, so + // a store-side predicate on it would test stale saved values. + descriptor.includePendingChanges = true + let rows: [PersistentPendingInput] + do { + rows = try backgroundContext.fetch(descriptor) + } catch { + print( + "⚠️ collectFinalizedSweptTombstones: scan failed: " + + "\(error.localizedDescription); skipping this pass" + ) + return + } + for pending in rows where pending.isSweptTombstone && !pending.isDeleted { + // A nil stamp is deliberately NOT back-filled. The unmined + // InstantSend sweep path produces one on purpose (the writer + // below maps a missing winner height to nil), so these rows + // are live holds, not stragglers: they must stay outside this + // height collector until the funding materialises, a later + // block-context sweep stamps them, or an authoritative release + // deletes them. Stamping one here would convert "no proof of + // finality" into a fabricated horizon. + guard let stamp = pending.winnerMinedHeight else { continue } + if stamp <= boundary { + backgroundContext.delete(pending) + } + } + } + + /// Extension entry for the round's NUMERIC chainlock height — the + /// same watermark whose bincode blob rides + /// `WalletChangeSetFFI.last_applied_chain_lock_bytes` (still stored, + /// for the Rust-side metadata roundtrip), delivered separately because + /// that blob is opaque here and the tombstone collection boundary + /// needs the number. Fired inside the round's begin/end bracket, after + /// the changeset callback, only when the round advanced the chainlock + /// watermark. + /// + /// Stores monotonic-max (chain locks only move forward; a late or + /// re-emitted lower height must not walk the boundary backwards), + /// then runs the tombstone collector with the completed boundary + /// `min(chainlockHeight, syncedHeight)` — the freshly known chainlock + /// half is what can newly prove a stamp final, so waiting for the next + /// height-carrying changeset would hold collectible junk for no + /// reason. Same fail-the-round contract as every per-kind callback: a + /// throwing wallet lookup returns `false` so Rust does not treat the + /// round as durable. + @discardableResult + func persistWalletChangesetChainLockHeight( + walletId: Data, + height: UInt32 + ) -> Bool { + onQueue { + let wallet: PersistentWallet? + do { + wallet = try fetchWalletRecord(walletId: walletId) + } catch { + print( + "⚠️ persistWalletChangesetChainLockHeight: wallet lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + guard let wallet else { return true } + + let effective = max(wallet.lastAppliedChainLockHeight ?? 0, height) + if wallet.lastAppliedChainLockHeight != effective { + wallet.lastAppliedChainLockHeight = effective + wallet.lastUpdated = Date() + } + + // `syncedHeight == 0` means no filter coverage is certified at + // all — the boundary's synced half is missing, so nothing can + // be proven final yet. + if wallet.syncedHeight > 0 { + collectFinalizedSweptTombstones( + walletId: walletId, + boundary: min(effective, wallet.syncedHeight) + ) + } + // No save() — bracketed by changesetBegin/End. + return true + } + } + + /// Apply a round's sweep batches — the one subtractive part of the + /// changeset path, delivered through the size-negotiated + /// `PersistenceCallbacksExtension` slot rather than as a field on + /// `WalletChangeSetFFI` (see `persistWalletChangeset` for why). Rust + /// fires this right after that callback within the same + /// begin/end round, so a wallet-relevant winner riding in the round has + /// its claim on the shared inputs already recorded when the removal here + /// decides which links are left pointing at a dead transaction. + /// + /// Returns `false` to fail the round, same contract as + /// `persistWalletChangeset`: a deletion that silently didn't happen + /// would have Rust clear the sweep while the dead row survives to be + /// replayed at the next load. + @discardableResult + func persistWalletChangesetSweeps( + walletId: Data, + sweeps: UnsafePointer?, + count: UInt + ) -> Bool { + onQueue { + // Same wallet gate as `persistWalletChangeset`: a stale + // post-deletion callback has nothing left to write to, but a + // lookup that throws must fail the round rather than let Rust + // discard a sweep that never landed. + let wallet: PersistentWallet? + do { + wallet = try fetchWalletRecord(walletId: walletId) + } catch { + print( + "⚠️ persistWalletChangesetSweeps: wallet lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + guard wallet != nil else { return true } + guard count > 0, let sweepsPtr = sweeps else { return true } + + // The funding txids this round removes, across every batch — + // the same changeset-wide set the SQLite co-swept rule keys + // on. A pending claim whose outpoint is funded by a co-swept + // loser is a claim on a dead parent's output — nobody's coin, + // not something the winner took: upstream's descendant closure + // always sweeps parent and child together, and its release + // computation excludes exactly these outpoints, so the claim + // is neither released nor legitimate to hold. Tombstoning it + // would wedge the parent's chainlocked reinstatement forever + // (the re-delivered funding output drains into the + // tombstone-outranks pick, `supersededByTxid` pins the hold, + // and the recovery clear refuses stamped rows). + var coSwept = Set() + for batchIndex in 0.. 0, let txidsPtr = batch.txids else { continue } + for i in 0..() + if batch.released_outpoints_count > 0, + let releasedPtr = batch.released_outpoints { + for i in 0.. 0, let txidsPtr = batch.txids { + // This wallet's detached tombstones, fetched ONCE per + // batch and grouped by the live `spendingTxid` each + // loser is looked up under. The per-loser form of this + // fetch paid the pending-changes tax — an in-memory + // predicate pass over every unsaved insert of the + // entity — once per swept txid, and a single + // network-derived sweep can carry many losers into the + // same round as thousands of freshly staged records. + // Pending changes stay ON (rows tombstoned earlier in + // this round exist only as staged state), the predicate + // names only the immutable `walletId`, and the mutable + // halves (`isSweptTombstone`, `spendingTxid`) are read + // off the live objects — a store-side predicate on a + // mutable column would test stale saved values. + // Rebuilt per batch, not per round: an earlier batch's + // retargets must be visible to a later batch sweeping + // that batch's winner. Within one batch no rebuild is + // needed — rows retarget to the batch's own winner, and + // upstream never lists a batch's winner among its own + // losers. + var tombstonesBySpender: [Data: [PersistentPendingInput]] = [:] + do { + var pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + pendingDescriptor.includePendingChanges = true + for pending in try backgroundContext.fetch(pendingDescriptor) + where pending.isSweptTombstone && !pending.isDeleted { + tombstonesBySpender[pending.spendingTxid, default: []] + .append(pending) + } + } catch { + print( + "⚠️ persistWalletChangesetSweeps: tombstone scan failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + + for i in 0..( + predicate: #Predicate { released.contains($0.outpoint) } + ) + rows = try backgroundContext.fetch(releasedDescriptor) + } catch { + // Same contract as the loser loop: a release + // silently skipped would report a removal durable + // that never fully happened. + print( + "⚠️ persistWalletChangesetSweeps: release lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + for txo in rows where !txo.isDeleted { + guard Self.resolvedWalletId(of: txo) == walletId, + txo.spendingTransaction == nil else { continue } + txo.isSpent = false + txo.supersededByTxid = nil + txo.spendingInputIndex = nil + txo.lastUpdated = Date() + } + } + } + + // No save() — bracketed by changesetBegin/End. + return true + } + } + + /// Delete the mirror of a transaction the wallet swept. + /// + /// A swept transaction was a recorded spend that `supersededBy` provably + /// beat to one of its inputs, so it can never confirm; Rust has already + /// dropped it. Keeping the row would hand it back at the next load and + /// re-create a balance the wallet has already corrected — this is the + /// only removal the changeset path performs. + /// + /// `isGloballySwept` is upstream's word as of this callback, not a + /// permanent verdict — the wallet's sweep state can itself be swept in + /// turn (IS-lock precedence: a chainlocked return beats the IS-locked + /// conflict that swept it originally), and `upsertTransaction` clears + /// this flag when a later record reinstates the txid. See that + /// method's doc comment for what reinstatement can and cannot undo. + /// + /// `commit_batch` calls `store()` once per wallet, and each of those + /// commits independently — there is no single transaction spanning every + /// wallet this sweep touches. That splits what has to be durable in + /// *this* callback from what can wait for a later one: the outputs this + /// row created are phantom money for every wallet, not just the one + /// running right now, and once Rust has proven the row dead no + /// restore/enumeration path may serve it to anyone — waiting for the + /// last wallet's callback to confirm that would leave it acknowledged-but- + /// resurrectable for however long the other wallets take to run, or + /// forever if one of them crashes first or never arrives. So the outputs + /// are deleted and `isGloballySwept` is set in EVERY callback that + /// reaches this function, idempotently, before anything wallet-scoped is + /// touched below. Physically removing `row` itself is different: that is + /// safe to defer, because `isGloballySwept` already makes the row inert + /// the moment the first callback sets it — see the ownership check near + /// the bottom for why the row is still worth reclaiming once nothing + /// points at it, now purely as housekeeping. + /// + /// The coins it claimed to *spend* split in two, and + /// `released` is the authority on which is which: + /// + /// - an input named there came free — no surviving transaction spends it; + /// - every other input it claimed was taken by the transaction that beat + /// it, and is gone. + /// + /// That distinction cannot be made here. Upstream only ever sweeps + /// *unconfirmed* records, and this store flips `isSpent` only for a + /// spender that reached a block, so a swept loser holds its inputs by + /// link alone with `isSpent == false`; deleting the row nils the link and + /// every one of those coins would fall back into the restore set, + /// including the consumed one. Nor can the winner's own row be consulted: + /// it need not be wallet-relevant at all, and even when it is, the sweep + /// can be committed in a round that arrives before the winner's record. + /// So upstream computes the split and names the freed coins, and this + /// applies it verbatim — the rest are held spent with no spender + /// linked, attributed to the winner via `supersededByTxid`, which keeps + /// them out of the restore set durably. + /// + /// A held input can also have no `PersistentTxo` at all yet — the loser + /// was persisted before its own funding TXO was, so + /// `resolveInputOutpoint` parked the claim as a `PersistentPendingInput` + /// instead. `PersistentTransaction.pendingInputs` cascades on delete just + /// like `outputs`, so left alone that claim would vanish with `row` + /// below, and the funding TXO's own later `upsertUtxo` — even after a + /// restart — would have nothing to tell it the coin isn't really free. + /// A held pending input is therefore detached from `row` (so the cascade + /// no longer reaches it) and repointed at `supersededBy` before the + /// delete, flagged `isSweptTombstone` so `upsertUtxo` knows to keep the + /// coin spent — durably, via `PersistentTxo.supersededByTxid` — once the + /// funding TXO materializes rather than treating it as an ordinary + /// in-flight spend. A released pending input needs none of this: it is + /// left for the cascade, the same as a released materialized input needs + /// no special handling beyond the loop above. + /// + /// The tombstone is written for EVERY sweep context; only the stamp + /// differs. A BLOCK-CONTEXT sweep (`winnerMinedHeight` non-nil) stamps + /// the winner's own mined height — the projection of key-wallet's + /// `observed_spent_outpoints` — and `collectFinalizedSweptTombstones` + /// evicts the row once the finality boundary reaches it. A + /// mempool-context sweep (`winnerMinedHeight` nil — the winner is + /// IS-locked and not yet mined) writes the SAME tombstone UNSTAMPED, + /// which the collector never touches. The in-memory model an unstamped + /// tombstone mirrors is the account's `spent_outpoints`: upstream's + /// `drop_conflicted_transactions` deletes the loser and RETAINS the + /// winner's shared inputs there — under DIP-10 the IS lock alone + /// settles them — but that set is rebuilt from live records on load, + /// and after the sweep neither the deleted loser nor a (possibly + /// wallet-irrelevant) winner leaves a record to rebuild it from. The + /// tombstone is the hold's only durable carrier; dropping it lets a + /// post-restart funding delivery credit a coin the network has + /// provably consumed. + /// + /// Nothing may collect an unstamped tombstone: an IS-locked winner has + /// no mining deadline (and the funding tx of an input it spends may + /// itself be IS-locked and unmined), so no watermark proves the + /// funding delivered-or-never. It resolves only through proof — the + /// funding TXO drains it (a wallet-owned claim always eventually + /// delivers via BIP158), a later block-context sweep re-stamps it into + /// the collectible set, or a release deletes it. The permanent residue + /// is foreign inputs of IS-context sweeps (a swept INCOMING payment + /// reaches this loop too, and ownership cannot gate it — nothing + /// anywhere can prove an input foreign, dashpay/rust-dashcore#968), + /// bounded by attack cost rather than collection: masternodes lock + /// first-seen, so every such row needs a conflicting payment delivered + /// straight to this wallet while withheld from the network, plus a + /// fee-paying IS-locked double-spend. + /// + /// A tombstoned row can itself need to move again: `supersededBy` is + /// only this round's winner, and nothing stops it from losing a later + /// round to a further winner while its own funding TXO is still + /// unresolved. `row.pendingInputs` above cannot see that earlier + /// tombstone — it already detached from `spendingTransaction` (and + /// therefore from `row`) the moment it was first written — so it is + /// looked up the only other way it is still findable, by the scalar + /// `spendingTxid` it was repointed to, and carried the rest of the + /// chain below: deleted if this round finally frees its outpoint, + /// repointed at the new winner if not. + /// + /// `PersistentTransaction` is shared across wallets by design, but + /// `released` is not: upstream computes it per wallet + /// (`per_wallet_released_outpoints`), so this wallet's set says nothing + /// about an input a *different* wallet's coin claims on the same row. + /// The input decisions below are scoped to the inputs this wallet + /// actually owns; the physical row delete at the bottom is housekeeping + /// only now (see above) and runs once no other wallet's claim is still + /// attached to it. See the ownership check below for how "no other + /// wallet" is decided without an explicit cross-wallet coordination + /// point. + /// + /// Fetch-free by design: the caller resolves `row` (through the + /// round-index-aware sweep lookup, failing the round if SwiftData + /// cannot answer) and hands over this loser's `priorTombstones` from + /// its once-per-batch scan. A `nil` row skips only the row-scoped work, + /// NOT the whole function. Sweeps are idempotent and can name a + /// transaction this store never had — but they can also name one this + /// store DID have and another wallet's callback already deleted. The + /// row is shared; the detached tombstones this wallet wrote against it + /// are not, and they are exactly the state that is still findable — by + /// scalar `spendingTxid` — after the row is gone. Skipping them would + /// strand them: this wallet's release decision would never reach a + /// tombstone that then marks its coin spent by a transaction that no + /// longer exists, and a held one could never follow the chain to a + /// further winner. So the wallet-scoped tombstone reconciliation at the + /// bottom runs either way. + private func applySweptTransaction( + walletId: Data, + supersededBy: Data, + released: Set, + coSwept: Set, + row: PersistentTransaction?, + priorTombstones: [PersistentPendingInput], + winnerMinedHeight: UInt32? + ) { + if let row { + // The global half, done every time this function runs regardless + // of which wallet's callback it is or whether this row has been + // seen by a sweep before: delete the outputs this row created + // (they are nobody's coin, ever — a swept transaction cannot have + // funded anything) and mark the row excluded from restoration. + // Both are idempotent, so re-processing an already-flagged row (a + // second wallet's callback, or a re-emitted sweep) is a harmless + // no-op. + for output in row.outputs { + backgroundContext.delete(output) + } + row.isGloballySwept = true + + // `released` is only ever true of the wallet that computed it, so + // an input this wallet does not own must be left exactly as it is + // — that wallet's own callback (delivered earlier, arriving + // later, or never coming at all) is the only thing allowed to + // decide it. Resolved through `resolvedWalletId(of:)` rather than + // a raw `walletId` compare, same reasoning as `loadWalletList`: + // the denormalized column reads empty on a row migrated before it + // existed, and comparing it raw would make every such coin look + // unowned and leave it untouched forever. + for txo in row.inputs where Self.resolvedWalletId(of: txo) == walletId { + let held = !released.contains(txo.outpoint) + txo.isSpent = held + // A held coin is attributed to the winner — the same stamp + // the pending-input drain writes, and the one SQLite + // records as `spent_in_txid`. Without it the hold has no + // durable carrier: `upsertUtxo`'s recovery clear frees a + // spent row with neither a spender nor a marker, and a + // restore-rescan re-delivers the funding output precisely + // because it is blind to an unconfirmed winner no block + // carries yet — resurrecting a provably consumed coin. + // Only an explicit release frees a stamped hold; a + // released coin's stale marker is likewise the release + // pass's business (the outpoint loop in the caller), not + // this one's. + if held { txo.supersededByTxid = supersededBy } + txo.spendingTransaction = nil + txo.lastUpdated = Date() + } + for pending in row.pendingInputs where pending.walletId == walletId { + if coSwept.contains(pending.outpoint.prefix(32)) { + // A claim on a co-swept loser's own output: nobody's + // coin, never in `released`, and a tombstone here + // would outlive the parent's reinstatement — see the + // `coSwept` doc in the caller. Deleted with the batch, + // the mobile mirror of the SQLite co-swept DELETE. + backgroundContext.delete(pending) + continue + } + guard !released.contains(pending.outpoint) else { + // Deleted now rather than left for the row's cascade. + // Still attached it reads as this wallet's claim in the + // ownership check below, so a shared loser holding one + // released input per wallet deadlocks: each callback + // sees the other's row and declines the delete, and + // replaying either reaches the same stalemate. The + // global marker keeps the dead transaction from + // contributing funds regardless, but the row and both + // pending entries would otherwise be stored forever. + backgroundContext.delete(pending) + continue + } + // Held in every winner context — `CORE_SWEEP_REMOVAL` + // requires each non-released input to keep a durable + // spend claim before its funding TXO materializes. A + // block-context winner stamps its mined height; an + // IS-locked, unmined winner leaves the stamp nil and the + // collector never touches the row — see the doc comment + // above for what resolves an unstamped hold. + pending.spendingTransaction = nil + pending.spendingTxid = supersededBy + pending.isSweptTombstone = true + pending.winnerMinedHeight = winnerMinedHeight + } + + // Whatever is still attached to `row` after the scoping above + // belongs to a different wallet that has not weighed in yet — + // this wallet's own rows are all resolved by now, held ones + // detached and released ones deleted. Whichever callback finds nothing + // left over is the last one to run and performs the delete, so + // order stops mattering. A wallet whose callback never arrives at + // all just leaves the row behind with every other wallet's inputs + // already correctly decided — a leaked dead row, not a + // wrongly-spent coin, and a re-emitted sweep cleans it up. + // + // Nothing below is load-bearing for correctness anymore: `row` + // has no outputs and reads as `isGloballySwept` as of the block + // above, in every callback that reaches this point, regardless of + // whether this delete ever fires. This is reclaiming the + // now-inert row's storage, not finishing the sweep. Detached + // tombstones deliberately do not count as claims here — they no + // longer need the row (the scalar reconciliation below never + // touches it), so holding the delete for them would leak the row + // for nothing. Nor do this wallet's released pending inputs: + // they were deleted outright above precisely so they cannot + // stalemate another wallet's callback. + let otherWalletStillClaims = row.inputs.contains { txo in + txo.spendingTransaction != nil && Self.resolvedWalletId(of: txo) != walletId + } || row.pendingInputs.contains { pending in + pending.spendingTransaction != nil && pending.walletId != walletId + } + if !otherWalletStillClaims { + backgroundContext.delete(row) + } + } + + // Chained-sweep continuation: a pending row an EARLIER sweep already + // tombstoned to this loser (itself a sweep's winner until now) is no + // longer reachable through `row.pendingInputs` — see the doc comment + // above. The caller found it by the scalar `spendingTxid` it carries + // instead (its once-per-batch scan), scoped to this wallet for the + // same reason the live pending inputs above were: the tombstone + // names one specific wallet's coin, and only that wallet's own + // released set is the right authority to re-decide it. + // + // Deliberately runs even with `row` nil. A tombstone's very + // existence means `resolveInputOutpoint` declined to re-attach a + // pending row when the winner's own record arrived (the duplicate + // guard matches on `(outpoint, spendingTxid)` and a tombstone + // occupies that key), so a wallet-relevant winner can carry no + // attached claim of this wallet's at all — and another wallet's + // callback, seeing nothing attached, legitimately deletes the shared + // row before this wallet's callback ever runs. The tombstones are + // this wallet's private state; the row's fate says nothing about + // whether they still need their release applied or their chain + // continued. + for pending in priorTombstones where !pending.isDeleted { + if released.contains(pending.outpoint) || coSwept.contains(pending.outpoint.prefix(32)) + { + backgroundContext.delete(pending) + } else { + // Re-pointed to the new winner; the stamp moves ONLY when + // this sweep has a block context. A block-context re-point + // re-stamps to the NEW winner's mined height — the claim + // now belongs to a spend anchored at that block, and its + // collection horizon moves with it. A mempool-context + // re-point (`winnerMinedHeight` nil) keeps the existing + // stamp untouched: upstream never retracts a block-context + // observed-spend entry for an unconfirmed conflict, and + // collection at the retained height stays sound — the + // funding output of a spent outpoint is mined at or below + // the height of ANY block-context spender of it, so the + // boundary passing that height still proves the funding + // was delivered or never will be. + pending.spendingTxid = supersededBy + if let winnerMinedHeight { + pending.winnerMinedHeight = winnerMinedHeight + } + } } } + /// Sweep-phase transaction lookup: round-index first, store-only on a + /// miss, and the store hit is REGISTERED so the next lookup of the same + /// txid — a later batch of this round sweeping or chaining onto it — + /// returns the same object instead of re-fetching. That registration is + /// what makes the store-only miss path safe here: every transaction row + /// carrying staged state is already in the index (record upserts + /// register inserts and store hits, the drain registers + /// relationship-resolved winners, and this helper registers what it + /// fetches — covering `isGloballySwept` staged by an earlier batch), so + /// the refresh a store-only fetch performs can only land on a clean + /// row. The plain-fetch fallback with no active round keeps the old + /// behavior for unbracketed callers. + /// + /// This replaces a plain pending-changes fetch that paid an in-memory + /// predicate pass over every unsaved `PersistentTransaction` insert + /// once per swept txid — O(records × losers) in the folded rounds that + /// carry an initial scan's records and a large conflict sweep together, + /// all of it synchronous on the persistence queue before + /// `endChangeset`. + private func fetchSweepTransactionRow(txid: Data) throws -> PersistentTransaction? { + if let known = roundIndex?.transactionsByTxid[txid] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + descriptor.fetchLimit = 1 + descriptor.relationshipKeyPathsForPrefetching = [\.outputs, \.inputs, \.pendingInputs] + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = try backgroundContext.fetch(descriptor).first, !row.isDeleted else { + return nil + } + roundIndex?.transactionsByTxid[txid] = row + return row + } + /// Find or create the `PersistentWallet` row for `walletId`. /// Used only by `persistWalletMetadata`; every other write path /// fetches via `findWalletRecord` and drops on missing so that @@ -1065,10 +1903,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Find the `PersistentWallet` row for `walletId`. Returns `nil` /// when no row exists. private func findWalletRecord(walletId: Data) -> PersistentWallet? { + try? fetchWalletRecord(walletId: walletId) + } + + /// Throwing form of `findWalletRecord`, for callers that must tell a + /// successful "no such wallet" apart from a failed lookup — anything + /// carrying a subtractive change, where swallowing the failure would + /// report a removal durable that never happened. + private func fetchWalletRecord(walletId: Data) throws -> PersistentWallet? { let descriptor = FetchDescriptor( predicate: walletRecordPredicate(walletId: walletId) ) - return try? backgroundContext.fetch(descriptor).first + return try backgroundContext.fetch(descriptor).first } /// Predicate matching the `PersistentWallet` row owned by THIS @@ -1213,6 +2059,118 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // MARK: - Round-indexed lookups + // + // The helpers below are the only way the changeset hot path + // (`upsertTransaction`, `upsertUtxo`, `resolveInputOutpoint`, + // `markUtxoSpent`, `markUtxoInstantLocked`, `removePendingInputs`, + // `persistAccountAddresses`) resolves rows by key. Each one reads + // `roundIndex` first, and on a miss — only while the index is + // active — fetches with `includePendingChanges = false` so the store + // lookup stays on SQLite's indexes instead of scanning the round's + // pending inserts in memory (see `roundIndex`); a store hit is + // registered in the index so the same key never fetches twice in one + // round (the store-only refetch would refresh the object and discard + // the round's unsaved mutations — see `roundIndex`). A miss on both + // sources may re-fetch on a later call, which is safe: there is no + // registered object for the refresh to clobber. With no active index + // the helpers degrade to the plain default fetch. Predicates only + // name immutable key columns (`txid`, `outpoint`, `address` are + // fixed at insert), so matching on store values instead of in-memory + // values cannot miss an in-round mutation; mutable-column filters + // (`spendingTxid` on pending rows) stay in Swift at the call sites, + // on live values. `isDeleted` is filtered on both sources because a + // store-only fetch still returns rows whose delete is staged but + // unsaved. + // + // The sweep phase has its own fetch discipline. Loser rows resolve + // through `fetchSweepTransactionRow` — index-first, store-only on a + // miss, registering its hits so later batches reuse the object (see + // its doc for why the miss path cannot refresh staged state away). + // The per-batch tombstone scan and the by-outpoint release fetch stay + // on plain pending-changes fetches, ONCE per batch: they key on + // columns that MUTATE mid-round (`spendingTxid`, `isSweptTombstone`) + // or must see rows staged earlier in the round, which neither the + // index nor a store-only fetch can answer. The sweep pass also + // mutates TXO / pending rows through `row.inputs` / + // `row.pendingInputs` without any keyed lookup the index could + // observe — which is safe only because sweeps are applied LAST in + // `persistWalletChangeset`, so no store-only first-touch fetch can + // follow those mutations within the round and refresh them away. + + /// Resolve a `PersistentTransaction` by its unique `txid`. + private func fetchTransactionRow(txid: Data) -> PersistentTransaction? { + if let known = roundIndex?.transactionsByTxid[txid] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + descriptor.fetchLimit = 1 + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = (try? backgroundContext.fetch(descriptor))?.first, + !row.isDeleted else { return nil } + roundIndex?.transactionsByTxid[txid] = row + return row + } + + /// Resolve a `PersistentTxo` by its unique 36-byte `outpoint`. + private func fetchTxoRow(outpoint: Data) -> PersistentTxo? { + if let known = roundIndex?.txosByOutpoint[outpoint] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + descriptor.fetchLimit = 1 + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = (try? backgroundContext.fetch(descriptor))?.first, + !row.isDeleted else { return nil } + roundIndex?.txosByOutpoint[outpoint] = row + return row + } + + /// Every live `PersistentPendingInput` row keyed on `outpoint` — + /// saved rows plus this round's staged inserts. Non-unique key, so + /// this returns the full set; callers filter further (by + /// `spendingTxid`, `createdAt`) on the live objects. Saved rows are + /// re-fetched store-only on every call rather than registered: no + /// path mutates a pending row's attributes before the sweep pass, + /// and sweeps run last (see the MARK comment), so the refetch + /// refresh never has unsaved changes to discard — deletions, the + /// one staged state these rows do accumulate mid-round, survive it. + /// De-duped by object identity as insurance against a save landing + /// mid-round (which would make a staged row visible to the store + /// fetch too). + private func pendingInputRows(outpoint: Data) -> [PersistentPendingInput] { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + if roundIndex != nil { descriptor.includePendingChanges = false } + var rows = (try? backgroundContext.fetch(descriptor)) ?? [] + if let staged = roundIndex?.pendingInputsByOutpoint[outpoint] { + let seen = Set(rows.map { ObjectIdentifier($0) }) + rows.append(contentsOf: staged.filter { !seen.contains(ObjectIdentifier($0)) }) + } + return rows.filter { !$0.isDeleted } + } + + /// Resolve a `PersistentCoreAddress` by its unique `address`. + private func coreAddressRow(address: String) -> PersistentCoreAddress? { + if let known = roundIndex?.coreAddressesByAddress[address] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.address == address } + ) + descriptor.fetchLimit = 1 + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = (try? backgroundContext.fetch(descriptor))?.first, + !row.isDeleted else { return nil } + roundIndex?.coreAddressesByAddress[address] = row + return row + } + 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, and @@ -1234,9 +2192,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // let resolvedWalletId: Data = account.wallet.walletId let txidData = hashData(tx.txid) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txidData } - ) // The FFI projection always serializes the transaction body // (`dashcore::consensus::encode::serialize` upstream), so @@ -1258,8 +2213,43 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let firstSeen: UInt64 = tx.first_seen != 0 ? tx.first_seen : UInt64(Date().timeIntervalSince1970) + let existing = fetchTransactionRow(txid: txidData) + // A sweep is upstream's word at the moment it fired, but the + // wallet's sweep state is not monotonic: `CoreChangeSet::merge` + // documents the exact reachable sequence — an unconfirmed + // transaction swept by an IS-locked conflict can return + // chainlocked and sweep that conflict in turn, per key-wallet's + // own IS-lock precedence rules. When both events land in the same + // changeset the merge already strips the sweep before it gets + // here. Across separate rounds it can't: the earlier sweep is + // already durable (row tombstoned, possibly still physically + // present because another wallet's claim held the delete back — + // see `applySweptTransaction`), and this later record is the only + // signal this callback ever sees that the wallet reversed itself. + // Upstream never re-emits a live record for a txid it still + // considers dead, so a record naming an `isGloballySwept` txid is + // authoritative reinstatement, not a stale replay — treat it as + // upstream's newer word and let it win: clear the tombstone and + // fall through to the ordinary upsert below. + // + // What this does and does not restore: `context`/`blockHeight`, + // `involvedAccounts` membership, and this record's own input + // reconciliation all rebuild normally from here since they're + // driven straight off `tx` and `account`. The outputs + // `applySweptTransaction` physically deleted are a different + // story — they come back only if this round (or the one + // `upsertUtxo` processes moments later, before any other sweep + // callback can re-tombstone this row) also carries fresh + // `utxos_added` entries for them, the same way any transaction's + // outputs ordinarily arrive alongside its record. That is not + // this method's call to make: if Rust doesn't re-emit them, they + // cannot be reconstructed here from nothing. + if let existing, existing.isGloballySwept { + existing.isGloballySwept = false + } + let record: PersistentTransaction - if let existing = try? backgroundContext.fetch(descriptor).first { + if let existing { record = existing } else { record = PersistentTransaction( @@ -1273,6 +2263,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { firstSeen: firstSeen ) backgroundContext.insert(record) + roundIndex?.transactionsByTxid[txidData] = record } record.context = tx.context @@ -1376,6 +2367,44 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { tx.context >= TransactionContextType.inBlock.rawValue } + /// Whether a TXO's existing spender link must survive an arriving + /// record that also claims the outpoint. The link is this store's spend + /// attribution, and the sweep release pass trusts it: the loser walk + /// detaches rows by their spender and the by-outpoint release frees + /// only detached rows (`spendingTransaction == nil`). A network-final + /// spender's link must therefore never be stolen by a later conflicting + /// record — upstream prunes a chainlocked spender to a bare txid (and + /// after a restart holds no history at all), so a loser reusing that + /// coin arrives with upstream unable to see the settled claim, and its + /// own eventual sweep names the coin released. With the link intact the + /// release is refused; with it stolen, the provably consumed coin reads + /// unspent after the next restart — a guaranteed double spend. + /// + /// Kept when the existing spender has not been globally swept (a swept + /// spender's claims were resolved by its own sweep) and is + /// network-final: IS-locked, in-block, or chainlocked. Two mempool + /// spenders keep last-writer-wins, as before. The single sanctioned + /// takeover mirrors DIP-10 precedence: a chainlocked arrival may take + /// the coin from a spender that was only IS-locked — a plain in-block + /// arrival may not, exactly as upstream's sweep gate refuses a plain + /// block against a signed lock. A re-emit of the same spender is never + /// a takeover. + private static func settledSpenderLinkIsKept( + existing: PersistentTransaction?, + newTxid: Data, + newContext: UInt32 + ) -> Bool { + guard let existing, existing.txid != newTxid else { return false } + guard !existing.isGloballySwept else { return false } + guard existing.context >= TransactionContextType.instantSend.rawValue else { + return false + } + let chainlockOverIsLock = + newContext >= TransactionContextType.inChainLockedBlock.rawValue + && existing.context == TransactionContextType.instantSend.rawValue + return !chainlockOverIsLock + } + /// Mark the `PersistentTxo` whose 36-byte `outpoint` matches the /// given input as spent and link it to `spendingTransaction`. /// If no matching TXO exists yet (in-Swift out-of-order, or @@ -1389,26 +2418,29 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { spendingTxid: Data, walletId: Data ) { - let txoDescriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - if let txo = try? backgroundContext.fetch(txoDescriptor).first { - // Flag and link move together — see - // `reconcileSpendObservation` for the finality rule. + if let txo = fetchTxoRow(outpoint: outpoint) { + // `reconcileSpendObservation` is the single spend verdict — + // flag and link move together under its finality rule. One + // sweep-specific term rides on top of it: a TXO the sweep is + // holding (`supersededByTxid` set) was proved consumed by a + // winner this record knows nothing about, so the verdict may + // never downgrade it back into the restore set. The sharp case + // is the winner's own record arriving IS-locked — a context + // below in-block — for a coin the sweep already settled. let verdict = Self.reconcileSpendObservation( currentSpenderTxid: txo.spendingTransaction?.txid, currentIsSpent: txo.isSpent, incoming: spendingTransaction, incomingTxid: spendingTxid ) + let resolvedIsSpent = verdict.isSpent || txo.supersededByTxid != nil let linkageChanged = - txo.isSpent != verdict.isSpent + txo.isSpent != resolvedIsSpent || (verdict.adoptLink && txo.spendingTransaction?.txid != spendingTxid) || (verdict.adoptLink && txo.spendingInputIndex != inputIndex) if linkageChanged { - txo.isSpent = verdict.isSpent - if verdict.adoptLink { - if txo.spendingTransaction?.txid != spendingTxid { + txo.isSpent = resolvedIsSpent + if verdict.adoptLink { if txo.spendingTransaction?.txid != spendingTxid { txo.spendingTransaction = spendingTransaction } // Capture the canonical vin index so the detail @@ -1431,11 +2463,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // (outpoint, spending-tx) pair already exists — re-upserts // of the same transaction would otherwise produce // duplicate pending rows that all resolve to the same - // TXO, wasting fetch work on the resolve side. - let pendingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint && $0.spendingTxid == spendingTxid } - ) - if (try? backgroundContext.fetch(pendingDescriptor).first) == nil { + // TXO, wasting fetch work on the resolve side. The + // `spendingTxid` half of the pair is compared in Swift on + // the live rows (it is mutable — `applySweptTransaction` + // rewrites it on tombstones — so it can't be a store-side + // predicate under the round index's store-only fetch). + let alreadyPending = pendingInputRows(outpoint: outpoint) + .contains { $0.spendingTxid == spendingTxid } + if !alreadyPending { let pending = PersistentPendingInput( outpoint: outpoint, inputIndex: inputIndex, @@ -1444,6 +2479,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: walletId ) backgroundContext.insert(pending) + roundIndex?.pendingInputsByOutpoint[outpoint, default: []].append(pending) } } } @@ -1454,13 +2490,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `upsertUtxo`'s resolve path so a freshly-arrived TXO doesn't /// keep its corresponding pending row alive. private func removePendingInputs(for outpoint: Data) { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - guard let rows = try? backgroundContext.fetch(descriptor), !rows.isEmpty else { - return - } - for row in rows { + // Deletes are not unregistered from `roundIndex` — the stale + // entry answers `isDeleted == true` and every lookup filters on + // that (see the index's doc). + for row in pendingInputRows(outpoint: outpoint) { backgroundContext.delete(row) } } @@ -1473,11 +2506,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { 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: PersistentTxo - if let existing = try? backgroundContext.fetch(descriptor).first { + if let existing = fetchTxoRow(outpoint: outpoint) { record = existing // Backfill if the account or wallet linkage is missing — // the per-wallet query path filters on TXO.walletId, so @@ -1496,11 +2526,28 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // 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 == txidData } - ) let parentTx: PersistentTransaction - if let existingTx = try? backgroundContext.fetch(txDescriptor).first { + if let existingTx = fetchTransactionRow(txid: txidData) { + // A globally-swept parent is a transaction Rust has already + // proven can never confirm — a fresh UTXO entry naming its + // txid would (re-)create exactly the phantom output + // `applySweptTransaction` deletes on every callback that + // observes the sweep. Bail rather than attach a new + // `PersistentTxo` to a row still excluded from restoration. + // + // This does not fight `upsertTransaction`'s reinstatement + // path — it relies on it running first. `applyAccountChangeset` + // processes an account's `tx.transactions` before its + // `utxos_added`, so a reinstating record for this same txid + // in this same round has already cleared the tombstone by + // the time this guard reads it here; only a UTXO entry with + // no accompanying record this round (or in a stray one that + // arrives out of order relative to it) still finds the flag + // set. That is genuinely a stale/out-of-order signal — Rust + // does not otherwise re-emit a swept loser's own outputs — + // and staying defensive here is correct: there is no record + // in flight to attribute a resurrected output to. + guard !existingTx.isGloballySwept else { return } parentTx = existingTx } else { // Stub row — `transactionData` is left as empty @@ -1512,6 +2559,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // treats as miss. parentTx = PersistentTransaction(txid: txidData, transactionData: Data()) backgroundContext.insert(parentTx) + roundIndex?.transactionsByTxid[txidData] = parentTx } let script: Data = { @@ -1530,6 +2578,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.account = account record.walletId = resolvedWalletId backgroundContext.insert(record) + roundIndex?.txosByOutpoint[outpoint] = record } record.amount = utxo.amount @@ -1540,6 +2589,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.isLocked = utxo.is_locked record.lastUpdated = Date() + // The wallet is handing this outpoint over as a UTXO, so it holds it + // unspent — authoritative, and the only thing that can lift a mark + // with neither a spender nor a winner behind it (a pre-stamp row + // from before `applySweptTransaction` named its winner; every hold + // written today is stamped). A row whose spend is still on record + // is left alone: the pending-input resolve below owns that + // transition. So is a `supersededByTxid` hold: the winner that + // consumed this coin is known even though its row never + // materialized here, and a re-delivery cannot outrank that verdict + // — a restore-rescan re-finds the funding output precisely because + // it is blind to an unconfirmed winner no block carries yet. Only + // an explicit release frees a stamped coin. + if record.isSpent, record.spendingTransaction == nil, record.supersededByTxid == nil { + record.isSpent = false + } + // 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 @@ -1547,11 +2612,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // 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 { + if let coreAddr = coreAddressRow(address: record.address) { record.coreAddress = coreAddr } } @@ -1565,62 +2626,82 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // `upsertTransaction`, so the spend signal is order- // independent at this layer regardless of which side arrives // first. - let outpointKey = record.outpoint - let pendingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpointKey } - ) - if let pendingRows = try? backgroundContext.fetch(pendingDescriptor), - !pendingRows.isEmpty { - // Reconcile EVERY deferred observation, not just the newest — - // the rows are about to be deleted, and picking one would let - // a mempool competitor recorded after a confirmed spender - // erase that confirmed evidence with the rows. Applying the - // finality-aware rule per row makes the order irrelevant by - // construction: confirmed evidence wins and is never - // displaced by a mempool observation, so the oldest-first - // pass below converges to the same state any order would. - var adoptedAny = false - for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) { - // Resolve the spending tx (prefer the relationship; fall - // back to a txid lookup if the row wasn't faulted in). - let resolvedSpending: PersistentTransaction? - if let spending = pending.spendingTransaction { - resolvedSpending = spending - } else { - let spendingTxid = pending.spendingTxid - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == spendingTxid } - ) - resolvedSpending = try? backgroundContext.fetch(txDescriptor).first + let pendingRows = pendingInputRows(outpoint: record.outpoint) + if !pendingRows.isEmpty { + // A tombstone outranks every ordinary row regardless of age. + // The per-row reconciliation below arbitrates between competing + // *observations*; a tombstone is not an observation — it is the + // sweep's settled verdict that its winner consumed this coin. + // The two coexist in exactly one way: records precede sweeps + // within a round, so the winner's own record can stage an + // ordinary pending row moments before the sweep repoints the + // loser's row, which keeps its original, older `createdAt`. + // Letting an observation win there would leave `isSpent` gated + // on the winner confirming, never stamp `supersededByTxid`, and + // then delete every row including the tombstone — the durable + // hold evaporates and the consumed coin re-enters the restore + // set. + if let tombstone = pendingRows.filter(\.isSweptTombstone) + .max(by: { $0.createdAt < $1.createdAt }) + { + record.spendingInputIndex = tombstone.inputIndex + if let spending = resolvePendingSpender(tombstone), + record.spendingTransaction?.txid != spending.txid + { + record.spendingTransaction = spending } - guard let spending = resolvedSpending else { continue } - // Flag and link move together — see - // `reconcileSpendObservation` for the finality rule. - let verdict = Self.reconcileSpendObservation( - currentSpenderTxid: record.spendingTransaction?.txid, - currentIsSpent: record.isSpent, - incoming: spending, - incomingTxid: spending.txid - ) - record.isSpent = verdict.isSpent - if verdict.adoptLink { - if record.spendingTransaction?.txid != spending.txid { - record.spendingTransaction = spending + // A sweep's winner is already final — there is no mempool + // state to wait out — so `isSpent` does not gate on + // resolving the spender the way an ordinary pending spend + // does; that lookup only succeeds when the winner happens to + // have its own materialized row, which is not guaranteed. + // `supersededByTxid` is what makes the mark durable either + // way, and it is what the recovery clear above checks so + // this coin is not handed back as spendable on a later sync. + record.isSpent = true + record.supersededByTxid = tombstone.spendingTxid + } else { + // Reconcile EVERY deferred observation, not just the newest — + // the rows are about to be deleted, and picking one would let + // a mempool competitor recorded after a confirmed spender + // erase that confirmed evidence with the rows. Applying the + // finality-aware rule per row makes the order irrelevant by + // construction: confirmed evidence wins and is never + // displaced by a mempool observation, so the oldest-first + // pass converges to the same state any order would. + var adoptedAny = false + for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) { + guard let spending = resolvePendingSpender(pending) else { continue } + // Flag and link move together — see + // `reconcileSpendObservation` for the finality rule. + let verdict = Self.reconcileSpendObservation( + currentSpenderTxid: record.spendingTransaction?.txid, + currentIsSpent: record.isSpent, + incoming: spending, + incomingTxid: spending.txid + ) + // A stamped hold is the sweep's settled verdict and + // outranks any observation, exactly as in + // `resolveInputOutpoint`. + record.isSpent = verdict.isSpent || record.supersededByTxid != nil + if verdict.adoptLink { + if record.spendingTransaction?.txid != spending.txid { + record.spendingTransaction = spending + } + // The vin index rides with the adopted claim so the + // spending tx's detail view renders inputs in the + // canonical serialized order. + record.spendingInputIndex = pending.inputIndex + adoptedAny = true } - // The vin index rides with the adopted claim so the - // spending tx's detail view renders inputs in the - // canonical serialized order. - record.spendingInputIndex = pending.inputIndex - adoptedAny = true } - } - if !adoptedAny, let newest = pendingRows.max(by: { $0.createdAt < $1.createdAt }) { - // No row resolved a spending tx this flush: carry the - // newest claim's vin index forward the way the old - // single-row path did; the linkage itself catches up on - // the next flush that carries the spending tx. - record.spendingInputIndex = newest.inputIndex - } + if !adoptedAny, let newest = pendingRows.max(by: { $0.createdAt < $1.createdAt }) { + // No row resolved a spending tx this flush: carry the + // newest claim's vin index forward the way the old + // single-row path did; the linkage itself catches up on + // the next flush that carries the spending tx. + record.spendingInputIndex = newest.inputIndex + } } record.lastUpdated = Date() for row in pendingRows { backgroundContext.delete(row) @@ -1628,6 +2709,21 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// Resolve a pending row's spending transaction — the relationship when + /// it is faulted in, otherwise a txid lookup through the round index. + private func resolvePendingSpender(_ pending: PersistentPendingInput) -> PersistentTransaction? { + if let spending = pending.spendingTransaction { + // Resolved through the relationship, not the index — register it + // so a later `fetchTransactionRow` for this txid returns this + // same object instead of running a first-touch store fetch that + // would refresh away any staged writes it carries (see + // `roundIndex`). + roundIndex?.transactionsByTxid[spending.txid] = spending + return spending + } + return fetchTransactionRow(txid: pending.spendingTxid) + } + /// The one rule every spend-linkage writer follows, so `isSpent` and /// `spendingTransaction` move as a single finality-aware state instead /// of a monotonic flag beside a last-writer-wins link (which could @@ -1658,6 +2754,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (adoptLink: true, isSpent: true) } if currentIsSpent { + // Refusing the link protects EXISTING confirmed evidence. With + // no spender linked there is none to protect: the flag is true + // because a sweep hold says the coin was consumed + // (`supersededByTxid`), and the arriving record is typically the + // very winner that hold names — the one transaction that can + // supply the attribution the hold could not. Adopt the link and + // keep the flag; a linked settled spender is still never + // displaced by a mempool competitor, which is the case the rule + // was written for. + if currentSpenderTxid == nil { + return (adoptLink: true, isSpent: true) + } return (adoptLink: false, isSpent: true) } return (adoptLink: true, isSpent: false) @@ -1668,10 +2776,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { txid: hashData(entry.outpoint.txid), vout: entry.outpoint.vout ) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - guard let txo = try? backgroundContext.fetch(descriptor).first else { + guard let txo = fetchTxoRow(outpoint: outpoint) else { return } // Link the spending transaction. The FFI now carries @@ -1689,10 +2794,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if txo.spendingTransaction?.txid == spendingTxid { spendingTx = txo.spendingTransaction } else { - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == spendingTxid } - ) - spendingTx = try? backgroundContext.fetch(txDescriptor).first + spendingTx = fetchTransactionRow(txid: spendingTxid) } } // When the spending tx isn't resolved this flush, leave the row @@ -1702,18 +2804,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // `isSpent` on every reordered emit. if let spending = spendingTx { // Flag and link move together — see - // `reconcileSpendObservation` for the finality rule. + // `reconcileSpendObservation` for the finality rule. A stamped + // hold outranks the verdict: this emit can carry the sweep + // winner's own IS-locked spend of a coin the sweep already + // proved consumed, and answering from the verdict alone would + // flip the durable hold back into the restore set until the + // winner reaches a block. let verdict = Self.reconcileSpendObservation( currentSpenderTxid: txo.spendingTransaction?.txid, currentIsSpent: txo.isSpent, incoming: spending, incomingTxid: spendingTxid ) - txo.isSpent = verdict.isSpent + txo.isSpent = verdict.isSpent || txo.supersededByTxid != nil if verdict.adoptLink, txo.spendingTransaction?.txid != spendingTxid { txo.spendingTransaction = spending - } - } + } } txo.lastUpdated = Date() // The spend signal landed both via the legacy // `utxos_spent` slice (this path) and — assuming the @@ -1728,10 +2834,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func markUtxoInstantLocked(_ op: OutPointFFI) { let outpoint = PersistentTxo.makeOutpoint(txid: hashData(op.txid), vout: op.vout) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - if let txo = try? backgroundContext.fetch(descriptor).first { + if let txo = fetchTxoRow(outpoint: outpoint) { txo.isInstantLocked = true txo.lastUpdated = Date() } @@ -1763,6 +2866,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.dpnsNameStates | PlatformWalletPersistenceCapabilities.trackedAssetLocks | PlatformWalletPersistenceCapabilities.trackedMasternodes + | PlatformWalletPersistenceCapabilities.coreSweepRemoval + | PlatformWalletPersistenceCapabilities.dashpayPayments ) } @@ -1778,6 +2883,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_persist_tracked_masternodes_fn = persistTrackedMasternodesCallback extensionCallbacks.on_load_tracked_masternodes_fn = loadTrackedMasternodesCallback extensionCallbacks.on_load_tracked_masternodes_free_fn = loadTrackedMasternodesFreeCallback + // Sweeps negotiate through this size-tagged structure rather than + // riding `WalletChangeSetFFI` because that struct crosses by bare + // pointer: `struct_size` above is what proves to an older native + // library that this slot exists, and proves to this build that an + // older library will simply never call it — rather than either side + // reading memory the other never allocated. + extensionCallbacks.on_persist_wallet_changeset_sweeps_fn = + persistWalletChangesetSweepsCallback + // The numeric chainlock height rides its own slot for the same + // reason: the bincode chainlock bytes on `WalletChangeSetFFI` are + // opaque to this side, and the tombstone-collection finality + // boundary `min(chainlockHeight, syncedHeight)` needs the number. + extensionCallbacks.on_persist_wallet_changeset_chain_lock_height_fn = + persistWalletChangesetChainLockHeightCallback return extensionCallbacks } @@ -1853,10 +2972,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `persistAccountChangeset`, …) fires between begin and end and /// only mutates `backgroundContext`; `save()` happens at the end. /// - /// Currently a no-op beyond the tag — `ModelContext`'s pending- - /// change buffer already gives us the batching we need. Kept as - /// a named hook so future work (explicit transaction scoping, - /// instrumented timing, etc.) has an obvious seam. + /// Beyond the tag, this builds the round's insert index (see + /// `roundIndex`) — `ModelContext`'s pending-change buffer already + /// gives us the batching we need. func beginChangeset(walletId: Data) { onQueue { self.inChangeset = true @@ -1865,7 +2983,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { category: .persistence, fields: ["wallet_reference": .reference(walletId)] ) - } + // The index's O(1) lookups are only equivalent to the plain + // pending-changes fetch when the index and the store + // partition the rows between them: index = this round's + // inserts, store = everything saved. A context that is + // already dirty here (an out-of-round writer whose `save()` + // threw and left its staged rows behind) breaks that + // partition — such a row is in neither source — so the + // round runs unindexed and the lookup helpers fall back to + // the exact pre-index fetch, pending changes included. + self.roundIndex = backgroundContext.hasChanges ? nil : ChangesetRoundIndex() } } /// Closes a persistence round. Commits all per-kind writes @@ -1890,8 +3017,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // Clear the flag before draining deferred backfills so each one's // save() lands cleanly outside the round; `drainDeferredBackfills` // is guarded on `!inChangeset`, so the ordering inside this `defer` - // (clear, then drain) is load-bearing. + // (clear, then drain) is load-bearing. The round index dies here + // on both paths — after the commit its entries are ordinary saved + // rows the store fetch finds on its own, and after a rollback the + // context has un-inserted every one of them. defer { + self.roundIndex = nil self.inChangeset = false self.drainDeferredBackfills() } @@ -3138,7 +4269,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // No save here even outside a round: the Rust store() round // that invoked this callback brackets it with begin/end, so // `inChangeset` is set in practice; if a host ever fires it - // without a bracket, autosave/next round flushes the stage. + // without a bracket, the next round's own save flushes the + // stage (autosave is disabled on this context — see init). } } @@ -3575,12 +4707,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { for entry in entries { let address = entry.address - let existingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.address == address } - ) - let existing = try? backgroundContext.fetch(existingDescriptor).first let row: PersistentCoreAddress - if let existing = existing { + if let existing = coreAddressRow(address: address) { row = existing } else { row = PersistentCoreAddress( @@ -3594,6 +4722,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { balance: entry.balance ) backgroundContext.insert(row) + roundIndex?.coreAddressesByAddress[address] = row } // Mutation path for both insert + update. row.publicKey = entry.publicKey @@ -3616,12 +4745,27 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // address row now exists. Avoid the SwiftData // optional-relationship-in-predicate gotcha by // filtering nil-coreAddress in Swift after the fetch. + // + // Deliberately NOT a round-indexed store-only lookup: this + // joins TXOs by `address`, and the rows it returns are the + // same objects the outpoint-keyed hot path mutates — a + // store-only fetch here would refresh those objects and + // discard the round's unsaved writes (see `roundIndex`). + // The pending-changes scan this keeps is bounded by the + // round's TXO inserts per emitted address entry; the + // outpoint-keyed quadratic hot path stays indexed. let txoBackfillDescriptor = FetchDescriptor( predicate: #Predicate { $0.address == address } ) if let txosAtAddress = try? backgroundContext.fetch(txoBackfillDescriptor) { for txo in txosAtAddress where txo.coreAddress == nil { txo.coreAddress = row + // This write happened outside any keyed lookup, so + // register the row: a later first-touch + // `fetchTxoRow` for this outpoint would otherwise + // run a store-only fetch and refresh the link away + // (see `roundIndex`). + roundIndex?.txosByOutpoint[txo.outpoint] = txo } } } @@ -6167,6 +7311,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { func recordEntry( for txRow: PersistentTransaction, accountIndex: UInt32 ) -> UnresolvedAssetLockTxRecordFFI? { + // A globally-swept transaction lost a double-spend on one of + // its own inputs and can never confirm. Restoring it would put + // a dead funding tx back in the account's live history — or, + // through the spender pass below, hand the double-spend screen + // a swept loser as the settled spender of a lock's input, which + // is the one verdict that must never come from a transaction + // the wallet has already removed. + guard !txRow.isGloballySwept else { return nil } let txBytes = txRow.transactionData guard !txBytes.isEmpty else { return nil } let txBuf = UnsafeMutablePointer.allocate(capacity: txBytes.count) @@ -6272,9 +7424,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) -> (UnsafeMutablePointer?, Int) { // Provider special-tx kinds are the contiguous discriminant range // 2...5 (ProviderRegistration=2 … ProviderUpdateRevocation=5). + // `!isGloballySwept` excludes a provider tx that itself lost a + // double-spend on one of its inputs — an edge case (most losers are + // ordinary spends), but a swept row is never restorable regardless + // of kind. let descriptor = FetchDescriptor( predicate: #Predicate { tx in tx.transactionTypeKind >= 2 && tx.transactionTypeKind <= 5 + && tx.isGloballySwept == false } ) guard let providerTxs = try? backgroundContext.fetch(descriptor), @@ -6804,8 +7961,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { func persistTrackedMasternodes(networkRaw: UInt32, rows: [TrackedMasternodeRow]) -> Bool { onQueue { do { - let existing = try trackedMasternodeContext.fetch( - FetchDescriptor( + let existing = try trackedMasternodeContext.fetch( FetchDescriptor( predicate: #Predicate { $0.networkRaw == networkRaw } ) ) @@ -6819,8 +7975,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { found.addedAt = row.addedAt found.snapshotJSON = row.snapshotJSON } else { - trackedMasternodeContext.insert(PersistentTrackedMasternode( - networkRaw: networkRaw, + trackedMasternodeContext.insert(PersistentTrackedMasternode( networkRaw: networkRaw, proTxHash: row.proTxHash, label: row.label, addedAt: row.addedAt, @@ -7038,6 +8193,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { guard let row = try? backgroundContext.fetch(descriptor).first else { return nil } + // A globally-swept row can still physically exist (another + // wallet's claim may not have cleared yet), but Rust has already + // proven it dead — treat it the same as "no such transaction" + // rather than handing back a body sent-payment reconciliation or + // the asset-lock proof flow would read as live. + guard !row.isGloballySwept else { + return nil + } // The Rust side decodes `transactionData` into a // `dashcore::Transaction`; an empty buffer (left over // from an orphaned stub row in the UTXO upsert path @@ -7555,8 +8718,64 @@ private func persistWalletChangesetCallback( .takeUnretainedValue() let walletId = Data(bytes: walletIdPtr, count: 32) - handler.persistWalletChangeset(walletId: walletId, changeset: changesetPtr) - return 0 + // Non-zero fails the round: `endChangeset(success: false)` rolls the + // staged writes back and Rust keeps its in-memory state instead of + // treating a partly-applied changeset as durable. + return handler.persistWalletChangeset(walletId: walletId, changeset: changesetPtr) ? 0 : 1 +} + +/// C shim for the extension's `on_persist_wallet_changeset_sweeps_fn` — +/// the round's sweep batches, fired right after the changeset callback +/// above within the same begin/end bracket. Same non-zero-fails-the-round +/// contract: a removal Rust believes durable but that never landed would +/// replay the dead row at the next load. +private func persistWalletChangesetSweepsCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + sweepsPtr: UnsafePointer?, + sweepsCount: UInt +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + + let walletId = Data(bytes: walletIdPtr, count: 32) + return handler.persistWalletChangesetSweeps( + walletId: walletId, + sweeps: sweepsPtr, + count: sweepsCount + ) ? 0 : 1 +} + +/// C shim for the extension's +/// `on_persist_wallet_changeset_chain_lock_height_fn` — the round's +/// NUMERIC chainlock height, fired inside the same begin/end bracket +/// after the changeset callback whenever the round advanced the chainlock +/// watermark. Same non-zero-fails-the-round contract as its siblings. +private func persistWalletChangesetChainLockHeightCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + chainLockHeight: UInt32 +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + + let walletId = Data(bytes: walletIdPtr, count: 32) + return handler.persistWalletChangesetChainLockHeight( + walletId: walletId, + height: chainLockHeight + ) ? 0 : 1 } /// C shim for `on_changeset_begin_fn`. Forwards to diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 1e28edf34d8..793da6fa30e 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -24,7 +24,11 @@ final class DashModelMigrationTests: XCTestCase { var v1Container: ModelContainer? = try ModelContainer( for: v1Schema, configurations: [v1Configuration]) - v1Container?.mainContext.insert(PersistentKeyword( + // V1 registers the FROZEN component (see `DashSchemaFrozenModels`), + // so a row written into a V1 container is that type — inserting the + // live one would materialise as the frozen entity and then fail its + // cast on read. + v1Container?.mainContext.insert(DashSchemaV1.PersistentKeyword( keyword: "preserved", contractId: "contract")) try v1Container?.mainContext.save() @@ -42,7 +46,9 @@ final class DashModelMigrationTests: XCTestCase { migrationPlan: DashMigrationPlan.self, configurations: [v2Configuration]) - let keywords = try migrated.mainContext.fetch(FetchDescriptor()) + // V2 registers the same frozen copy, so the read side is frozen too. + let keywords = try migrated.mainContext.fetch( + FetchDescriptor()) XCTAssertEqual(keywords.map(\.keyword), ["preserved"]) migrated.mainContext.insert(PersistentTrackedMasternode( @@ -58,6 +64,59 @@ final class DashModelMigrationTests: XCTestCase { 1) } + /// The stage this change adds: a V3 store must migrate to V4 and read + /// back with the sweep columns backfilled to their "nothing swept yet" + /// values. V3 registers the frozen component, so the row goes in as the + /// frozen type and comes out as the live one — which is the whole point + /// of the freeze: the same entity, one property wider. + @MainActor + func testV3StoreMigratesToV4AndBackfillsTheSweepColumns() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("dash.store") + + let walletId = Data(repeating: 0x5A, count: 32) + + let v3Schema = Schema(versionedSchema: DashSchemaV3.self) + let v3Configuration = ModelConfiguration( + "DashSweepMigrationTest", + schema: v3Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + var v3Container: ModelContainer? = try ModelContainer( + for: v3Schema, + configurations: [v3Configuration]) + v3Container?.mainContext.insert(DashSchemaV1.PersistentWallet( + walletId: walletId, + network: .testnet)) + try v3Container?.mainContext.save() + v3Container = nil + + let v4Schema = Schema(versionedSchema: DashSchemaV4.self) + let v4Configuration = ModelConfiguration( + "DashSweepMigrationTest", + schema: v4Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + let migrated = try ModelContainer( + for: v4Schema, + migrationPlan: DashMigrationPlan.self, + configurations: [v4Configuration]) + + let wallets = try migrated.mainContext.fetch( + FetchDescriptor()) + XCTAssertEqual(wallets.count, 1, "the V3 row must survive the migration") + XCTAssertNil( + wallets.first?.lastAppliedChainLockHeight, + "a wallet migrated from V3 has no chainlock boundary yet, so no " + + "tombstone it later takes can be collected on a fabricated one") + } + /// Guards the freeze itself: `DashSchemaV1.PersistentAssetLock` only /// keeps V1/V2 stores openable if SwiftData names its entity /// "PersistentAssetLock" — i.e. from the UNQUALIFIED type name. If a diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 478c114d0ca..4fd3c52c85c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -61,6 +61,12 @@ final class InvitationPersistenceTests: XCTestCase { // the persist/load/free trio onto `PersistentTrackedMasternode`, // so restart survival is genuinely attested. | PlatformWalletPersistenceCapabilities.trackedMasternodes + | PlatformWalletPersistenceCapabilities.coreSweepRemoval + // DashPay payment rows: the handler wires + // `on_persist_dashpay_payments_fn` and lands the overlay on + // `PersistentDashpayPayment` rows, so the sweep's Failed flip + // may ride this store's rounds — genuinely attested. + | PlatformWalletPersistenceCapabilities.dashpayPayments XCTAssertEqual( capabilities.version, @@ -83,6 +89,9 @@ final class InvitationPersistenceTests: XCTestCase { XCTAssertFalse(diagnostic.contains( PlatformWalletPersistenceCapabilities.pendingContactCrypto )) + XCTAssertTrue(diagnostic.contains( + PlatformWalletPersistenceCapabilities.coreSweepRemoval + )) } /// Create inserts one row (fields mapped, `walletId` set), a re-upsert of the @@ -95,7 +104,11 @@ final class InvitationPersistenceTests: XCTestCase { // 1. Create. handler.beginChangeset(walletId: walletId) - handler.persistInvitations(walletId: walletId, upserts: [snapshot(statusRaw: 0)], removed: []) + XCTAssertTrue( + handler.persistInvitations( + walletId: walletId, upserts: [snapshot(statusRaw: 0)], removed: [] + ) + ) _ = handler.endChangeset(walletId: walletId, success: true) var rows = try fetchRows(container) @@ -110,7 +123,11 @@ final class InvitationPersistenceTests: XCTestCase { // 2. Status change → upsert in place, no duplicate row. handler.beginChangeset(walletId: walletId) - handler.persistInvitations(walletId: walletId, upserts: [snapshot(statusRaw: 1)], removed: []) + XCTAssertTrue( + handler.persistInvitations( + walletId: walletId, upserts: [snapshot(statusRaw: 1)], removed: [] + ) + ) _ = handler.endChangeset(walletId: walletId, success: true) rows = try fetchRows(container) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift new file mode 100644 index 00000000000..8993144e0bc --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -0,0 +1,2815 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for the one subtractive part of the changeset path: the sweep +/// batches delivered through the persistence extension's +/// `on_persist_wallet_changeset_sweeps_fn` alongside each round's +/// `WalletChangeSetFFI`. +/// +/// A swept transaction was a recorded spend that a later, final transaction +/// provably beat to one of its inputs, so it can never confirm and Rust has +/// already dropped it. Everything else the round carries is additive, so a +/// mirror that ignores the sweeps keeps the dead row, hands it back at the +/// next load, and re-creates a balance the wallet has already corrected — +/// the bug the upstream sweep exists to fix, one layer up. +/// +/// The fixtures model the shape that makes the coins tricky: an unconfirmed +/// loser — upstream sweeps nothing else — spends A and B, and the winner +/// takes only A. Because the loser never reached a block, this store never +/// flipped `isSpent` on either coin, so both are one deleted row away from +/// re-entering the restore set, and only the released set upstream carries +/// says which of them belongs there. +@MainActor +final class SweptTransactionPersistTests: XCTestCase { + + private let walletId = Data(repeating: 0x01, count: 32) + private let fundingTxid = Data(repeating: 0x41, count: 32) + private let sweptTxid = Data(repeating: 0x42, count: 32) + private let winnerTxid = Data(repeating: 0x44, count: 32) + + private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// File-backed variant of `makeHandler()` — an in-memory store can't + /// outlive its own `ModelContainer`, so simulating a restart (a fresh + /// load/persister over the same on-disk store) needs a real file two + /// separate containers can both point at. + private func makeHandler(url: URL) throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let configuration = ModelConfiguration(schema: DashModelContainer.schema, url: url) + let container = try ModelContainer( + for: DashModelContainer.schema, + migrationPlan: DashMigrationPlan.self, + configurations: [configuration] + ) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// Seed the shape a confirmed spend leaves behind: a funding transaction + /// with two outputs, a spending transaction that claimed both (linked + /// and flagged spent), and the change that spend created. + /// + /// `winnerTakesA` models a wallet-relevant winner that already + /// re-pointed A at itself, which is what the additive half of the round + /// does before the sweep runs. + private func seedSpend(in container: ModelContainer, winnerTakesA: Bool) throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 140_000 + ) + // Mempool context: the only kind of record upstream sweeps. + let swept = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -140_000 + ) + context.insert(funding) + context.insert(swept) + + let winner: PersistentTransaction? + if winnerTakesA { + let row = PersistentTransaction( + txid: winnerTxid, + transactionData: Data(repeating: 0x06, count: 10), + context: 2, + blockHeight: 102, + netAmount: -100_000 + ) + context.insert(row) + winner = row + } else { + winner = nil + } + + // A — the coin the winner also takes. When the winner is + // wallet-relevant its confirmed record owns the link and the flag; + // otherwise A is left where the unconfirmed loser put it, linked and + // unspent, which is what makes it indistinguishable from B. + let coinA = PersistentTxo( + transaction: funding, + vout: 0, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + coinA.walletId = walletId + coinA.isSpent = winner != nil + coinA.spendingTransaction = winner ?? swept + context.insert(coinA) + + // B — named only by the loser, and so still unspent. + let coinB = PersistentTxo( + transaction: funding, + vout: 1, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinB.walletId = walletId + coinB.spendingTransaction = swept + context.insert(coinB) + + let change = PersistentTxo( + transaction: swept, + vout: 0, + amount: 60_000, + address: "yChangeAddr", + height: 0 + ) + change.walletId = walletId + context.insert(change) + + try context.save() + } + + /// Drive one changeset round of sweeps through the same entry point the + /// Rust persister calls. + /// One sweep batch: the transactions it removed, the winner it is + /// attributed to, the winner's finality context, and the coins it + /// freed. + private struct Batch { + var losers: [Data] + var winner: Data + /// The winner's own mined block height — `SweepBatchFFI`'s + /// `has_winner_mined_height`/`winner_mined_height` pair. Non-nil + /// models a block-context sweep (the winner is mined, tombstones + /// are written and stamped with this height); `nil` models a + /// mempool-context sweep (the winner is IS-locked and not yet + /// mined, and no tombstone may be created). Deliberately + /// undefaulted so every test states which world it is in. + var winnerMinedHeight: UInt32? + var released: [(txid: Data, vout: UInt32)] = [] + } + + /// Drive a changeset of sweep batches through the same entry point the + /// Rust persister calls, preserving their order. + /// + /// The nested buffers are allocated explicitly and freed after the call. + /// `withUnsafeMutableBufferPointer` only guarantees its pointer for the + /// duration of its own closure, so storing `baseAddress` in a struct the + /// FFI reads later would hand the consumer a dangling pointer. + @discardableResult + private func sweep( + _ handler: PlatformWalletPersistenceHandler, + _ batches: [Batch] + ) -> Bool { + sweep(handler, batches, walletId: walletId) + } + + /// `walletId`-parameterized form for the multi-wallet tests below, + /// where the same shared loser row needs a separate callback per wallet + /// — each carrying that wallet's own `released` set, the way two real + /// `persistWalletChangeset` calls would. + @discardableResult + private func sweep( + _ handler: PlatformWalletPersistenceHandler, + _ batches: [Batch], + walletId: Data + ) -> Bool { + typealias RawTxid = ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) + + var txidBuffers: [UnsafeMutablePointer] = [] + var releasedBuffers: [UnsafeMutablePointer] = [] + var ffiBatches: [SweepBatchFFI] = [] + defer { + for (i, buf) in txidBuffers.enumerated() { + buf.deinitialize(count: batches[i].losers.count) + buf.deallocate() + } + for (i, buf) in releasedBuffers.enumerated() { + buf.deinitialize(count: batches[i].released.count) + buf.deallocate() + } + } + + for batch in batches { + let txids = UnsafeMutablePointer.allocate(capacity: max(batch.losers.count, 1)) + for (i, loser) in batch.losers.enumerated() { + var tuple: RawTxid = (0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0) + Swift.withUnsafeMutableBytes(of: &tuple) { dst in + loser.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + txids.advanced(by: i).initialize(to: tuple) + } + txidBuffers.append(txids) + + let freed = UnsafeMutablePointer.allocate( + capacity: max(batch.released.count, 1) + ) + for (i, outpoint) in batch.released.enumerated() { + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = outpoint.vout + freed.advanced(by: i).initialize(to: entry) + } + releasedBuffers.append(freed) + + var entry = SweepBatchFFI() + entry.txids = UnsafePointer(txids) + entry.txids_count = UInt(batch.losers.count) + entry.released_outpoints = UnsafePointer(freed) + entry.released_outpoints_count = UInt(batch.released.count) + Swift.withUnsafeMutableBytes(of: &entry.superseded_by) { dst in + batch.winner.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + // The winner's finality context: `has_winner_mined_height` + // false is the mempool path (IS-locked, unmined winner — + // no tombstone may be created), true carries the winner's + // own mined block. + entry.has_winner_mined_height = batch.winnerMinedHeight != nil + entry.winner_mined_height = batch.winnerMinedHeight ?? 0 + ffiBatches.append(entry) + } + + let sweeps = UnsafeMutablePointer.allocate( + capacity: max(ffiBatches.count, 1) + ) + sweeps.initialize(from: ffiBatches, count: ffiBatches.count) + defer { + sweeps.deinitialize(count: ffiBatches.count) + sweeps.deallocate() + } + + // The extension entry point, not a `WalletChangeSetFFI` field: the + // Rust persister delivers sweeps through the size-negotiated + // `on_persist_wallet_changeset_sweeps_fn` in the same round as the + // changeset callback, and this drives the Swift side of exactly + // that call. + handler.beginChangeset(walletId: walletId) + let applied = handler.persistWalletChangesetSweeps( + walletId: walletId, + sweeps: UnsafePointer(sweeps), + count: UInt(ffiBatches.count) + ) + _ = handler.endChangeset(walletId: walletId, success: applied) + return applied + } + + private func transaction(_ container: ModelContainer, txid: Data) -> PersistentTransaction? { + let context = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + return try? context.fetch(descriptor).first + } + + private func txo(_ container: ModelContainer, txid: Data, vout: UInt32) -> PersistentTxo? { + let outpoint = PersistentTxo.makeOutpoint(txid: txid, vout: vout) + let context = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return try? context.fetch(descriptor).first + } + + /// The row and everything it created go; the funding transaction and its + /// coins stay. + func testSweptTransactionAndItsOutputsAreDeleted() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) + ]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row is gone") + XCTAssertNil(txo(container, txid: sweptTxid, vout: 0), "the change it created is gone with it") + XCTAssertNotNil(transaction(container, txid: fundingTxid), "the funding transaction is untouched") + } + + /// The released set is applied verbatim: the coin it names comes back, + /// and the one it does not stays out — the winner took that one. + func testSweepFreesOnlyTheInputsTheWinnerDidNotTake() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) + ]) + + let takenByWinner = txo(container, txid: fundingTxid, vout: 0) + XCTAssertNotNil(takenByWinner) + XCTAssertTrue(takenByWinner!.isSpent, "the coin the winner took stays spent") + XCTAssertEqual(takenByWinner!.spendingTransaction?.txid, winnerTxid) + + let losersOwn = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(losersOwn) + XCTAssertFalse(losersOwn!.isSpent, "the loser's own input is free again") + XCTAssertNil(losersOwn!.spendingTransaction) + } + + /// The winner does not have to reach this store at all: it can spend our + /// coin while paying only to outside addresses, and then no record for it + /// is ever written here. Nothing on hand could separate the coin it took + /// from the loser's own — upstream can, and says so through the released + /// set, which is the entire reason that set is carried. + func testAnAbsentWinnerStillKeepsItsOwnInputSpent() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) + ]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row still goes") + + let takenByWinner = txo(container, txid: fundingTxid, vout: 0) + XCTAssertNotNil(takenByWinner) + XCTAssertTrue( + takenByWinner!.isSpent, + "a coin the chain has already spent must not come back" + ) + XCTAssertNil(takenByWinner!.spendingTransaction, "and no spender is invented for it") + XCTAssertEqual( + takenByWinner!.supersededByTxid, + winnerTxid, + "the hold is attributed to the winner — SQLite's spent_in_txid, mirrored" + ) + + let losersOwn = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(losersOwn) + XCTAssertFalse( + losersOwn!.isSpent, + "the loser's own input is free, winner record or not" + ) + } + + /// A re-delivery of the funding output — what a restore-rescan does, + /// blind to the unconfirmed winner no block carries yet — must NOT + /// outrank the sweep's verdict: the coin was provably consumed, and + /// handing it back would resurrect it into the restore set on every + /// restore-from-seed until the winner confirms. Only an explicit + /// release frees a stamped hold — the same answer the SQLite store's + /// upsert valve gives to the identical event stream. + func testWalletReDeliveringAStampedHeldCoinKeepsItSpent() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + XCTAssertTrue(txo(container, txid: fundingTxid, vout: 1)!.isSpent) + + redeliverCoinB(handler) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(held.isSpent, "the stamped hold survives re-delivery") + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertNil(held.spendingTransaction) + } + + /// The winner's own record can reach this store only after the sweep + /// and the funding TXO already did — IS-locked, not yet in a block. + /// Both writers it flows through resolved the in-block gate to false + /// and wrote it outright: `resolveInputOutpoint` on the record pass, + /// then `markUtxoSpent` on the `utxos_spent` emit riding the same + /// round. Either flipped the durable stamped hold back into the + /// restore set until the winner confirmed — contradicting the verdict + /// the sweep already recorded (and the handler's own "winner is + /// already final" reasoning). + func testAWinnersLateRecordDoesNotDowngradeAStampedHold() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let l = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // The sweep holds the claim; the funding TXO then materializes it + // as a stamped hold. + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + + // The winner's own record finally arrives, IS-locked (context 1 < + // in-block), with the spent emit riding along the way a real round + // delivers both. + deliverRecordWithSpentEmit( + handler, + txid: winnerTxid, + context: 1, + inputOutpoint: (txid: fundingTxid, vout: 0) + ) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + held.isSpent, + "the winner's own unconfirmed arrival must not downgrade the stamped hold" + ) + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertEqual( + held.spendingTransaction?.txid, + winnerTxid, + "the spender is linked all the same" + ) + } + + /// The record-only half of the scenario above: a flush can deliver the + /// winner's record without a `utxos_spent` emit (the wallet had no live + /// UTXO to classify — the coin sits as a stamped hold), so + /// `resolveInputOutpoint`'s own monotonic guard must carry the hold by + /// itself. Pinned separately because the combined test's spent emit + /// re-applies the hold through `markUtxoSpent`'s guard, masking a + /// regression in the record pass alone. + func testAWinnersLateRecordAloneDoesNotDowngradeAStampedHold() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let l = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + + deliverRecordWithSpentEmit( + handler, + txid: winnerTxid, + context: 1, + inputOutpoint: (txid: fundingTxid, vout: 0), + includeSpentEmit: false + ) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + held.isSpent, + "the record pass alone must not downgrade the stamped hold" + ) + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertEqual(held.spendingTransaction?.txid, winnerTxid) + } + + /// One changeset round carrying a transaction record and — unless the + /// caller opts out to pin the record pass alone — the `utxos_spent` + /// emit for the input it consumed, the shape a real round takes when + /// the wallet classifies the spend in the same flush as the record. + private func deliverRecordWithSpentEmit( + _ handler: PlatformWalletPersistenceHandler, + txid: Data, + context: UInt32, + inputOutpoint: (txid: Data, vout: UInt32), + includeSpentEmit: Bool = true + ) { + let name = strdup("Standard { index: 0 }") + defer { free(name) } + + var input = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &input.txid) { dst in + inputOutpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + input.vout = inputOutpoint.vout + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = 0 + + var spent = SpentOutPointFFI() + spent.outpoint = input + Swift.withUnsafeMutableBytes(of: &spent.spending_txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &input) { inputPtr in + record.input_outpoints = inputPtr + record.input_outpoints_count = 1 + withUnsafeMutablePointer(to: &record) { recordPtr in + withUnsafeMutablePointer(to: &spent) { spentPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + if includeSpentEmit { + account.utxos_spent = spentPtr + account.utxos_spent_count = 1 + } + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Multi-input record delivery with no spent emit — the shape a + /// wallet-relevant loser takes when its inputs were never classified + /// against live UTXOs (`input_outpoints` carries every raw input either + /// way). + private func deliverRecord( + _ handler: PlatformWalletPersistenceHandler, + txid: Data, + context: UInt32, + inputOutpoints: [(txid: Data, vout: UInt32)] + ) { + let name = strdup("Standard { index: 0 }") + defer { free(name) } + + var inputs: [OutPointFFI] = inputOutpoints.map { outpoint in + var input = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &input.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + input.vout = outpoint.vout + return input + } + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = 0 + + handler.beginChangeset(walletId: walletId) + inputs.withUnsafeMutableBufferPointer { inputsPtr in + record.input_outpoints = inputsPtr.baseAddress + record.input_outpoints_count = UInt(inputsPtr.count) + withUnsafeMutablePointer(to: &record) { recordPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// The pruned-finalized-release defect, on this store's terms: a + /// chainlocked spender F is pruned upstream to a bare txid, so a later + /// loser L that pays this wallet while reusing F's input (plus an + /// attacker-owned one) sweeps with F's coin wrongly named in the + /// released set. F's row and its `spendingTransaction` link survive + /// HERE, and `settledSpenderLinkIsKept` keeps L's record pass from + /// stealing the attribution — so the loser walk never detaches F's coin + /// and the by-outpoint release refuses it (`spendingTransaction == nil` + /// gate), while the coin only L claimed still comes free in the same + /// batch. + func testAReleaseNamingACoinASettledSpenderStillClaimsIsRefused() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let finalizedTxid = Data(repeating: 0x46, count: 32) + let attackerTxid = Data(repeating: 0x47, count: 32) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 200_000 + ) + // F: the chainlocked spender of the settled coin — upstream keeps + // only its txid from here on; this store keeps the row and the link. + let finalized = PersistentTransaction( + txid: finalizedTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 3, + blockHeight: 120, + netAmount: -100_000 + ) + context.insert(funding) + context.insert(finalized) + + let settledCoin = PersistentTxo( + transaction: funding, + vout: 0, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + settledCoin.walletId = walletId + settledCoin.isSpent = true + settledCoin.spendingTransaction = finalized + context.insert(settledCoin) + + let losersOwnCoin = PersistentTxo( + transaction: funding, + vout: 1, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + losersOwnCoin.walletId = walletId + context.insert(losersOwnCoin) + try context.save() + + // L: arrives after F's pruning — pays this wallet, reuses F's input + // alongside the attacker's and one coin of its own. Its record pass + // must NOT steal F's link. + deliverRecord( + handler, + txid: sweptTxid, + context: 0, + inputOutpoints: [ + (txid: fundingTxid, vout: 0), + (txid: attackerTxid, vout: 0), + (txid: fundingTxid, vout: 1), + ] + ) + XCTAssertEqual( + try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).spendingTransaction?.txid, + finalizedTxid, + "a settled spender's link is not stolen by a conflicting record" + ) + XCTAssertEqual( + try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)).spendingTransaction?.txid, + sweptTxid, + "the loser's own coin links normally" + ) + + // W (final) beats L on the attacker input alone. Upstream's release + // set — computed from live records that no longer include F — + // wrongly names F's coin alongside the loser's own. + sweep(handler, [Batch( + losers: [sweptTxid], + winner: winnerTxid, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1)] + )]) + + let settled = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + settled.isSpent, + "a released coin a settled stored spender still claims must stay spent" + ) + XCTAssertEqual(settled.spendingTransaction?.txid, finalizedTxid) + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(freed.isSpent, "a coin only the swept loser claimed must come free") + XCTAssertNil(freed.spendingTransaction) + XCTAssertNil(freed.supersededByTxid) + } + + /// The backstop for rows written before holds named their winner: a + /// coin held spent with neither a spender nor a `supersededByTxid` + /// stamp has nothing durable behind it, so the wallet re-delivering it + /// as a UTXO — the authority on what it holds — still lifts the mark. + /// Every hold written today is stamped; this pins the migration path + /// for the ones already on disk. + func testAPreStampHoldStillFreesOnRedelivery() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 40_000 + ) + context.insert(funding) + let coinB = PersistentTxo( + transaction: funding, + vout: 1, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinB.walletId = walletId + coinB.isSpent = true + context.insert(coinB) + try context.save() + + redeliverCoinB(handler) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(freed.isSpent, "a hold with nothing durable behind it frees on re-delivery") + XCTAssertNil(freed.spendingTransaction) + } + + /// Hand coin B back through the ordinary account changeset, the way a + /// rescan that re-finds the funding transaction does. + private func redeliverCoinB(_ handler: PlatformWalletPersistenceHandler) { + let name = strdup("Standard { index: 0 }") + let address = strdup("yFundAddr") + defer { + free(name) + free(address) + } + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + fundingTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = 1 + utxo.amount = 40_000 + utxo.address = address + utxo.height = 100 + utxo.is_confirmed = true + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Two sweeps in one round, the later disagreeing with the earlier. + /// + /// The first frees coin B; a second transaction spends it; the second + /// sweep removes that spender and frees nothing, because its own winner + /// took B. The later answer is the true one — and it only sticks because + /// the batches are applied in sequence. Folding their release sets would + /// leave the first "B is free" outliving the last "B is spent". + func testALaterSweepKeepingACoinSpentOverridesAnEarlierRelease() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + // A second transaction takes coin B after the first sweep freed it. + let secondLoser = Data(repeating: 0x55, count: 32) + let context = ModelContext(container) + let reclaimer = PersistentTransaction( + txid: secondLoser, + transactionData: Data(repeating: 0x07, count: 10), + context: 0, + blockHeight: 0, + netAmount: -40_000 + ) + context.insert(reclaimer) + let coinB = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == coinB } + ) + let row = try XCTUnwrap(try context.fetch(descriptor).first) + row.spendingTransaction = reclaimer + try context.save() + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]), + // Its winner consumed B, so this batch frees nothing. + Batch(losers: [secondLoser], winner: Data(repeating: 0x56, count: 32), winnerMinedHeight: 400), + ]) + + let contested = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(contested) + XCTAssertTrue( + contested!.isSpent, + "the later sweep kept the coin spent, so it must not come back" + ) + } + + /// Seed the review finding's exact shape: one loser transaction shared + /// by two wallets, spending a coin from each. `walletA` owns P, `walletB` + /// owns Q; neither wallet's `PersistentTransaction` row for the winner is + /// ever created here, matching the "winner can pay only outside + /// addresses" case the released set exists to handle. The two coins live + /// in the same funding transaction only for setup convenience — nothing + /// about the fix depends on that; what makes `loser` shared is that its + /// `row.inputs` spans two different owning wallets. + private func seedSharedLoserAcrossTwoWallets( + in container: ModelContainer, + walletA: Data, + walletB: Data, + loserTxid: Data + ) throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletA, network: .testnet)) + context.insert(PersistentWallet(walletId: walletB, network: .testnet)) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 140_000 + ) + context.insert(funding) + + let loser = PersistentTransaction( + txid: loserTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -140_000 + ) + context.insert(loser) + + // P — wallet A's coin, claimed only by the shared loser. + let coinP = PersistentTxo( + transaction: funding, vout: 0, amount: 100_000, address: "yWalletA", height: 100 + ) + coinP.walletId = walletA + coinP.spendingTransaction = loser + context.insert(coinP) + + // Q — wallet B's coin, also claimed only by the shared loser. + let coinQ = PersistentTxo( + transaction: funding, vout: 1, amount: 40_000, address: "yWalletB", height: 100 + ) + coinQ.walletId = walletB + coinQ.spendingTransaction = loser + context.insert(coinQ) + + try context.save() + } + + /// The BLOCKING finding's exact shape, built on top of + /// `seedSharedLoserAcrossTwoWallets`: the shared loser also created an + /// output of its own — phantom money, since a transaction that never + /// confirms funded nothing — and was `involvedAccounts`-linked to an + /// account under `walletA` from back when it was still a live candidate + /// (the ordinary `upsertTransaction` path does this before a later round + /// ever learns the tx lost a double-spend). That link is what makes this + /// fixture actually exercise the fix: without the `isGloballySwept` + /// guard, `walletOwnsTransaction` finds `walletA` through + /// `involvedAccounts` alone, regardless of what happens to P. + private func seedSharedLoserWithOutputAndInvolvedAccount( + in container: ModelContainer, + walletA: Data, + walletB: Data, + loserTxid: Data + ) throws { + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletA, walletB: walletB, loserTxid: loserTxid + ) + let context = ModelContext(container) + let walletRecord = try XCTUnwrap( + try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.walletId == walletA }) + ).first + ) + let account = PersistentAccount( + wallet: walletRecord, accountType: 0, accountIndex: 0, accountTypeName: "Standard" + ) + context.insert(account) + + let loserDescriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == loserTxid } + ) + let loser = try XCTUnwrap(try context.fetch(loserDescriptor).first) + loser.involvedAccounts.append(account) + + let phantomChange = PersistentTxo( + transaction: loser, vout: 2, amount: 60_000, address: "yLoserChange", height: 0 + ) + phantomChange.walletId = walletA + context.insert(phantomChange) + + try context.save() + } + + /// The review finding, order 1: wallet B's callback — the one that + /// releases nothing — runs first. Before the fix this alone deleted the + /// shared loser row (nothing in the old code held it back), so wallet + /// A's later release of P landed on the missing-row no-op and P stayed + /// wrongly spent forever. + func testSharedLoserAppliesBothWalletsReleaseSetsRegardlessOfOrder_BThenA() throws { + let (handler, container) = try makeHandler() + let loserTxid = Data(repeating: 0x81, count: 32) + let winner = Data(repeating: 0x82, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Wallet B first: its own released set names nothing, so its coin + // (Q) is held rather than freed. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + XCTAssertNotNil( + transaction(container, txid: loserTxid), + "wallet B alone must not delete a row wallet A still has a claim on" + ) + let untouchedP = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(untouchedP.isSpent, "wallet B's callback must not touch wallet A's coin") + XCTAssertNotNil(untouchedP.spendingTransaction, "P is still linked to the loser, untouched") + + // Wallet A second: its own released set names P. + sweep(handler, [ + Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ], walletId: walletId) + + XCTAssertNil( + transaction(container, txid: loserTxid), + "the last wallet to run performs the delete" + ) + + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(p.isSpent, "wallet A's own release must free its own coin") + XCTAssertNil(p.spendingTransaction) + + let q = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(q.isSpent, "wallet B's earlier decision to hold Q must survive wallet A's callback") + XCTAssertNil(q.spendingTransaction) + } + + /// The review finding, order 2: wallet A — the one that releases P — + /// runs first. The fix is meant to be order-independent, so this must + /// land on the exact same end state as the B-then-A ordering above. + func testSharedLoserAppliesBothWalletsReleaseSetsRegardlessOfOrder_AThenB() throws { + let (handler, container) = try makeHandler() + let loserTxid = Data(repeating: 0x91, count: 32) + let winner = Data(repeating: 0x92, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Wallet A first: releases P. + sweep(handler, [ + Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ], walletId: walletId) + + XCTAssertNotNil( + transaction(container, txid: loserTxid), + "wallet A alone must not delete a row wallet B still has a claim on" + ) + let untouchedQ = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(untouchedQ.isSpent, "wallet A's callback must not touch wallet B's coin") + XCTAssertNotNil(untouchedQ.spendingTransaction, "Q is still linked to the loser, untouched") + + // Wallet B second: releases nothing. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + XCTAssertNil( + transaction(container, txid: loserTxid), + "the last wallet to run performs the delete" + ) + + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(p.isSpent, "wallet A's earlier release must survive wallet B's callback") + XCTAssertNil(p.spendingTransaction) + + let q = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(q.isSpent, "wallet B's own decision to hold its coin must stick") + XCTAssertNil(q.spendingTransaction) + } + + /// The BLOCKING review finding: a shared loser's own output, and its + /// reachability through `walletCoreTxids`, must not survive across a + /// restart when only ONE wallet's callback ever commits and the other's + /// never arrives at all — a crash, a rejection, or simply never coming. + /// + /// `commit_batch` calls `store()` once per wallet and each commits + /// independently, so before the fix wallet B alone could not delete a + /// row wallet A still had an outstanding claim on (see the + /// `_BThenA`/`_AThenB` tests above) — and the OUTPUT went with the row, + /// because deletion was the only thing that excluded either. If wallet + /// A's own callback then never runs, that hold is permanent: the row, + /// its phantom output, and its `involvedAccounts` link to wallet A all + /// stay fully live forever, so `walletCoreTxids` hands the dead + /// transaction back to wallet A as its own after every future restart. + /// + /// Only wallet B's callback ever runs here, and it releases nothing — + /// the worst case, since it gives the row no reason to be physically + /// deleted at all. The fix's global half must still make the output and + /// the enumeration exclusion durable from that single callback alone. + func testSharedLoserOutputAndEnumerationAreExcludedAfterOnlyOneWalletsCallbackCommits() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-shared-durability-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + let loserTxid = Data(repeating: 0xA1, count: 32) + let winner = Data(repeating: 0xA2, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + + do { + let (handler, container) = try makeHandler(url: storeURL) + try seedSharedLoserWithOutputAndInvolvedAccount( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Only wallet B's callback ever runs, and it releases nothing — + // wallet A's own callback (which would release P) never arrives + // in this test at all. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + XCTAssertNotNil( + transaction(container, txid: loserTxid), + "wallet A's own claim on P is still outstanding, so the row itself survives" + ) + XCTAssertNil( + txo(container, txid: loserTxid, vout: 2), + "the loser's own output must not survive even a single committed callback, " + + "regardless of which wallet's callback that was" + ) + let row = try XCTUnwrap(transaction(container, txid: loserTxid)) + XCTAssertTrue( + row.isGloballySwept, + "any callback that reaches the sweep must flag the row, not just wallet A's own" + ) + } + + // Restart: a fresh handler/container over the same file. Wallet A's + // callback never happens in this test, simulating a crash or a + // rejection that stops it from ever arriving — the exact scenario + // the finding describes. + let (handler, container) = try makeHandler(url: storeURL) + + XCTAssertNil( + txo(container, txid: loserTxid, vout: 2), + "the phantom output must not resurrect across a restart" + ) + let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) + XCTAssertFalse(erroredA) + XCTAssertFalse( + txidsA.contains { $0.txid == loserTxid }, + "wallet A must not be able to enumerate the swept loser as its own transaction " + + "after a restart, even though it is still linked via involvedAccounts and " + + "its own callback never ran" + ) + } + + /// Cross-round reinstatement — the BLOCKING finding this round fixes. + /// The sweep and its reinstating record land in two SEPARATE + /// `persistWalletChangeset` rounds, with wallet B's still-outstanding + /// claim keeping the shared row physically present in between, exactly + /// as `testSharedLoserOutputAndEnumerationAreExcludedAfterOnlyOneWalletsCallbackCommits` + /// establishes on its own. Before the fix, `upsertTransaction` bailed + /// unconditionally on `isGloballySwept == true`, so round 2's record — + /// upstream's newer word, per `CoreChangeSet::merge`'s documented + /// IS-lock-precedence sequence (swept by an IS-locked conflict, then + /// returns chainlocked and sweeps that conflict in turn) — would be + /// silently discarded forever, and `upsertUtxo` would keep rejecting + /// its output on the strength of a tombstone nothing could ever clear. + /// Verified across a restart: the reinstatement has to be durable, not + /// merely visible in the context that just applied it. + func testAReinstatingRecordInALaterRoundRevivesASweptTransactionAndItsOutputs() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-reinstatement-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + let loserTxid = Data(repeating: 0xB1, count: 32) + let winner = Data(repeating: 0xB2, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + + do { + let (handler, container) = try makeHandler(url: storeURL) + try seedSharedLoserWithOutputAndInvolvedAccount( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Round 1: only wallet B's own sweep callback runs, releasing + // nothing. Wallet A's own claim on P (its funding coin) is still + // outstanding, so the shared row survives physically even + // though the global half of the sweep already tombstoned it and + // deleted its phantom output. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + let tombstoned = try XCTUnwrap(transaction(container, txid: loserTxid)) + XCTAssertTrue(tombstoned.isGloballySwept, "sanity: the row is tombstoned after round 1") + XCTAssertNil( + txo(container, txid: loserTxid, vout: 2), + "sanity: the loser's own output is gone after round 1" + ) + + // Round 2, a SEPARATE callback (not coalesced with round 1's + // sweep — the cross-round shape the merge-level fix in + // `CoreChangeSet::merge` cannot reach): the wallet returns + // chainlocked and sweeps the erstwhile winner in turn. Arrives + // here exactly like any freshly-detected transaction would — + // nothing marks it as "the reinstating one" — with its own + // output riding along in the same round the way a transaction's + // outputs ordinarily do. + deliverReinstatingRecord( + handler, + walletId: walletId, + txid: loserTxid, + context: 3, // inChainLockedBlock + blockHeight: 200, + inputOutpoints: [(txid: fundingTxid, vout: 0)], + outputVout: 2, + outputAmount: 60_000, + outputAddress: "yLoserChange" + ) + + let reinstated = try XCTUnwrap( + transaction(container, txid: loserTxid), + "the reinstating record must not be discarded" + ) + XCTAssertFalse( + reinstated.isGloballySwept, + "a later record naming a tombstoned txid must clear the tombstone" + ) + XCTAssertEqual(reinstated.blockHeight, 200) + + let revivedOutput = try XCTUnwrap( + txo(container, txid: loserTxid, vout: 2), + "the reinstated transaction's own output must come back" + ) + XCTAssertEqual(revivedOutput.amount, 60_000) + + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(p.isSpent, "wallet A reclaims its input once its own record is live again") + XCTAssertEqual(p.spendingTransaction?.txid, loserTxid) + + let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) + XCTAssertFalse(erroredA) + XCTAssertTrue( + txidsA.contains { $0.txid == loserTxid }, + "wallet A must be able to enumerate the reinstated transaction as its own again" + ) + } + + // Restart: a fresh handler/container over the same file. The + // reinstatement has to be durable, not just visible to the context + // that applied it. + let (handler, container) = try makeHandler(url: storeURL) + + let survived = try XCTUnwrap(transaction(container, txid: loserTxid)) + XCTAssertFalse(survived.isGloballySwept, "the reinstatement must survive a restart") + XCTAssertNotNil( + txo(container, txid: loserTxid, vout: 2), + "the revived output must survive a restart" + ) + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(p.isSpent, "the reclaimed input must survive a restart") + XCTAssertEqual(p.spendingTransaction?.txid, loserTxid) + + let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) + XCTAssertFalse(erroredA) + XCTAssertTrue( + txidsA.contains { $0.txid == loserTxid }, + "the reinstated transaction must still enumerate as wallet A's own after a restart" + ) + } + + /// A failed wallet lookup must fail the round, not read as "no such + /// wallet". + /// + /// `try?` collapsed the two: a thrown SwiftData fetch returned success + /// without applying the sweep, Rust discarded the subtractive event, and + /// a later round could then persist a height beyond a removal that never + /// landed. Driving the real failure is awkward, so this pins the + /// distinction that makes it impossible — a wallet that genuinely is not + /// there is still a successful no-op. + func testAMissingWalletIsASuccessfulNoOp() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + // Delete the wallet row, leaving the fetch to succeed and find + // nothing — the branch that must stay a success. + let context = ModelContext(container) + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try context.fetch(descriptor) { + context.delete(row) + } + try context.save() + + let applied = sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + XCTAssertTrue(applied, "a stale post-deletion callback is not a failure") + XCTAssertNotNil( + transaction(container, txid: sweptTxid), + "and it must not have applied anything either" + ) + } + + /// Companion to `testAMissingWalletIsASuccessfulNoOp` above, which its + /// own doc admits does not distinguish the fix from the old `try?` + /// behavior — a successful empty fetch reads identically either way. + /// This drives a genuinely THROWING fetch instead, using a real seam + /// rather than a mock: a file-backed store (so the container's SQLite + /// connection is live and long-lived, unlike the in-memory variant) is + /// truncated on disk, out from under that open connection, between + /// seeding and the sweep. `fetchWalletRecord`'s `context.fetch` then has + /// to perform real I/O against a file that is no longer a valid SQLite + /// database, which is the only way found to make it throw without + /// adding a test-only injection point to production code. + func testAThrowingWalletLookupFailsTheRound() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-throwing-lookup-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + + let (handler, _) = try makeHandler(url: storeURL) + + // Corrupt the on-disk store out from under the still-open container + // BEFORE any context — including a seed helper's — reads or writes + // through it: SwiftData's row cache is scoped to the persistent + // store coordinator, not to any one `ModelContext`, so a row + // touched by a throwaway seeding context would still be served from + // that shared cache here and never reach disk at all. With nothing + // cached yet, `fetchWalletRecord`'s fetch is the first real read + // this store ever performs, and it hits the truncated file — well + // short of a valid SQLite header — directly. + let handle = try FileHandle(forWritingTo: storeURL) + handle.truncateFile(atOffset: 16) + try handle.close() + + let applied = sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + XCTAssertFalse(applied, "a genuinely failed wallet lookup must fail the round") + } + + /// Two wallets, each holding an unresolved *released* input on the same + /// shared loser — the case where the row would otherwise never be + /// reclaimed. + /// + /// Left attached, a released pending input reads as its wallet's claim + /// in the ownership check, so A declines the delete because B's row is + /// there and B declines because A's is: a stalemate no replay breaks. + /// The dead transaction contributes no funds either way thanks to the + /// global marker, so this is storage rather than balance — but the row + /// and both pending entries would be kept forever. + func testTwoWalletsReleasedPendingInputsDoNotDeadlockTheRowDelete() throws { + let (handler, container) = try makeHandler() + let walletB = Data(repeating: 0x02, count: 32) + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletId, walletB: walletB, loserTxid: sweptTxid + ) + + // Each wallet has one pending input on the loser, and each will be + // released by its own wallet's sweep. + let context = ModelContext(container) + let loserTxid = sweptTxid + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == loserTxid } + ) + descriptor.fetchLimit = 1 + let loser = try XCTUnwrap(try context.fetch(descriptor).first) + let pendingA = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 8), + inputIndex: 0, + spendingTxid: loserTxid, + spendingTransaction: loser, + walletId: walletId + ) + let pendingB = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 9), + inputIndex: 1, + spendingTxid: loserTxid, + spendingTransaction: loser, + walletId: walletB + ) + context.insert(pendingA) + context.insert(pendingB) + try context.save() + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 8)]) + ]) + sweep( + handler, + [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 9)])], + walletId: walletB + ) + + XCTAssertNil( + transaction(container, txid: sweptTxid), + "a released pending input is not a claim once its own wallet has resolved it" + ) + } + + /// A txid the store has never seen is not an error: sweeps are + /// idempotent, and a round can name a transaction this mirror never + /// recorded in the first place. + func testSweepingAnUnknownTransactionIsANoOp() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + let applied = sweep(handler, [ + Batch(losers: [Data(repeating: 0x99, count: 32)], winner: winnerTxid, winnerMinedHeight: 400) + ]) + + XCTAssertTrue(applied, "an absent row is a successful no-op, not a failed round") + XCTAssertNotNil(transaction(container, txid: sweptTxid)) + XCTAssertNotNil(transaction(container, txid: fundingTxid)) + } + + /// The loser can be persisted before its own funding output ever is — + /// `upsertTransaction` parks a spend like that as a `PersistentPendingInput` + /// rather than a `PersistentTxo` update (see `resolveInputOutpoint`). + /// When the sweep holds that input (it's not in `released`), there is no + /// `PersistentTxo` row to mark — the only record of the claim is the + /// pending row, which cascades away with the loser it names unless + /// `applySweptTransaction` rescues it first. This is the regression the + /// review finding described: seed the pending spend, sweep it, restart + /// the store, and only then let the funding UTXO arrive. The coin must + /// come back spent, attributed to the winner, not as a fresh unspent row. + func testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-pending-input-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + + do { + let (handler, container) = try makeHandler(url: storeURL) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let swept = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(swept) + // What `resolveInputOutpoint` would have written: the funding + // TXO for (fundingTxid, 0) has never been seen here. + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: swept, + walletId: walletId + )) + try context.save() + XCTAssertNil( + txo(container, txid: fundingTxid, vout: 0), + "sanity: the funding TXO has not arrived yet" + ) + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the loser is gone") + } + + // Restart: a fresh persister loading the same on-disk store. + let (handler, container) = try makeHandler(url: storeURL) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the funding UTXO's own upsert must still create the row" + ) + XCTAssertTrue( + coin.isSpent, + "the winner's claim must survive the loser's deletion, a restart, " + + "and the funding UTXO's own arrival" + ) + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// Records precede sweeps within a round, so a wallet-relevant winner + /// whose own funding side is ALSO unobserved stages an ordinary pending + /// row for the same outpoint moments before the sweep repoints the + /// loser's row into a tombstone — and the tombstone keeps the loser's + /// original, older `createdAt`. The drain's newest-wins pick then + /// selected the winner's ordinary row, took the gated branch (`isSpent` + /// stays false until the winner confirms — never, for an IS-locked + /// unconfirmed winner), skipped the `supersededByTxid` stamp, and + /// deleted every pending row including the tombstone: the durable hold + /// evaporated and the consumed coin re-entered the restore set. + func testAWinnersOwnPendingRowDoesNotEvaporateTheSweepTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let outpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + + // The doomed spend arrived before its funding output — parked as a + // pending row, exactly what `resolveInputOutpoint` writes. Backdated + // so the winner's row below is strictly newer, as it always is in + // reality (the loser's record preceded the winner's by definition). + let loser = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(loser) + let losersClaim = PersistentPendingInput( + outpoint: outpoint, + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: loser, + walletId: walletId + ) + losersClaim.createdAt = Date(timeIntervalSinceNow: -10) + context.insert(losersClaim) + + // The winner's own record — IS-locked, still unconfirmed — lands in + // the same round as the sweep, records first, and stages its own + // ordinary pending row for the same still-unfunded outpoint. + let winner = PersistentTransaction( + txid: winnerTxid, + transactionData: Data(repeating: 0x06, count: 10), + context: 1, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(winner) + context.insert(PersistentPendingInput( + outpoint: outpoint, + inputIndex: 0, + spendingTxid: winnerTxid, + spendingTransaction: winner, + walletId: walletId + )) + try context.save() + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + // Sanity: the coexisting pair this regression is about — the + // winner's ordinary row plus the repointed tombstone. + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + let rows = try context.fetch(pendingDescriptor) + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(rows.filter(\.isSweptTombstone).count, 1) + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + coin.isSpent, + "the sweep's hold must survive the winner's own coexisting pending row" + ) + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// Chained-sweep continuation of `testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent` + /// above: L spends P; W spends P and Q and sweeps L, holding P (still + /// unfunded); X spends Q and sweeps W, this time releasing P. The + /// tombstone `applySweptTransaction` wrote for P when L was swept + /// already detached from `spendingTransaction`, so the second sweep of + /// W cannot find it through `row.pendingInputs` the way the first sweep + /// did — it can only be found by the scalar `spendingTxid` it now + /// carries. This is the review finding: without that second lookup, the + /// second sweep's release of P is silently dropped, and P's funding TXO + /// resurrects the coin attributed to the wrong (already deleted) + /// transaction instead of coming back spendable. + func testChainedSweepBeforeFundingReleasesAnEarlierTombstoneOnASecondSweep() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x61, count: 32) // L + let secondLoser = Data(repeating: 0x62, count: 32) // W + let finalWinner = Data(repeating: 0x63, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(l) + // P (fundingTxid:0) has never been observed as a TXO — parked as a + // pending input, the same as `testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent`. + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding P (still unfunded). + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) + + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let tombstoneDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + let tombstone = try XCTUnwrap(try context.fetch(tombstoneDescriptor).first) + XCTAssertTrue(tombstone.isSweptTombstone, "the first sweep must tombstone the pending row") + XCTAssertEqual(tombstone.spendingTxid, secondLoser) + XCTAssertNil(tombstone.spendingTransaction, "must have detached from the doomed loser's FK") + + // W's own row, plus a materialized claim on Q, needed for the + // second sweep to find W at all — the same requirement any sweep of + // a wallet-relevant loser has. + let w = PersistentTransaction( + txid: secondLoser, + transactionData: Data(repeating: 0x06, count: 10), + context: 0, + blockHeight: 0, + netAmount: -90_000 + ) + context.insert(w) + let qFunding = PersistentTransaction( + txid: Data(repeating: 0x65, count: 32), + transactionData: Data(repeating: 0x09, count: 10), + context: 2, + blockHeight: 100, + netAmount: 40_000 + ) + context.insert(qFunding) + let coinQ = PersistentTxo( + transaction: qFunding, + vout: 0, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinQ.walletId = walletId + coinQ.spendingTransaction = w + context.insert(coinQ) + try context.save() + + // Second sweep: X beats W, this time releasing P. + sweep(handler, [ + Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ]) + + let survivingTombstones = try context.fetch(tombstoneDescriptor) + XCTAssertTrue( + survivingTombstones.isEmpty, + "a released outpoint's tombstone must not survive a chained sweep" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the funding UTXO's own upsert must still create the row" + ) + XCTAssertFalse( + coin.isSpent, + "the final sweep released this coin, so it must come back spendable even " + + "though an earlier sweep in the chain had tombstoned it" + ) + XCTAssertNil(coin.supersededByTxid) + } + + /// The held (not released) half of the chained scenario above: the + /// second sweep keeps P spent instead of releasing it, and the + /// tombstone must end up attributed to the NEW winner rather than the + /// intermediate one that no longer has a row. + func testChainedSweepBeforeFundingRepointsAnEarlierTombstoneToTheNewWinner() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x71, count: 32) // L + let secondLoser = Data(repeating: 0x72, count: 32) // W + let finalWinner = Data(repeating: 0x73, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding P. + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) + + // W's own row — this time claiming ONLY P, so the second sweep has + // no other input to reason about. + let w = PersistentTransaction( + txid: secondLoser, + transactionData: Data(repeating: 0x06, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(w) + try context.save() + + // Second sweep: X beats W, still holding the same input. + sweep(handler, [Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400)]) + + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let tombstoneDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + let tombstone = try XCTUnwrap(try context.fetch(tombstoneDescriptor).first) + XCTAssertTrue(tombstone.isSweptTombstone) + XCTAssertEqual( + tombstone.spendingTxid, + finalWinner, + "the tombstone must be repointed at the FINAL winner, not the intermediate " + + "one the second sweep already removed" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + coin.isSpent, + "the final winner's claim must survive both sweeps and the funding UTXO's own arrival" + ) + XCTAssertEqual(coin.supersededByTxid, finalWinner) + } + + /// The multi-loser batch shape upstream's descendant closure always + /// produces — parent P and child C removed together — which no fixture + /// here ever exercised: C spends P:0, still unfunded, so the claim + /// lives as a pending row. Upstream never releases a loser-funded + /// outpoint, so without a co-swept check the sweep tombstones the + /// claim to the winner — and P's chainlocked reinstatement then + /// re-delivers P:0 straight into the tombstone-outranks drain: + /// `isSpent = true`, `supersededByTxid = winner`, recovery clear + /// refusing stamped holds. Permanently unspendable. A dead parent's + /// output is nobody's coin; the claim must be deleted with the batch. + func testABatchSweepingParentAndChildDeletesTheChildsClaimOnTheParentsOutput() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + // P is `fundingTxid` (so the redelivery helper reaches it) and its + // record was never persisted — the weaker-preconditions shape. C's + // claim on P:0 is parked as a pending row, exactly what + // `resolveInputOutpoint` writes. + let childTxid = Data(repeating: 0xB5, count: 32) // C + let winner = Data(repeating: 0xB6, count: 32) // W + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + + let c = PersistentTransaction( + txid: childTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(c) + context.insert(PersistentPendingInput( + outpoint: pOutpoint, + inputIndex: 0, + spendingTxid: childTxid, + spendingTransaction: c, + walletId: walletId + )) + try context.save() + + // One batch removes both; upstream excludes P:0 from the released + // set because its funder is itself a loser. + sweep(handler, [Batch(losers: [fundingTxid, childTxid], winner: winner, winnerMinedHeight: 400)]) + + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + XCTAssertTrue( + try context.fetch(pendingDescriptor).isEmpty, + "a claim on a co-swept parent's output must be deleted, not tombstoned" + ) + + // The chainlocked return: P reinstated with its output re-delivered + // must land spendable — nothing the batch left behind may hold it. + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse( + coin.isSpent, + "the reinstated parent's output must not be wedged by its dead child's claim" + ) + XCTAssertNil(coin.supersededByTxid) + } + + /// The whole chain inside ONE round: a single sweeps callback can carry + /// two batches where the second sweeps the first's winner, so the + /// tombstone the first batch just wrote — staged, unsaved, retargeted by + /// nothing but in-memory mutation — must be visible to the second + /// batch's scalar reconciliation. Pins the per-batch tombstone scan + /// reading the mutable columns off live objects; a store-side predicate + /// would test the stale saved values and miss the row entirely. + func testChainedSweepAcrossTwoBatchesInOneRoundReleasesTheFreshTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0xA1, count: 32) // L + let secondLoser = Data(repeating: 0xA2, count: 32) // W — batch 1's winner + let finalWinner = Data(repeating: 0xA3, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // One callback, two batches: W beats L holding the unfunded coin, + // then X beats W and frees it. + sweep(handler, [ + Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400), + Batch( + losers: [secondLoser], + winner: finalWinner, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + ), + ]) + + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + XCTAssertTrue( + try context.fetch(pendingDescriptor).isEmpty, + "the second batch must find and release the tombstone the first batch just wrote" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(coin.isSpent, "the released coin funds as spendable") + XCTAssertNil(coin.supersededByTxid) + } + + /// The funding-BEFORE-release ordering of the chained scenario above: + /// the funding TXO arrives between the sweep that held the coin and the + /// sweep that frees it, so the tombstone drains into + /// `PersistentTxo.supersededByTxid` and the pending row is gone by the + /// time the release runs. With the intermediate winner's own record on + /// hand the drain links `spendingTransaction` too, so the release DOES + /// reach the row through `row.inputs` — but nothing cleared the marker, + /// and a released coin keeping its dead winner's marker turns the next + /// hold on this outpoint permanent (`upsertUtxo`'s recovery clear reads + /// a present marker as a durable claim). + func testAReleasedCoinDropsItsDeadWinnersMarker() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x91, count: 32) // L + let secondLoser = Data(repeating: 0x92, count: 32) // W + let finalWinner = Data(repeating: 0x93, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding the still-unfunded coin. + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) + + // W's own record lands before the funding TXO does, so the drain + // below links `spendingTransaction` as well as stamping the marker. + let w = PersistentTransaction( + txid: secondLoser, + transactionData: Data(repeating: 0x06, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(w) + try context.save() + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let stamped = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(stamped.isSpent, "sanity: the drained claim holds the coin") + XCTAssertEqual(stamped.supersededByTxid, secondLoser) + + // Second sweep: X beats W, and this time upstream frees the coin. + sweep(handler, [ + Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ]) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(freed.isSpent, "the released coin is spendable again") + XCTAssertNil(freed.spendingTransaction) + XCTAssertNil( + freed.supersededByTxid, + "the dead winner's marker goes with the hold it carried" + ) + } + + /// The unreachable-claim variant of the same ordering: the claim + /// drained into `PersistentTxo.supersededByTxid`, its pending row is + /// gone, and the winner it names was NEVER recorded here — so when that + /// winner is swept in turn there is no `row` to fetch, no `row.inputs` + /// to walk, and no tombstone left for the scalar reconciliation to + /// find. Only an outpoint-keyed release — the form Kotlin's + /// `releaseByOutpoint` and SQLite's outpoint-matched UPDATE both + /// implement — can reach the coin; without it the release is silently + /// dropped and the coin stays spent forever. + func testAReleaseReachesAClaimDrainedToTheTxoWhenTheWinnerWasNeverRecorded() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x94, count: 32) // L + let unrecordedWinner = Data(repeating: 0x95, count: 32) // W — never a row here + let finalWinner = Data(repeating: 0x96, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding the still-unfunded coin. + sweep(handler, [Batch(losers: [firstLoser], winner: unrecordedWinner, winnerMinedHeight: 400)]) + + // The funding TXO arrives with W still unrecorded: the drain stamps + // the marker but has no row to link. + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let stamped = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(stamped.isSpent, "sanity: the drained claim holds the coin") + XCTAssertEqual(stamped.supersededByTxid, unrecordedWinner) + XCTAssertNil(stamped.spendingTransaction, "sanity: no relationship to reach it by") + + // Second sweep: X beats the never-recorded W, freeing the coin. + sweep(handler, [ + Batch( + losers: [unrecordedWinner], + winner: finalWinner, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + ) + ]) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse( + freed.isSpent, + "the release must reach a drained claim even with no row and no tombstone left" + ) + XCTAssertNil(freed.supersededByTxid) + } + + /// The multi-wallet continuation of the chained scenarios above — the + /// review finding on the missing-row early return. A shared loser L + /// spends one still-unfunded coin of wallet A's and two of wallet B's, + /// so the first sweep leaves each wallet's claims as detached tombstones + /// pointing at winner W. When W's own record then arrives, + /// `resolveInputOutpoint`'s duplicate guard sees each `(outpoint, W)` + /// tombstone and attaches nothing to W's row — so when W is swept in + /// turn, wallet A's callback finds no other wallet's claim on the row + /// and deletes it. Wallet B's independently committed callback then runs + /// against a row that no longer exists, and before the fix returned + /// without ever applying B's release decision: B's released coin would + /// later come back spent by the obsolete W, and B's held coin stayed + /// attributed to W, unable to follow any further sweep. + func testSharedWinnerDeletedByAnotherWalletsCallbackStillReconcilesThisWalletsTombstones() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + let walletB = Data(repeating: 0x02, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(PersistentWallet(walletId: walletB, network: .testnet)) + + let sharedLoser = Data(repeating: 0xC1, count: 32) // L + let sharedWinner = Data(repeating: 0xC2, count: 32) // W + let finalWinner = Data(repeating: 0xC3, count: 32) // X + + let l = PersistentTransaction( + txid: sharedLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -140_000 + ) + context.insert(l) + // None of the three coins L claims has been funded here yet: one of + // wallet A's (vout 0) and two of wallet B's (vouts 1 and 2), all + // parked as pending inputs the way `resolveInputOutpoint` does. + for (vout, owner) in [(UInt32(0), walletId), (1, walletB), (2, walletB)] { + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: vout), + inputIndex: vout, + spendingTxid: sharedLoser, + spendingTransaction: l, + walletId: owner + )) + } + try context.save() + + // First sweep, one independently committed callback per wallet: W + // beats L, holding everything (nothing funded, nothing released). + sweep(handler, [Batch(losers: [sharedLoser], winner: sharedWinner, winnerMinedHeight: 400)], walletId: walletId) + sweep(handler, [Batch(losers: [sharedLoser], winner: sharedWinner, winnerMinedHeight: 400)], walletId: walletB) + XCTAssertNil(transaction(container, txid: sharedLoser), "L is gone once both wallets ran") + + // W's own record arrives, claiming all three outpoints. The + // `(outpoint, W)` tombstones occupy the duplicate-guard key, so no + // new pending relationship attaches to W's row — the premise that + // lets wallet A's callback below delete it. + deliverReinstatingRecord( + handler, + walletId: walletId, + txid: sharedWinner, + context: 0, + blockHeight: 0, + inputOutpoints: [ + (txid: fundingTxid, vout: 0), + (txid: fundingTxid, vout: 1), + (txid: fundingTxid, vout: 2), + ], + outputVout: 0, + outputAmount: 120_000, + outputAddress: "yWinnerChange" + ) + + // Second sweep: X beats W. Wallet A's callback runs first, releases + // its own coin, and — finding no attached claim of any other + // wallet's — deletes the shared row. + sweep(handler, [ + Batch(losers: [sharedWinner], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ], walletId: walletId) + XCTAssertNil( + transaction(container, txid: sharedWinner), + "sanity: wallet A's callback deleted the shared winner row — the premise " + + "wallet B's callback below has to survive" + ) + + // Wallet B's callback arrives after the row is gone, releasing one + // of its two coins and holding the other. + sweep(handler, [ + Batch(losers: [sharedWinner], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 2)]) + ], walletId: walletB) + + let heldOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) + let heldDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == heldOutpoint } + ) + let heldTombstone = try XCTUnwrap( + try context.fetch(heldDescriptor).first, + "wallet B's held tombstone must survive the row's absence" + ) + XCTAssertEqual( + heldTombstone.spendingTxid, + finalWinner, + "the held tombstone must follow the chain to X even though W's row was " + + "already deleted by wallet A's callback" + ) + let releasedOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 2) + let releasedDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == releasedOutpoint } + ) + XCTAssertTrue( + try context.fetch(releasedDescriptor).isEmpty, + "wallet B's release decision must reach its tombstone even though W's row " + + "was already deleted by wallet A's callback" + ) + + // The funding TXOs finally arrive, one per owning wallet. + deliverFundingUtxo(handler, walletId: walletId, vout: 0, amount: 100_000) + deliverFundingUtxo(handler, walletId: walletB, vout: 1, amount: 40_000) + deliverFundingUtxo(handler, walletId: walletB, vout: 2, amount: 20_000) + + let coinA = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(coinA.isSpent, "wallet A's released coin comes back spendable") + let heldB = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(heldB.isSpent, "wallet B's held coin stays spent") + XCTAssertEqual( + heldB.supersededByTxid, + finalWinner, + "the held coin must be attributed to the final winner, not the deleted W" + ) + let releasedB = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 2)) + XCTAssertFalse( + releasedB.isSpent, + "wallet B's released coin must not resurrect spent under the obsolete winner" + ) + XCTAssertNil(releasedB.supersededByTxid) + } + + /// Hand a UTXO for `(fundingTxid, vout)` back through the ordinary + /// account changeset — the same entry point `redeliverCoinB` drives, but + /// generalized so a fresh outpoint can be delivered rather than the one + /// baked into `seedSpend`. + private func deliverFundingUtxo( + _ handler: PlatformWalletPersistenceHandler, + vout: UInt32, + amount: UInt64 + ) { + deliverFundingUtxo(handler, walletId: walletId, vout: vout, amount: amount) + } + + /// `walletId`-parameterized form for the multi-wallet tests, where each + /// wallet's own funding UTXO has to arrive through that wallet's own + /// changeset — the drain in `upsertUtxo` resolves the tombstone by + /// outpoint, but the round itself is wallet-scoped like every real one. + private func deliverFundingUtxo( + _ handler: PlatformWalletPersistenceHandler, + walletId: Data, + vout: UInt32, + amount: UInt64 + ) { + let name = strdup("Standard { index: 0 }") + let address = strdup("yFundAddr") + defer { + free(name) + free(address) + } + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + fundingTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = vout + utxo.amount = amount + utxo.address = address + utxo.height = 100 + utxo.is_confirmed = true + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Deliver a plain transaction record — with a fresh output of its own + /// riding along in the same round — through the ordinary account + /// changeset entry point. Models the reinstating event the BLOCKING + /// finding describes: upstream reports a previously-swept txid to + /// `records` exactly the way it reports any freshly-detected + /// transaction, with nothing on the wire flagging it as "the one that + /// used to be swept" — `upsertTransaction` has to infer that entirely + /// from the row it finds already sitting in the store. + private func deliverReinstatingRecord( + _ handler: PlatformWalletPersistenceHandler, + walletId: Data, + txid: Data, + context: UInt32, + blockHeight: UInt32, + inputOutpoints: [(txid: Data, vout: UInt32)], + outputVout: UInt32, + outputAmount: UInt64, + outputAddress: String + ) { + let name = strdup("Standard { index: 0 }") + let address = strdup(outputAddress) + defer { + free(name) + free(address) + } + + let inputs = UnsafeMutablePointer.allocate( + capacity: max(inputOutpoints.count, 1) + ) + for (i, input) in inputOutpoints.enumerated() { + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + input.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = input.vout + inputs.advanced(by: i).initialize(to: entry) + } + defer { + inputs.deinitialize(count: inputOutpoints.count) + inputs.deallocate() + } + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = blockHeight + record.input_outpoints = inputs + record.input_outpoints_count = UInt(inputOutpoints.count) + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = outputVout + utxo.amount = outputAmount + utxo.address = address + utxo.height = blockHeight + utxo.is_confirmed = true + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &record) { recordPtr in + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + // MARK: - Bounded tombstone lifetime + + /// The block-context winner's mined height used across the bounded- + /// lifetime tests — the stamp every tombstone carries, and the exact + /// boundary value at which it collects. + private static let winnerHeight: UInt32 = 400 + + /// One committed round carrying chain progress: the synced height, + /// (unless the caller opts out) opaque chainlock bytes, and — when + /// `chainLockHeight` is supplied — the NUMERIC chainlock height + /// through the extension's dedicated slot, fired inside the same + /// begin/end bracket after the changeset callback exactly the way the + /// Rust persister fires it. The bytes and the number are deliberately + /// independent knobs: the reviewer's point is precisely that bytes + /// alone must not enable collection. + private func heightsRound( + _ handler: PlatformWalletPersistenceHandler, + synced: UInt32, + chainLock: Bool = true, + chainLockHeight: UInt32? = nil + ) { + handler.beginChangeset(walletId: walletId) + var cs = WalletChangeSetFFI() + cs.has_chain = true + cs.chain.has_synced_height = true + cs.chain.synced_height = synced + var clBytes = [UInt8](repeating: 9, count: 84) + clBytes.withUnsafeMutableBufferPointer { buf in + if chainLock { + cs.last_applied_chain_lock_bytes = buf.baseAddress + cs.last_applied_chain_lock_bytes_len = UInt(buf.count) + } + withUnsafePointer(to: &cs) { csPtr in + _ = handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + if let chainLockHeight { + _ = handler.persistWalletChangesetChainLockHeight( + walletId: walletId, + height: chainLockHeight + ) + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Record a loser spending `(spentTxid, 0)` with the funding side + /// unobserved, then sweep it in the given winner context — + /// `winnerMinedHeight` non-nil leaves the stamped tombstone the + /// collection tests reason about; `nil` (an IS-locked, unmined winner) + /// must leave nothing. + private func seedSweptTombstone( + _ handler: PlatformWalletPersistenceHandler, + _ container: ModelContainer, + winnerMinedHeight: UInt32?, + spentTxid: Data? = nil, + loser: Data? = nil, + winner: Data? = nil + ) throws { + let loser = loser ?? sweptTxid + let context = ModelContext(container) + let swept = PersistentTransaction( + txid: loser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(swept) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: spentTxid ?? fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: loser, + spendingTransaction: swept, + walletId: walletId + )) + try context.save() + sweep(handler, [Batch( + losers: [loser], + winner: winner ?? winnerTxid, + winnerMinedHeight: winnerMinedHeight + )]) + } + + private func pendingRows( + _ container: ModelContainer, + spentTxid: Data? = nil + ) throws -> [PersistentPendingInput] { + let outpoint = PersistentTxo.makeOutpoint(txid: spentTxid ?? fundingTxid, vout: 0) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return try ModelContext(container).fetch(descriptor) + } + + /// Every pending-input row this wallet holds, regardless of outpoint — + /// the attacker-growth metric the mempool-context tests measure. + private func walletPendingRows( + _ container: ModelContainer + ) throws -> [PersistentPendingInput] { + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + return try ModelContext(container).fetch(descriptor) + } + + /// This wallet's persisted row, for asserting on the stored numeric + /// chainlock height. + private func walletRow(_ container: ModelContainer) throws -> PersistentWallet? { + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + return try ModelContext(container).fetch(descriptor).first + } + + /// The attacker-shaped row's lawful cousin: a block-context sweep's + /// tombstone stores the WINNER'S own mined height and is collected + /// exactly when the finality boundary `min(chainlockHeight, + /// syncedHeight)` reaches it — upstream key-wallet's + /// `prune_finalized_observed_spends` condition verbatim, no + /// observation-age margin. At that boundary the funding transaction of + /// the guarded outpoint (necessarily mined at or below the winner's + /// height) has been filter-scanned with no false negatives, so an + /// undrained tombstone is provably not guarding the wallet's coin. + func testASweptTombstoneIsCollectedAtFinalityAndNotBefore() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + let tombstone = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(tombstone.isSweptTombstone, "sanity: the sweep flagged the row") + XCTAssertEqual( + tombstone.winnerMinedHeight, Self.winnerHeight, + "the tombstone is stamped with the WINNER'S own mined height — " + + "not any observation watermark" + ) + + heightsRound( + handler, + synced: Self.winnerHeight - 1, + chainLockHeight: Self.winnerHeight - 1 + ) + XCTAssertEqual( + try pendingRows(container).count, 1, + "boundary \(Self.winnerHeight - 1) has not reached the winner's " + + "height \(Self.winnerHeight) — the hold stays" + ) + + heightsRound(handler, synced: Self.winnerHeight, chainLockHeight: Self.winnerHeight) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the boundary reaching the winner's height collects the row — no margin" + ) + } + + /// The reviewer's "weaker still" point, named: synced-height progress + /// plus even PRESENT chainlock BYTES must not collect — the bincode + /// blob proves a chainlock was once applied, but says nothing about + /// how far finality reaches. Only the NUMERIC chainlock height + /// delivered through the extension slot supplies the boundary's + /// chainlock half, mirroring upstream's (and the SQLite store's) + /// "no-op until a chainlock height has been persisted". + func testASweptTombstoneOutlivesSyncProgressWithoutANumericChainLockHeight() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + heightsRound(handler, synced: 10_000, chainLock: true) + XCTAssertEqual( + try pendingRows(container).count, 1, + "chainlock BYTES exist and the synced height is far past the " + + "stamp — but no numeric chainlock height has ever been " + + "stored, so no finality boundary exists and the hold stays" + ) + + heightsRound(handler, synced: 10_000, chainLockHeight: 10_000) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the first NUMERIC chainlock height supplies the boundary and " + + "the long-aged stamp collects" + ) + } + + /// The genuine claim the tombstone exists for: its funding TXO arrives, + /// the drain moves the hold onto the TXO row (`supersededByTxid`) and + /// deletes the pending rows — so no amount of later boundary progress + /// may touch the materialised hold. + func testADrainedClaimIsImmuneToTheCollector() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + XCTAssertEqual( + try XCTUnwrap(try pendingRows(container).first).winnerMinedHeight, + Self.winnerHeight, + "sanity: held, undrained, stamped with the winner's height" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "sanity: the drain consumed the pending rows" + ) + + heightsRound(handler, synced: 10_000, chainLockHeight: 10_000) + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the materialised claim's row survives collection" + ) + XCTAssertTrue(coin.isSpent, "still held spent by the winner's claim") + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// A held tombstone with a nil winner-height stamp is never collected. + /// The mempool-context sweep path writes exactly this shape — an + /// IS-locked, unmined winner has no finality horizon to stamp — and + /// legacy rows read identically. With no proof of finality the safe + /// reading is to hold it forever rather than guess. + /// Replaces the rejected back-fill design, which stamped such a row + /// with the current height and thereby fabricated a finality horizon. + func testATombstoneWithoutAWinnerHeightIsNeverCollected() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + // The real writer: an IS-context sweep of a loser whose funding + // TXO never arrived. + try seedSweptTombstone(handler, container, winnerMinedHeight: nil) + + // Two rounds, not one: a back-filling collector (the rejected + // design) would stamp the row on the first round and collect it on + // the second. + heightsRound(handler, synced: 1_000_000, chainLockHeight: 1_000_000) + heightsRound(handler, synced: 1_000_010, chainLockHeight: 1_000_010) + + let row = try XCTUnwrap( + try pendingRows(container).first, + "no winner height, no proof of finality — the hold outlasts any boundary" + ) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertNil( + row.winnerMinedHeight, + "and the stamp is never back-filled — that would fabricate the horizon" + ) + } + + /// A chained sweep that re-points a still-unfunded claim to a new + /// BLOCK-context winner also re-stamps it with THAT winner's mined + /// height: the claim now belongs to a spend anchored at a later block, + /// and its collection horizon moves with it. + func testARepointedTombstoneIsRestampedToTheLaterSweep() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + XCTAssertEqual( + try XCTUnwrap(try pendingRows(container).first).winnerMinedHeight, + Self.winnerHeight, + "sanity: stamped with the first winner's mined height" + ) + + // The first winner is itself swept — by a winner mined 50 blocks + // later — the chained-sweep continuation that re-points the + // earlier tombstone (no row needed: the tombstone is found by the + // scalar `spendingTxid` it carries). + let finalWinner = Data(repeating: 0x66, count: 32) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: Self.winnerHeight + 50 + )]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.winnerMinedHeight, Self.winnerHeight + 50, + "re-pointed to a later block-context winner ⇒ re-stamped to " + + "THAT winner's mined height" + ) + + // And the horizon moved with it: the old height no longer collects, + // the new one does. + heightsRound( + handler, + synced: Self.winnerHeight + 49, + chainLockHeight: Self.winnerHeight + 49 + ) + XCTAssertEqual( + try pendingRows(container).count, 1, + "the boundary reaching only the FIRST winner's height must no " + + "longer collect the re-stamped claim" + ) + heightsRound( + handler, + synced: Self.winnerHeight + 50, + chainLockHeight: Self.winnerHeight + 50 + ) + XCTAssertTrue(try pendingRows(container).isEmpty) + } + + /// A mempool-context sweep — an InstantSend-locked winner that has not + /// mined — preserves an UNSTAMPED tombstone for every held-but-unfunded + /// input. Under DIP-10 the IS lock alone settles those inputs: upstream + /// deletes the loser and retains them in the account's + /// `spent_outpoints`, a hold with no height that no record survives to + /// rebuild (the winner need not be wallet-relevant). The tombstone is + /// that hold's only durable carrier — `CORE_SWEEP_REMOVAL` requires + /// every non-released input to keep a durable spend claim before its + /// funding TXO materializes — and it is unstamped because an IS-locked + /// winner has no mining deadline, so no boundary may ever collect it. + func testAMempoolContextSweepPreservesAnUnstampedTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + + for i in 0..<3 { + let spent = Data(repeating: UInt8(0x70 + i), count: 32) + try seedSweptTombstone( + handler, + container, + winnerMinedHeight: nil, + spentTxid: spent, + loser: Data(repeating: UInt8(0x80 + i), count: 32), + winner: Data(repeating: UInt8(0x90 + i), count: 32) + ) + let row = try XCTUnwrap( + try pendingRows(container, spentTxid: spent).first, + "an unmined IS-locked winner must leave a held tombstone for input #\(i)" + ) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertNil(row.winnerMinedHeight, "and it carries no finality stamp") + } + // Arbitrary chainlock/height advancement never collects an + // unstamped hold — two rounds, so a back-filling collector would + // be caught too. + heightsRound(handler, synced: 1_000_000, chainLockHeight: 1_000_000) + heightsRound(handler, synced: 1_000_010, chainLockHeight: 1_000_010) + XCTAssertEqual( + try walletPendingRows(container).count, 3, + "every unstamped hold outlasts any boundary — only funding " + + "materialization, a block-context re-stamp, or a release " + + "resolves one" + ) + } + + /// The mempool-context sweep still spend-marks a coin that HAS + /// materialised — that path is unchanged: the row carries real funding + /// data and `supersededByTxid` is its durable hold. The + /// never-materialised claim the same loser carries survives too, as an + /// unstamped tombstone — the pending row is the only durable carrier + /// of a hold upstream keeps in `spent_outpoints` and cannot rebuild + /// after the loser's record is gone. + func testAMempoolContextSweepStillSpendMarksAMaterialisedCoin() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + // The same loser also claims an input whose funding side was never + // observed — the shape that would have become a tombstone. + let unfundedTxid = Data(repeating: 0x77, count: 32) + let context = ModelContext(container) + let loserRow = try XCTUnwrap(transaction(container, txid: sweptTxid)) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: unfundedTxid, vout: 0), + inputIndex: 2, + spendingTxid: sweptTxid, + spendingTransaction: loserRow, + walletId: walletId + )) + try context.save() + + sweep(handler, [Batch( + losers: [sweptTxid], + winner: winnerTxid, + winnerMinedHeight: nil + )]) + + let coinB = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue( + coinB.isSpent, + "a materialised coin is spend-marked by the IS-locked winner exactly as before" + ) + XCTAssertEqual(coinB.supersededByTxid, winnerTxid) + let claim = try XCTUnwrap( + try pendingRows(container, spentTxid: unfundedTxid).first, + "while the never-materialised claim survives as a tombstone" + ) + XCTAssertTrue(claim.isSweptTombstone) + XCTAssertEqual(claim.spendingTxid, winnerTxid, "re-pointed at the winner") + XCTAssertNil(claim.winnerMinedHeight, "unstamped — the winner is unmined") + } + + /// The reviewer's named regression: an IS-locked winner sweeps on the + /// mempool path and never mines, the app restarts, chainlocks and + /// heights advance arbitrarily, and only then is the funding output + /// delivered. Under DIP-10 the IS lock already settled that input — + /// upstream deleted the loser and retained the hold in the account's + /// `spent_outpoints`, a set rebuilt from records on load that no + /// surviving record can reconstruct. The unstamped tombstone is the + /// claim's only durable carrier, so the funding delivery must drain + /// INTO it and land spent: crediting the coin would hand coin + /// selection an outpoint the network has provably consumed. + func testAFundingOutputArrivingAfterAMempoolSweepAndRestartLandsSpent() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("mempool-sweep-restart-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + + do { + let (handler, container) = try makeHandler(url: storeURL) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: nil) + XCTAssertNil(transaction(container, txid: sweptTxid), "sanity: the loser is gone") + let tombstone = try XCTUnwrap( + try walletPendingRows(container).first, + "sanity: the mempool sweep left the hold behind" + ) + XCTAssertTrue(tombstone.isSweptTombstone) + XCTAssertNil(tombstone.winnerMinedHeight, "unstamped — no finality horizon exists") + } + + // Restart: a fresh persister loading the same on-disk store, then + // arbitrary chainlock/height advancement while the winner stays + // unmined — none of it may collect the unstamped hold — and only + // then the funding delivery. + let (handler, container) = try makeHandler(url: storeURL) + heightsRound(handler, synced: 25_000, chainLockHeight: 25_000) + XCTAssertEqual( + try walletPendingRows(container).count, 1, + "the unstamped hold survives the restart and every boundary" + ) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the funding UTXO's own upsert must still create the row" + ) + XCTAssertTrue( + coin.isSpent, + "an input the IS-locked winner consumed must never come back " + + "spendable — the sweep's claim outlives the restart" + ) + XCTAssertEqual(coin.supersededByTxid, winnerTxid, "held by the winner the sweep named") + XCTAssertTrue( + try walletPendingRows(container).isEmpty, + "the claim drained into the TXO row" + ) + } + + /// The unrelated-advancement scenario, block-context half: the + /// chainlock can run arbitrarily far ahead, but while `syncedHeight` + /// sits below the winner's mined height the boundary has not reached + /// the spend and the hold must survive — the funding output could + /// still be delivered by the unscanned range. It collects the moment + /// the synced height catches up. + func testABlockContextTombstoneOutlivesUnrelatedAdvancementBelowItsWinnersHeight() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + // Chainlocks race ahead by thousands of blocks; the filter scan + // has only reached one block short of the winner. + heightsRound( + handler, + synced: Self.winnerHeight - 1, + chainLockHeight: Self.winnerHeight + 10_000 + ) + XCTAssertEqual( + try pendingRows(container).count, 1, + "min(chainlock, synced) = \(Self.winnerHeight - 1) is below the " + + "winner's height — any amount of unrelated chainlock " + + "progress must not collect the hold" + ) + + // No fresh chainlock this round: the changeset-path collector runs + // off the STORED numeric height. + heightsRound(handler, synced: Self.winnerHeight) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the scan reaching the winner's height completes the boundary and collects" + ) + } + + /// The other direction of the chained case: an UNSTAMPED hold + /// (IS-context sweep) re-pointed by a later BLOCK-context sweep gains + /// that winner's stamp — the claim now belongs to a spend anchored in + /// a real block, so it enters the collectible set and the boundary + /// reaching the new winner's height collects it. One of the three + /// resolution channels that bound the unstamped population. + func testAnUnstampedTombstoneRestampedByABlockContextSweepBecomesCollectible() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + // IS-context sweep: the hold lands unstamped. + try seedSweptTombstone(handler, container, winnerMinedHeight: nil) + XCTAssertNil( + try XCTUnwrap(try pendingRows(container).first).winnerMinedHeight, + "sanity: held and unstamped" + ) + + // The IS-locked first winner is itself beaten by a mined conflict + // still claiming the unfunded input — the chained-sweep + // continuation finds the tombstone by its scalar `spendingTxid`. + let finalWinner = Data(repeating: 0x66, count: 32) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: Self.winnerHeight + 50 + )]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.winnerMinedHeight, Self.winnerHeight + 50, + "the block-context re-point stamps the previously unstamped hold" + ) + + heightsRound( + handler, + synced: Self.winnerHeight + 50, + chainLockHeight: Self.winnerHeight + 50 + ) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "once stamped, the ordinary finality boundary collects the row" + ) + } + + /// The IS-locked half of the chained case: an unmined winner re-points + /// the claim but must NOT disturb the earlier block-context stamp — + /// upstream's observed-spend entry is never retracted by an + /// unconfirmed conflict. Collection at the retained height stays sound + /// (the funding output is mined at or below the FIRST spender's height + /// regardless of who claims the coin now), so the row still collects + /// at that boundary. + func testAMempoolRepointedTombstoneKeepsItsBlockContextStamp() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + // The first winner is evicted by an IS-locked, unmined conflict. + let finalWinner = Data(repeating: 0x66, count: 32) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: nil + )]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.winnerMinedHeight, Self.winnerHeight, + "an unmined winner re-points the claim without touching the " + + "earlier block-context stamp" + ) + + heightsRound(handler, synced: Self.winnerHeight, chainLockHeight: Self.winnerHeight) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the retained stamp still bounds the row: the funding output " + + "sits at or below the first spender's height, so the " + + "boundary reaching it proves delivery-or-never" + ) + } + + /// The chainlock-height extension callback stores monotonic-max on the + /// wallet row: chain locks only move forward, and a late or re-emitted + /// lower height must not walk the finality boundary backwards. + func testTheChainLockHeightCallbackStoresMonotonicMaxOnTheWalletRow() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + XCTAssertNil( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, + "sanity: fresh row, no numeric chainlock height yet" + ) + + heightsRound(handler, synced: 10, chainLockHeight: 500) + XCTAssertEqual( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, 500, + "the first height lands as stored" + ) + + heightsRound(handler, synced: 11, chainLockHeight: 300) + XCTAssertEqual( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, 500, + "a lower height must not walk the watermark backwards" + ) + + heightsRound(handler, synced: 12, chainLockHeight: 700) + XCTAssertEqual( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, 700, + "a higher height advances it" + ) + } +}