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 4d882e2ae55..c5ca42d2c14 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 7d4c4c2f0b7..c54a01991db 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -54,6 +54,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 @@ -117,6 +126,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 @@ -150,10 +225,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.modelContainer = modelContainer self.network = network 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`. /// @@ -330,10 +417,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 } @@ -798,9 +892,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. @@ -830,6 +950,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 @@ -848,10 +993,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 @@ -871,10 +1709,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 @@ -1019,6 +1865,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 @@ -1040,9 +1998,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 @@ -1064,8 +2019,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( @@ -1079,6 +2069,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { firstSeen: firstSeen ) backgroundContext.insert(record) + roundIndex?.transactionsByTxid[txidData] = record } record.context = tx.context @@ -1182,6 +2173,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 @@ -1195,26 +2224,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 @@ -1237,11 +2269,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, @@ -1250,6 +2285,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: walletId ) backgroundContext.insert(pending) + roundIndex?.pendingInputsByOutpoint[outpoint, default: []].append(pending) } } } @@ -1260,13 +2296,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) } } @@ -1279,11 +2312,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 @@ -1302,11 +2332,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 @@ -1318,6 +2365,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 = { @@ -1336,6 +2384,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.account = account record.walletId = resolvedWalletId backgroundContext.insert(record) + roundIndex?.txosByOutpoint[outpoint] = record } record.amount = utxo.amount @@ -1346,6 +2395,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 @@ -1353,11 +2418,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 } } @@ -1371,62 +2432,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) @@ -1434,6 +2515,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 @@ -1464,6 +2560,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) @@ -1474,10 +2582,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 @@ -1495,10 +2600,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 @@ -1508,18 +2610,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 @@ -1534,10 +2640,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() } @@ -1569,6 +2672,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.dpnsNameStates | PlatformWalletPersistenceCapabilities.trackedAssetLocks | PlatformWalletPersistenceCapabilities.trackedMasternodes + | PlatformWalletPersistenceCapabilities.coreSweepRemoval + | PlatformWalletPersistenceCapabilities.dashpayPayments ) } @@ -1584,6 +2689,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 } @@ -1659,14 +2778,23 @@ 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 { _ = walletId self.inChangeset = true + // 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() } } @@ -1693,8 +2821,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() } @@ -2890,7 +4022,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). } } @@ -3284,12 +4417,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( @@ -3303,6 +4432,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 @@ -3325,12 +4455,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 } } } @@ -5665,6 +6810,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) @@ -5770,9 +6923,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), @@ -6302,8 +7460,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 } ) ) @@ -6317,8 +7474,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, @@ -6515,6 +7671,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 @@ -7029,8 +8193,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" + ) + } +}