diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 4c0baa95899..ca3152a537d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2,15 +2,18 @@ import Foundation import SwiftData import DashSDKFFI -/// Read seam for the persistence reads whose failure must reject the round. +/// Read seam for the persistence reads whose failure must not be +/// mistaken for absence. /// /// The asset-lock guards withhold outputs a finalized lock has already /// consumed, so each of them treats an unreadable table as a failure /// rather than as "nothing to withhold". Those branches only run when a /// `fetch` throws, which a live store never does on demand, so the reads /// they protect are taken through a fetcher the handler owns instead of -/// calling the context directly. Production passes `LiveModelFetcher` — -/// `ModelContext.fetch` verbatim. +/// calling the context directly. The wallet-changeset round's reads go +/// through it too — a thrown row lookup rejects the round, a thrown +/// bulk prefetch demotes to row lookups — and so tests can count them. +/// Production passes `LiveModelFetcher` — `ModelContext.fetch` verbatim. protocol ModelFetching: Sendable { func fetch( _ descriptor: FetchDescriptor, @@ -987,14 +990,351 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // MARK: - Wallet Changeset (transactions, utxos, accounts, balance, chain) + /// Per-round lookup cache for the wallet-changeset apply path. + /// + /// A single changeset can carry thousands of transaction records + /// (an SPV catch-up folds many blocks into one `store()` round), + /// and the apply helpers used to issue an individual + /// `ModelContext.fetch` per row, per input, and per UTXO. Each of + /// those fetches re-evaluates its predicate against every object + /// staged (unsaved) in the open begin/end changeset bracket, so + /// the round's cost grew quadratically with its size — hours of + /// CPU for an 8k-record round on a large wallet. + /// + /// Instead, `buildWalletChangesetRoundCache` walks the changeset + /// once, bulk-fetches every row the round could touch with + /// chunked `IN` predicates, and the helpers hit these + /// dictionaries. Inserts and deletes performed during the round + /// update the cache in place so later rows observe them, exactly + /// as they observed staged objects through per-row fetches. + /// + /// A key found in a dictionary is a hit. A key absent from the + /// dictionary but present in the corresponding `prefetched*` set + /// is an authoritative miss (the bulk fetch covered it). A key in + /// neither (rare: values discovered mid-round, e.g. a pending + /// row's `spendingTxid` loaded from the store) falls back to a + /// single-row fetch. + /// + /// A fallback fetch that THROWS is never an answer: see + /// `fetchFailure` (rejects the round) and `pendingFetchFailed` + /// (leaves the key uncached so later reads retry). + private final class WalletChangesetRoundCache { + /// txid → transaction row (records, stubs, spending txs). + var transactions: [Data: PersistentTransaction] = [:] + /// 36-byte outpoint → TXO row. + var txos: [Data: PersistentTxo] = [:] + /// 36-byte outpoint → unresolved pending-input rows. A key + /// present with an empty array is authoritative: the rows + /// were deleted this round (or a fallback fetch found none). + var pendingInputs: [Data: [PersistentPendingInput]] = [:] + /// Base58Check address → core-address row. + var coreAddresses: [String: PersistentCoreAddress] = [:] + + /// Keys covered by the bulk prefetch — absence from the + /// dictionaries above is authoritative for these. TXOs and + /// pending inputs are keyed on the same outpoints but tracked + /// separately, so a failed chunk fetch of one entity only + /// demotes that entity's lookups to the per-row fallback. + var prefetchedTxids: Set = [] + var prefetchedTxoOutpoints: Set = [] + var prefetchedPendingOutpoints: Set = [] + var prefetchedAddresses: Set = [] + + /// Outpoints whose pending-input fallback fetch THREW. The cache + /// holds no authoritative answer for these: reads retry the fetch, + /// and inserts must not seed a dictionary entry that would read as + /// "this is the complete set". + var pendingFetchFailed: Set = [] + + /// First fallback fetch that threw this round. Once set, the + /// round is rejected (`persistWalletChangeset` reports failure, + /// `endChangeset` rolls every staged write back): the + /// transaction, TXO and account lookups all take "absent" as + /// license to insert a duplicate, and the core-address lookup + /// is held to the same rule so an unreadable store never + /// commits a partial round. Pending inputs are the exception — + /// a duplicate pending row resolves to the same TXO, so their + /// failed reads retry instead (`pendingFetchFailed`). + var fetchFailure: (model: String, error: Error)? + } + + /// Walk the changeset's account buckets, collect every txid / + /// outpoint / address the apply helpers could look up, and + /// bulk-fetch the matching rows in chunks (staying under SQLite's + /// bind-variable limit). One fetch per entity per ~900 keys + /// replaces one fetch per row. + private func buildWalletChangesetRoundCache( + accountsPtr: UnsafePointer, + count: Int + ) -> WalletChangesetRoundCache { + let cache = WalletChangesetRoundCache() + + for i in 0.. 0, let txsPtr = acc.transactions { + for t in 0.. 0 { + for j in 0.. 0, let utxosPtr = acc.utxos_added { + for u in 0.. 0, let spentPtr = acc.utxos_spent { + for s in 0.. 0, let ilPtr = acc.utxos_instant_locked { + for l in 0..( + predicate: #Predicate { chunk.contains($0.txid) } + ) + if let rows = try? modelFetcher.fetch(descriptor, in: backgroundContext) { + for row in rows { cache.transactions[row.txid] = row } + } else { + cache.prefetchedTxids.subtract(chunk) + } + } + for chunk in Self.chunked(Array(cache.prefetchedTxoOutpoints)) { + let txoDescriptor = FetchDescriptor( + predicate: #Predicate { chunk.contains($0.outpoint) } + ) + if let rows = try? modelFetcher.fetch(txoDescriptor, in: backgroundContext) { + for row in rows { cache.txos[row.outpoint] = row } + } else { + cache.prefetchedTxoOutpoints.subtract(chunk) + } + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { chunk.contains($0.outpoint) } + ) + if let rows = try? modelFetcher.fetch(pendingDescriptor, in: backgroundContext) { + for row in rows { + cache.pendingInputs[row.outpoint, default: []].append(row) + } + } else { + cache.prefetchedPendingOutpoints.subtract(chunk) + } + } + for chunk in Self.chunked(Array(cache.prefetchedAddresses)) { + let descriptor = FetchDescriptor( + predicate: #Predicate { chunk.contains($0.address) } + ) + if let rows = try? modelFetcher.fetch(descriptor, in: backgroundContext) { + for row in rows { cache.coreAddresses[row.address] = row } + } else { + cache.prefetchedAddresses.subtract(chunk) + } + } + + return cache + } + + /// Row read for a key outside the prefetched sets. A thrown fetch + /// rejects the round (see `WalletChangesetRoundCache.fetchFailure`) + /// and reads as empty here only so the caller can return; whatever + /// it stages afterwards is discarded with the round. + private func fallbackFetchAll( + _ descriptor: FetchDescriptor, + cache: WalletChangesetRoundCache + ) -> [T] { + do { + return try modelFetcher.fetch(descriptor, in: backgroundContext) + } catch { + if cache.fetchFailure == nil { + cache.fetchFailure = (String(describing: T.self), error) + } + return [] + } + } + + private func fallbackFetch( + _ descriptor: FetchDescriptor, + cache: WalletChangesetRoundCache + ) -> T? { + fallbackFetchAll(descriptor, cache: cache).first + } + + /// Log a thrown round read and report the round as failed. The C + /// shim forwards `false` as a non-zero code so Rust closes the round + /// as failed and `endChangeset` discards everything staged. + private func rejectChangesetRound(walletId: Data, model: String, error: Error) -> Bool { + SDKLogger.event( + "persistence_changeset_failed", + category: .persistence, + severity: .error, + fields: [ + "reason": .publicText("round_fetch_failed"), + "model": .publicText(model), + "wallet_reference": .reference(walletId), + ], + error: error + ) + return false + } + + /// Split `keys` into slices below SQLite's historical 999 + /// bind-variable limit so each `IN` predicate stays translatable. + private static func chunked(_ keys: [T], size: Int = 900) -> [[T]] { + stride(from: 0, to: keys.count, by: size).map { + Array(keys[$0.. PersistentTransaction? { + if let hit = cache.transactions[txid] { return hit } + if cache.prefetchedTxids.contains(txid) { return nil } + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + guard let row = fallbackFetch(descriptor, cache: cache) else { return nil } + cache.transactions[txid] = row + return row + } + + /// Cache-first TXO lookup, same fallback contract as + /// `cachedTransaction`. + private func cachedTxo( + outpoint: Data, + cache: WalletChangesetRoundCache + ) -> PersistentTxo? { + if let hit = cache.txos[outpoint] { return hit } + if cache.prefetchedTxoOutpoints.contains(outpoint) { return nil } + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + guard let row = fallbackFetch(descriptor, cache: cache) else { return nil } + cache.txos[outpoint] = row + return row + } + + /// Cache-first core-address lookup, same fallback contract as + /// `cachedTransaction`. + private func cachedCoreAddress( + address: String, + cache: WalletChangesetRoundCache + ) -> PersistentCoreAddress? { + if let hit = cache.coreAddresses[address] { return hit } + if cache.prefetchedAddresses.contains(address) { return nil } + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.address == address } + ) + guard let row = fallbackFetch(descriptor, cache: cache) else { return nil } + cache.coreAddresses[address] = row + return row + } + + /// Cache-first pending-input lookup. Leaves an entry for `outpoint` + /// in the dictionary after every successful read, so the result is + /// authoritative on subsequent hits (including "no rows"). + private func cachedPendingInputs( + outpoint: Data, + cache: WalletChangesetRoundCache + ) -> [PersistentPendingInput] { + if let rows = cache.pendingInputs[outpoint] { return rows } + if cache.prefetchedPendingOutpoints.contains(outpoint) { + cache.pendingInputs[outpoint] = [] + return [] + } + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + guard let rows = try? modelFetcher.fetch(descriptor, in: backgroundContext) else { + // A thrown fetch is not "no rows" — leave the dictionary + // unpopulated so the next read retries, and remember the + // failure so an insert can't seed an entry that would read + // as the complete set. + cache.pendingFetchFailed.insert(outpoint) + return [] + } + cache.pendingFetchFailed.remove(outpoint) + cache.pendingInputs[outpoint] = rows + return rows + } + /// Apply a full `WalletChangeSetFFI` to SwiftData. /// /// 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 a round read threw (see + /// `rejectChangesetRound`). A wallet row that is genuinely absent + /// still reports `true`: that drop is reserved for stale + /// post-deletion callbacks (see `ensureWalletRecord`). + func persistWalletChangeset( + walletId: Data, + changeset: UnsafePointer + ) -> Bool { onQueue { - guard let wallet = findWalletRecord(walletId: walletId) else { return } + let walletDescriptor = FetchDescriptor( + predicate: walletRecordPredicate(walletId: walletId) + ) + let walletRow: PersistentWallet? + do { + walletRow = try modelFetcher.fetch(walletDescriptor, in: backgroundContext).first + } catch { + return rejectChangesetRound( + walletId: walletId, + model: String(describing: PersistentWallet.self), + error: error + ) + } + guard let wallet = walletRow else { return true } let cs = changeset.pointee // Chain update. @@ -1034,15 +1374,29 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { wallet.lastUpdated = Date() } - // Per-account: transactions, UTXOs, pool state. + // Per-account: transactions, UTXOs, pool state. All row + // lookups go through a per-round bulk-prefetched cache — + // see `WalletChangesetRoundCache`. if cs.accounts_count > 0, let accountsPtr = cs.accounts { - for i in 0.. 0, let txsPtr = acc.transactions { - for i in 0.. 0, let utxosPtr = acc.utxos_added { - for i in 0.. 0, let spentPtr = acc.utxos_spent { - for i in 0.. 0, let ilPtr = acc.utxos_instant_locked { - for i in 0..( + _ ptr: UnsafeMutablePointer?, + count: UInt, + cache: WalletChangesetRoundCache, + _ body: (Entry) -> Void + ) { + guard count > 0, let ptr else { return } + for i in 0..( - predicate: #Predicate { $0.txid == txidData } - ) // The FFI projection always serializes the transaction body // (`dashcore::consensus::encode::serialize` upstream), so @@ -1259,7 +1626,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { tx.first_seen != 0 ? tx.first_seen : UInt64(Date().timeIntervalSince1970) let record: PersistentTransaction - if let existing = try? backgroundContext.fetch(descriptor).first { + if let existing = cachedTransaction(txid: txidData, cache: cache) { record = existing } else { record = PersistentTransaction( @@ -1273,6 +1640,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { firstSeen: firstSeen ) backgroundContext.insert(record) + cache.transactions[txidData] = record } record.context = tx.context @@ -1351,14 +1719,17 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if let inPtr = tx.input_outpoints, tx.input_outpoints_count > 0 { for i in 0..( - predicate: #Predicate { $0.outpoint == outpoint } - ) - if let txo = try? backgroundContext.fetch(txoDescriptor).first { + if let txo = cachedTxo(outpoint: outpoint, cache: cache) { // Flag and link move together — see // `reconcileSpendObservation` for the finality rule. let verdict = Self.reconcileSpendObservation( @@ -1419,23 +1788,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } // A pending entry from an earlier write is now stale — // resolved by this fetch. Drop it. - removePendingInputs(for: outpoint) + removePendingInputs(for: outpoint, cache: cache) } else { // Defer: record a pending row so a future `upsertUtxo` - // can complete the link. Writing one row per input is - // cheap; the cascade-delete relationship + the resolve - // path in `upsertUtxo` keep the table from growing - // unbounded. + // can complete the link. The cascade-delete relationship + // + the resolve path in `upsertUtxo` clean rows up once + // they resolve. // // Skip the write if a pending row for this exact // (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 { + let existing = cachedPendingInputs(outpoint: outpoint, cache: cache) + if !existing.contains(where: { $0.spendingTxid == spendingTxid }) { let pending = PersistentPendingInput( outpoint: outpoint, inputIndex: inputIndex, @@ -1444,6 +1810,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: walletId ) backgroundContext.insert(pending) + // When the fallback fetch for this outpoint failed, the + // dictionary must stay unpopulated: seeding it with just + // this row would read as the complete set. The staged row + // is still found by the retrying fallback fetch (pending + // changes are visible to fetches). + if !cache.pendingFetchFailed.contains(outpoint) { + cache.pendingInputs[outpoint, default: []].append(pending) + } } } } @@ -1453,19 +1827,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// pending entries don't linger as orphans, and from /// `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 { + private func removePendingInputs(for outpoint: Data, cache: WalletChangesetRoundCache) { + for row in cachedPendingInputs(outpoint: outpoint, cache: cache) { backgroundContext.delete(row) } + // Authoritatively empty for the rest of the round — but only + // after a successful lookup: when the fallback fetch threw, rows + // may survive in the store, and writing `[]` would hide them + // from every later access in the round. + if !cache.pendingFetchFailed.contains(outpoint) { + cache.pendingInputs[outpoint] = [] + } } - private func upsertUtxo(account: PersistentAccount, utxo: UtxoEntryFFI) { + private func upsertUtxo( + account: PersistentAccount, + utxo: UtxoEntryFFI, + cache: WalletChangesetRoundCache + ) { // Pull the per-account wallet id once. Used both for the new // `PersistentTxo.walletId` denorm (so per-wallet predicates // can hit a single column) and for stub-tx routing below. @@ -1473,11 +1852,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 = cachedTxo(outpoint: outpoint, cache: cache) { record = existing // Backfill if the account or wallet linkage is missing — // the per-wallet query path filters on TXO.walletId, so @@ -1496,11 +1872,8 @@ 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 = cachedTransaction(txid: txidData, cache: cache) { parentTx = existingTx } else { // Stub row — `transactionData` is left as empty @@ -1512,6 +1885,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // treats as miss. parentTx = PersistentTransaction(txid: txidData, transactionData: Data()) backgroundContext.insert(parentTx) + cache.transactions[txidData] = parentTx } let script: Data = { @@ -1530,6 +1904,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.account = account record.walletId = resolvedWalletId backgroundContext.insert(record) + cache.txos[outpoint] = record } record.amount = utxo.amount @@ -1546,14 +1921,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // paid to an address outside our pool, or out-of-order flush), // 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 { - record.coreAddress = coreAddr - } + if record.coreAddress == nil, !record.address.isEmpty, + let coreAddr = cachedCoreAddress(address: record.address, cache: cache) { + record.coreAddress = coreAddr } // Resolve any deferred spend signal that landed before this @@ -1566,11 +1936,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // 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 { + let pendingRows = cachedPendingInputs(outpoint: outpointKey, cache: cache) + if !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 @@ -1587,11 +1954,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { 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 + resolvedSpending = cachedTransaction(txid: pending.spendingTxid, cache: cache) } guard let spending = resolvedSpending else { continue } // Flag and link move together — see @@ -1622,9 +1985,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.spendingInputIndex = newest.inputIndex } record.lastUpdated = Date() - for row in pendingRows { - backgroundContext.delete(row) - } + removePendingInputs(for: outpointKey, cache: cache) } } @@ -1663,15 +2024,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (adoptLink: true, isSpent: false) } - private func markUtxoSpent(_ entry: SpentOutPointFFI) { + private func markUtxoSpent(_ entry: SpentOutPointFFI, cache: WalletChangesetRoundCache) { let outpoint = PersistentTxo.makeOutpoint( 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 = cachedTxo(outpoint: outpoint, cache: cache) else { return } // Link the spending transaction. The FFI now carries @@ -1689,10 +2047,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 = cachedTransaction(txid: spendingTxid, cache: cache) } } // When the spending tx isn't resolved this flush, leave the row @@ -1723,15 +2078,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // written a `PersistentPendingInput` row when the TXO // didn't yet exist. Drain any leftover pending rows for // this outpoint so they don't linger as orphans. - removePendingInputs(for: outpoint) + removePendingInputs(for: outpoint, cache: cache) } - private func markUtxoInstantLocked(_ op: OutPointFFI) { + private func markUtxoInstantLocked(_ op: OutPointFFI, cache: WalletChangesetRoundCache) { 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 = cachedTxo(outpoint: outpoint, cache: cache) { txo.isInstantLocked = true txo.lastUpdated = Date() } @@ -3573,14 +3925,49 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return true } + // Bulk-prefetch the address rows and the TXO-backfill rows in + // chunked `IN` fetches instead of two per-entry fetches — a + // restore emits thousands of entries per round, and each + // per-row fetch would re-scan the round's staged objects + // (same quadratic the wallet-changeset round cache removes). + let allAddresses = entries.map(\.address) + var existingRows: [String: PersistentCoreAddress] = [:] + var txosByAddress: [String: [PersistentTxo]] = [:] + // Addresses whose bulk row fetch FAILED (threw) — a miss for + // these is not authoritative, so the upsert loop falls back to + // a single-row fetch instead of inserting over the `.unique` + // address column. A failed TXO-backfill fetch just skips the + // backfill for the chunk, matching the old per-row `try?`. + var unresolvedAddresses: Set = [] + for chunk in Self.chunked(allAddresses) { + let rowDescriptor = FetchDescriptor( + predicate: #Predicate { chunk.contains($0.address) } + ) + if let rows = try? backgroundContext.fetch(rowDescriptor) { + for row in rows { existingRows[row.address] = row } + } else { + unresolvedAddresses.formUnion(chunk) + } + let txoDescriptor = FetchDescriptor( + predicate: #Predicate { chunk.contains($0.address) } + ) + for txo in (try? backgroundContext.fetch(txoDescriptor)) ?? [] { + txosByAddress[txo.address, default: []].append(txo) + } + } + for entry in entries { let address = entry.address - let existingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.address == address } - ) - let existing = try? backgroundContext.fetch(existingDescriptor).first + if existingRows[address] == nil, unresolvedAddresses.contains(address) { + let fallbackDescriptor = FetchDescriptor( + predicate: #Predicate { $0.address == address } + ) + if let row = try? backgroundContext.fetch(fallbackDescriptor).first { + existingRows[address] = row + } + } let row: PersistentCoreAddress - if let existing = existing { + if let existing = existingRows[address] { row = existing } else { row = PersistentCoreAddress( @@ -3594,6 +3981,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { balance: entry.balance ) backgroundContext.insert(row) + // Register so a repeated address later in `entries` + // updates this staged row instead of inserting a + // duplicate (the per-row fetch this replaced saw + // staged rows via pending changes). + existingRows[address] = row } // Mutation path for both insert + update. row.publicKey = entry.publicKey @@ -3613,16 +4005,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // the relationship and `record.coreAddress` stayed nil. // Without this sweep the storage-explorer's "Address // Row" field renders as "—" forever even though the - // address row now exists. Avoid the SwiftData - // optional-relationship-in-predicate gotcha by - // filtering nil-coreAddress in Swift after the fetch. - 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 - } + // address row now exists. Sourced from the bulk prefetch + // above; nil-coreAddress filtering stays in Swift (the + // optional-relationship-in-predicate gotcha). + for txo in txosByAddress[address] ?? [] where txo.coreAddress == nil { + txo.coreAddress = row } } @@ -7555,8 +7942,7 @@ private func persistWalletChangesetCallback( .takeUnretainedValue() let walletId = Data(bytes: walletIdPtr, count: 32) - handler.persistWalletChangeset(walletId: walletId, changeset: changesetPtr) - return 0 + return handler.persistWalletChangeset(walletId: walletId, changeset: changesetPtr) ? 0 : 1 } /// C shim for `on_changeset_begin_fn`. Forwards to diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index c24f81295da..9c68f5235d9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -26,40 +26,7 @@ import DashSDKFFI // serves every other read live. That isolation is the point: a load-path // regression cannot pass because the wallet or unspent-TXO fetch failed // first, and the reconcile regression cannot pass because the whole store -// was unreadable. - -/// Serves every read live except the one model type it is told to fault, -/// and records the reads it saw so a test can prove which fetch failed. -private final class FetchFaultInjector: ModelFetching, @unchecked Sendable { - struct ReadFault: Error {} - - private let live = LiveModelFetcher() - private let faulted: ObjectIdentifier - private let lock = NSLock() - private var reads: [String] = [] - - init(faulting model: any PersistentModel.Type) { - faulted = ObjectIdentifier(model) - } - - /// Model names in the order they were read, the faulted one included. - var observedReads: [String] { - lock.lock() - defer { lock.unlock() } - return reads - } - - func fetch( - _ descriptor: FetchDescriptor, - in context: ModelContext - ) throws -> [T] { - lock.lock() - reads.append(String(describing: T.self)) - lock.unlock() - guard ObjectIdentifier(T.self) != faulted else { throw ReadFault() } - return try live.fetch(descriptor, in: context) - } -} +// was unreadable. The seam double is the shared `FetchFaultInjector`. final class AssetLockSpendVisibilityTests: XCTestCase { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BulkFetchPredicateTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BulkFetchPredicateTests.swift new file mode 100644 index 00000000000..f1d067b60c4 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BulkFetchPredicateTests.swift @@ -0,0 +1,84 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +/// Proving ground for the bulk `IN`-style fetches the wallet-changeset +/// round cache relies on (`PlatformWalletPersistenceHandler`'s +/// prefetch pass). +/// +/// SwiftData translates `[Data].contains($0.column)` into a SQL +/// `IN (?, ?, …)` — but nothing else in this package exercised that +/// form before the round cache, and the sibling `Set.contains` form +/// famously does NOT translate (it throws at predicate-compile time). +/// These tests pin the exact contract the cache builder depends on: +/// +/// 1. an `[Data]`-captured `contains` predicate round-trips BLOB keys +/// through the store, in chunks below SQLite's bind-variable limit; +/// 2. rows staged (unsaved) in the same context remain visible to the +/// bulk fetch (`includePendingChanges` default), which is what lets +/// the prefetch see rows earlier per-kind callbacks inserted in the +/// same begin/end changeset round. +@MainActor +final class BulkFetchPredicateTests: XCTestCase { + + func testChunkedDataContainsPredicateFetchesAllSavedRows() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + + // More rows than one SQLite bind chunk (900) so the chunked + // fetch path is genuinely exercised. + let total = 2_000 + var outpoints: [Data] = [] + outpoints.reserveCapacity(total) + for i in 0..( + predicate: #Predicate { chunk.contains($0.outpoint) } + ) + for row in try context.fetch(descriptor) { + fetched[row.outpoint] = row + } + } + + XCTAssertEqual(fetched.count, total) + for outpoint in outpoints { + XCTAssertNotNil(fetched[outpoint]) + } + // Spot-check a payload survived the BLOB round trip. + XCTAssertEqual(fetched[outpoints[1234]]?.amount, 1234) + } + + func testDataContainsPredicateSeesUnsavedPendingRows() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + + // One durably saved row, one staged-only row — the bulk fetch + // must see both, exactly like a mid-round prefetch that runs + // after earlier callbacks staged inserts without saving. + let savedTx = PersistentTransaction(txid: makeTxid(1), transactionData: Data()) + context.insert(savedTx) + try context.save() + + let pendingTx = PersistentTransaction(txid: makeTxid(2), transactionData: Data()) + context.insert(pendingTx) + + let txids = [makeTxid(1), makeTxid(2), makeTxid(3)] + let descriptor = FetchDescriptor( + predicate: #Predicate { txids.contains($0.txid) } + ) + let rows = try context.fetch(descriptor) + + XCTAssertEqual(Set(rows.map(\.txid)), [makeTxid(1), makeTxid(2)]) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift index 5b509c904e1..e09aba80007 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift @@ -450,8 +450,8 @@ final class DashPayContactPersistenceTests: XCTestCase { let labelPtr = labelRaw.bindMemory(to: UInt8.self).baseAddress var outgoing = ContactRequestFFI() - outgoing.owner_id = Self.tuple32(ownerId) - outgoing.contact_id = Self.tuple32(contactId) + outgoing.owner_id = tuple32(ownerId) + outgoing.contact_id = tuple32(contactId) outgoing.is_outgoing = true outgoing.sender_key_index = 5 outgoing.recipient_key_index = 6 @@ -534,8 +534,8 @@ final class DashPayContactPersistenceTests: XCTestCase { } _ = beginFn(callbacks.context, wid) var ignore = ContactIgnoredSenderFFI() - ignore.owner_id = Self.tuple32(ownerId) - ignore.sender_id = Self.tuple32(contactId) + ignore.owner_id = tuple32(ownerId) + ignore.sender_id = tuple32(contactId) ignore.is_ignored = true withUnsafePointer(to: &ignore) { ignPtr in let rc = contactsFn( @@ -700,17 +700,6 @@ final class DashPayContactPersistenceTests: XCTestCase { XCTAssertEqual(try fetchContactRows().count, 0) } - /// Copy a 32-byte `Data` into the C fixed-array tuple shape the - /// FFI structs use for ids. - private static func tuple32(_ data: Data) -> FFIByteTuple32 { - precondition(data.count == 32) - var tuple: FFIByteTuple32 = ( - 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 - ) - withUnsafeMutableBytes(of: &tuple) { $0.copyBytes(from: data) } - return tuple - } } // MARK: - DashPay payment-history persistence @@ -1080,16 +1069,6 @@ final class DashPayPaymentFFIMarshallingTests: XCTestCase { private let counterpartyId = Data((0..<32).map { UInt8($0 + 1) }) - private static func tuple32(_ data: Data) -> FFIByteTuple32 { - precondition(data.count == 32) - var tuple: FFIByteTuple32 = ( - 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 - ) - withUnsafeMutableBytes(of: &tuple) { $0.copyBytes(from: data) } - return tuple - } - func testInitFromFFICopiesAllFields() throws { let txidCString = strdup("ab12cd34") let memoCString = strdup("coffee ☕") @@ -1099,7 +1078,7 @@ final class DashPayPaymentFFIMarshallingTests: XCTestCase { } var ffi = DashpayPaymentFFI() - ffi.counterparty_id = Self.tuple32(counterpartyId) + ffi.counterparty_id = tuple32(counterpartyId) ffi.amount_duffs = 123_456_789 ffi.direction = DashPayPaymentDirection.received.rawValue ffi.status = DashPayPaymentStatus.confirmed.rawValue @@ -1122,7 +1101,7 @@ final class DashPayPaymentFFIMarshallingTests: XCTestCase { defer { free(txidCString) } var ffi = DashpayPaymentFFI() - ffi.counterparty_id = Self.tuple32(counterpartyId) + ffi.counterparty_id = tuple32(counterpartyId) ffi.amount_duffs = 1 ffi.direction = DashPayPaymentDirection.sent.rawValue ffi.status = DashPayPaymentStatus.pending.rawValue @@ -1141,7 +1120,7 @@ final class DashPayPaymentFFIMarshallingTests: XCTestCase { /// trapping. func testUnknownDiscriminantsAndNullTxidDegradeGracefully() throws { var ffi = DashpayPaymentFFI() - ffi.counterparty_id = Self.tuple32(counterpartyId) + ffi.counterparty_id = tuple32(counterpartyId) ffi.amount_duffs = 42 ffi.direction = 99 ffi.status = 99 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/FFIFixtures.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/FFIFixtures.swift new file mode 100644 index 00000000000..f6c97a34d2c --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/FFIFixtures.swift @@ -0,0 +1,29 @@ +import Foundation +@testable import SwiftDashSDK + +// Shared fixtures for suites that hand-build the C structs the +// persistence handler consumes. Both conversions below were previously +// re-declared privately in every such suite; they are pure value +// transforms with no test-local state, so one copy serves all of them. + +/// Copy a 32-byte `Data` into the C fixed-array tuple shape the FFI +/// structs use for txids, wallet ids, and identity ids. +func tuple32(_ data: Data) -> FFIByteTuple32 { + precondition(data.count == 32) + var tuple: FFIByteTuple32 = ( + 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 + ) + withUnsafeMutableBytes(of: &tuple) { $0.copyBytes(from: data) } + return tuple +} + +/// Deterministic 32-byte txid for index `i`: the little-endian `UInt64` +/// in the leading bytes keeps ids readable in failure output and lets a +/// test recover `i` back out of a stored key (see the outpoint decode in +/// `WalletChangesetRoundTests`). +func makeTxid(_ i: Int) -> Data { + var txid = Data(count: 32) + withUnsafeBytes(of: UInt64(i).littleEndian) { txid.replaceSubrange(0..<8, with: $0) } + return txid +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/FetchFaultInjector.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/FetchFaultInjector.swift new file mode 100644 index 00000000000..9b8f2fd3a4e --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/FetchFaultInjector.swift @@ -0,0 +1,38 @@ +import Foundation +import SwiftData +@testable import SwiftDashSDK + +/// `ModelFetching` seam double: serves every read live except the one +/// model type it is told to fault (none by default), and records the +/// reads it saw so a test can prove which fetch failed — or, with no +/// fault, count how many reads a code path issued. +final class FetchFaultInjector: ModelFetching, @unchecked Sendable { + struct ReadFault: Error {} + + private let live = LiveModelFetcher() + private let faulted: ObjectIdentifier? + private let lock = NSLock() + private var reads: [String] = [] + + init(faulting model: (any PersistentModel.Type)? = nil) { + faulted = model.map { ObjectIdentifier($0) } + } + + /// Model names in the order they were read, the faulted one included. + var observedReads: [String] { + lock.lock() + defer { lock.unlock() } + return reads + } + + func fetch( + _ descriptor: FetchDescriptor, + in context: ModelContext + ) throws -> [T] { + lock.lock() + reads.append(String(describing: T.self)) + lock.unlock() + guard ObjectIdentifier(T.self) != faulted else { throw ReadFault() } + return try live.fetch(descriptor, in: context) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletChangesetRoundTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletChangesetRoundTests.swift new file mode 100644 index 00000000000..fc2d9e6c254 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletChangesetRoundTests.swift @@ -0,0 +1,280 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +/// Coverage for the wallet-changeset apply path after the per-round +/// bulk-prefetch cache (`WalletChangesetRoundCache`) replaced the +/// per-row `ModelContext.fetch` storm: +/// +/// * spend linkage stays order-independent (spending tx before funding +/// TXO within one round resolves through the pending-input table); +/// * inputs with unknown funding keep the unconditional pending row — +/// the out-of-order spend-repair mechanism the cache must not regress; +/// * a round issues O(chunks) fetches, not O(rows) (the quadratic +/// per-row-fetch regression guard); +/// * a thrown single-row fallback fetch rejects the round instead of +/// reading as "row absent" and licensing a duplicate insert. +@MainActor +final class WalletChangesetRoundTests: XCTestCase { + + private let walletId = Data(repeating: 0x0A, count: 32) + + /// Lightweight description of one transaction record for the + /// FFI-struct builder below. + private struct TestTx { + var txid: Data + /// 0=incoming … 3=coinJoin (`TransactionRecordFFI.direction`). + var direction: UInt32 = 0 + var inputs: [(txid: Data, vout: UInt32)] = [] + /// vouts to emit as `utxos_added` entries for this tx. + var outputs: [UInt32] = [] + } + + private func makeHandler( + modelFetcher: ModelFetching = LiveModelFetcher() + ) throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet, + modelFetcher: modelFetcher + ) + // The changeset path drops writes for unknown wallets — seed + // the row the way the wallet-metadata callback would have. + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + return (handler, container) + } + + /// `txs` where tx_i spends tx_{i-1}'s only output: the same-round + /// chain that exercises every pending-input path. + private func spendChain(count: Int) -> [TestTx] { + (0.. 0 { tx.inputs = [(makeTxid(i - 1), 0)] } + return tx + } + } + + /// Build the C changeset for `txs`, run one begin→persist→end + /// round through `handler`, and free every allocation. Returns + /// what `endChangeset` reported; `expectPersisted` pins what the + /// changeset callback itself must have reported. + private func runRound( + handler: PlatformWalletPersistenceHandler, + txs: [TestTx], + expectPersisted: Bool = true + ) -> Bool { + var cStrings: [UnsafeMutablePointer] = [] + var inputBuffers: [(UnsafeMutablePointer, Int)] = [] + defer { + for ptr in cStrings { free(ptr) } + for (ptr, count) in inputBuffers { + ptr.deinitialize(count: count) + ptr.deallocate() + } + } + + let txBuffer = UnsafeMutablePointer.allocate(capacity: txs.count) + let totalOutputs = txs.reduce(0) { $0 + $1.outputs.count } + let utxoBuffer = UnsafeMutablePointer.allocate(capacity: max(totalOutputs, 1)) + defer { + txBuffer.deinitialize(count: txs.count) + txBuffer.deallocate() + utxoBuffer.deinitialize(count: totalOutputs) + utxoBuffer.deallocate() + } + + var utxoCount = 0 + for (i, tx) in txs.enumerated() { + var record = TransactionRecordFFI() + record.txid = tuple32(tx.txid) + record.tx_data = nil + record.tx_data_len = 0 + record.context = 2 // inBlock — spends may flip `isSpent` + record.block_height = 1_000 + UInt32(i) + record.direction = tx.direction + let typeName = strdup("Standard")! + cStrings.append(typeName) + record.transaction_type = typeName + record.transaction_type_kind = tx.direction == 3 ? 1 : 0 + record.net_amount = 1_000 + record.first_seen = 1_700_000_000 + if tx.inputs.isEmpty { + record.input_outpoints = nil + record.input_outpoints_count = 0 + } else { + let inputs = UnsafeMutablePointer.allocate(capacity: tx.inputs.count) + for (j, input) in tx.inputs.enumerated() { + var op = OutPointFFI() + op.txid = tuple32(input.txid) + op.vout = input.vout + inputs[j] = op + } + inputBuffers.append((inputs, tx.inputs.count)) + record.input_outpoints = inputs + record.input_outpoints_count = UInt(tx.inputs.count) + } + txBuffer[i] = record + + for vout in tx.outputs { + var utxo = UtxoEntryFFI() + utxo.outpoint = OutPointFFI() + utxo.outpoint.txid = tuple32(tx.txid) + utxo.outpoint.vout = vout + utxo.amount = 5_000 + let address = strdup("addr-\(i)-\(vout)")! + cStrings.append(address) + utxo.address = address + utxo.script_pubkey = nil + utxo.script_pubkey_len = 0 + utxo.height = 1_000 + UInt32(i) + utxo.is_confirmed = true + utxoBuffer[utxoCount] = utxo + utxoCount += 1 + } + } + + var account = AccountChangeSetFFI() + let accountName = strdup("Standard")! + cStrings.append(accountName) + account.account_type_name = accountName + account.account_index = 0 + account.transactions = txBuffer + account.transactions_count = UInt(txs.count) + account.utxos_added = utxoCount > 0 ? utxoBuffer : nil + account.utxos_added_count = UInt(utxoCount) + + return withUnsafeMutablePointer(to: &account) { accountPtr in + var changeset = WalletChangeSetFFI() + changeset.accounts = accountPtr + changeset.accounts_count = 1 + handler.beginChangeset(walletId: walletId) + let persisted = withUnsafePointer(to: changeset) { + handler.persistWalletChangeset(walletId: walletId, changeset: $0) + } + XCTAssertEqual(persisted, expectPersisted, "changeset callback result") + // What Rust does with the callback's code: close the round + // as failed when any per-kind callback reported failure. + return handler.endChangeset(walletId: walletId, success: persisted) + } + } + + private func fetchAll( + _ type: T.Type, + in container: ModelContainer + ) throws -> [T] { + try ModelContext(container).fetch(FetchDescriptor()) + } + + // MARK: - Correctness + + /// A same-round chain of spends (tx_i spends tx_{i-1}'s output, + /// records applied before any UTXO) must resolve every linkage + /// through the pending-input table and leave no pending rows. + func testSameRoundSpendChainResolvesAndDrainsPendingRows() throws { + let (handler, container) = try makeHandler() + let count = 50 + XCTAssertTrue(runRound(handler: handler, txs: spendChain(count: count))) + + let transactions = try fetchAll(PersistentTransaction.self, in: container) + XCTAssertEqual(transactions.count, count) + + let txos = try fetchAll(PersistentTxo.self, in: container) + XCTAssertEqual(txos.count, count) + for txo in txos { + let fundingIndex = txo.outpoint.withUnsafeBytes { $0.loadUnaligned(as: UInt64.self) } + if fundingIndex < UInt64(count - 1) { + XCTAssertTrue(txo.isSpent, "TXO of tx \(fundingIndex) should be spent") + XCTAssertEqual( + txo.spendingTransaction?.txid, + makeTxid(Int(fundingIndex) + 1), + "TXO of tx \(fundingIndex) should be linked to its spender" + ) + } else { + XCTAssertFalse(txo.isSpent, "tip TXO should stay unspent") + } + } + + let pending = try fetchAll(PersistentPendingInput.self, in: container) + XCTAssertTrue(pending.isEmpty, "all pending rows should have drained, got \(pending.count)") + } + + /// A transaction spending an outpoint whose funding tx is unknown + /// must still write the pending-input row — that row is the + /// out-of-order spend-repair mechanism (gap-limit discovery, + /// mid-sync restart), and the cache-backed dup-check must not + /// swallow it. + func testUnknownFundingInputWritesPendingRow() throws { + let (handler, container) = try makeHandler() + let unknownFunding = makeTxid(500) + XCTAssertTrue(runRound(handler: handler, txs: [ + TestTx(txid: makeTxid(1), inputs: [(unknownFunding, 2)]), + ])) + + let pending = try fetchAll(PersistentPendingInput.self, in: container) + XCTAssertEqual(pending.count, 1) + XCTAssertEqual( + pending.first?.outpoint, + PersistentTxo.makeOutpoint(txid: unknownFunding, vout: 2) + ) + XCTAssertEqual(pending.first?.spendingTxid, makeTxid(1)) + } + + // MARK: - Fetch failure + + /// A thrown single-row fallback fetch must reject the round, not + /// read as "row absent": the callers take `nil` as license to + /// insert over a `.unique` column, and that duplicate would only + /// surface as a failed `save()` at `endChangeset`. Faulting every + /// `PersistentTransaction` read fails the bulk prefetch (which + /// demotes the chunk to per-row fetches) and then the first + /// fallback, so this pins both halves of the contract. + func testThrownFallbackFetchRejectsTheRound() throws { + let injector = FetchFaultInjector(faulting: PersistentTransaction.self) + let (handler, container) = try makeHandler(modelFetcher: injector) + + XCTAssertFalse( + runRound(handler: handler, txs: spendChain(count: 3), expectPersisted: false), + "an unreadable transaction table must fail the round" + ) + XCTAssertTrue( + injector.observedReads.contains("PersistentTransaction"), + "the faulted read must be the transaction fetch" + ) + XCTAssertTrue( + try fetchAll(PersistentTransaction.self, in: container).isEmpty, + "nothing from the rejected round may reach the store" + ) + XCTAssertTrue(try fetchAll(PersistentTxo.self, in: container).isEmpty) + } + + // MARK: - Scaling + + /// A round must issue O(chunks) fetches, not O(rows): the per-row + /// implementation re-scanned every staged object on each fetch, so + /// round cost grew quadratically. Counting reads through the + /// `ModelFetching` seam pins that deterministically — a reintroduced + /// per-row fetch (through the seam) scales the count with the + /// record count. The fixture starts from an empty store so no key + /// misses the prefetch; a pre-seeded store could add legitimate + /// fallback reads (an existing TXO whose stored address differs + /// from the emitted one). + func testRoundFetchCountIsIndependentOfRecordCount() throws { + func fetchCount(records: Int) throws -> Int { + let injector = FetchFaultInjector() + let (handler, _) = try makeHandler(modelFetcher: injector) + XCTAssertTrue(runRound(handler: handler, txs: spendChain(count: records))) + return injector.observedReads.count + } + // Per round: the wallet row, the account row, then one bulk + // fetch per entity (transactions, TXOs, pending inputs, core + // addresses) per 900-key chunk — `chunked(_:size:)`'s default. + // Every entity's key set in a spend chain has `records` members. + func expected(records: Int) -> Int { 2 + 4 * ((records + 899) / 900) } + + XCTAssertEqual(try fetchCount(records: 100), expected(records: 100)) + XCTAssertEqual(try fetchCount(records: 2_000), expected(records: 2_000)) + } +}