From 15dd0eb9998a893a7b8cd9bfe6098288f2b23ff2 Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Thu, 9 Jul 2026 03:19:04 -0700 Subject: [PATCH] fix(swift-sdk): detect later added wallet tx to a synced spv client --- .../src/manager/wallet_lifecycle.rs | 28 +++++++ ...pvLateWalletBackfillIntegrationTests.swift | 84 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvLateWalletBackfillIntegrationTests.swift diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index e8a38200715..4b9543049cf 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -426,6 +426,34 @@ impl PlatformWalletManager

{ wallets.insert(wallet_id, Arc::clone(&platform_wallet)); } + // Re-seed the lock-free balance atomic from the wallet's inner + // balance now that the wallet is in `self.wallets`. + // + // A wallet added while SPV is already synced (e.g. importing an + // existing mnemonic with `birth_height = 0`) has its historical + // funds backfilled by the SPV rescan that `insert_wallet` above + // triggers. That rescan can complete — emitting the + // `BlockProcessed` event that carries the post-backfill balance — + // *before* this wallet lands in `self.wallets`, so + // `BalanceUpdateHandler` drops those events (the wallet isn't in + // the map yet) and the atomic stays at zero even though the inner + // `ManagedWalletInfo` balance is correct. Mirror the inner balance + // into the atomic here (as `manager::load` does for restored + // wallets); any later block events are applied normally now that + // the wallet is mapped. + { + let wm = self.wallet_manager.read().await; + if let Some(info) = wm.get_wallet_info(&wallet_id) { + let b = &info.core_wallet.balance; + platform_wallet.balance().set( + b.confirmed(), + b.unconfirmed(), + b.immature(), + b.locked(), + ); + } + } + // Best-effort identity discovery. For a recovery flow (existing // mnemonic re-typed by the user) this hydrates every identity // the wallet had on Platform without the caller having to fire diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvLateWalletBackfillIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvLateWalletBackfillIntegrationTests.swift new file mode 100644 index 00000000000..b3f33640b68 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvLateWalletBackfillIntegrationTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import SwiftDashSDK + +/// A wallet imported into an ALREADY-RUNNING, already-synced SPV client must +/// backfill its historical transactions. +/// +/// The realistic flow this exercises: the client is started empty and synced to +/// the chain tip, then the user imports an existing, already-funded mnemonic +/// with `birthHeight: 0` — per the `createWallet` contract that means "scan from +/// genesis and see funds received before this device knew the wallet". +/// +/// Regression: the SPV rescan that fires when the wallet is added backfills its +/// historical UTXOs, and `register_wallet` re-seeds the lock-free balance atomic +/// from the wallet's inner balance so those backfilled funds are visible even +/// though the `BlockProcessed` events can land before the wallet enters the +/// manager's `wallets` map. Before the fix the imported wallet's balance stayed 0. +final class SpvLateWalletBackfillIntegrationTests: IntegrationTestCase { + private let fundingDash: Double = 0.5 + private var fundingDuffs: UInt64 { UInt64(fundingDash * 1e8) } + + func testWalletAddedToRunningClientBackfillsHistory() async throws { + let sdk = env.sdk + + // 1. Derive the wallet's receive address WITHOUT registering it in the + // SPV-driving manager, so the client can start genuinely empty. A + // throwaway manager (which never starts SPV, on its own in-memory + // store) just hands us the mnemonic + funding address. + let mnemonic = try Mnemonic.generate(wordCount: 24) + let fundingAddress = try await MainActor.run { () -> String in + let throwaway = try PlatformWalletManager( + sdk: sdk, + modelContainer: try DashModelContainer.createInMemory() + ) + let w = try throwaway.createWallet( + mnemonic: mnemonic, + network: .regtest, + createDefaultAccounts: true + ) + return try w.coreWallet().nextReceiveAddress() + } + + // 2. Fund the address on-chain and confirm it (`fund` mines 1 block). + _ = try await env.fund(address: fundingAddress, dash: fundingDash) + + // 3. Start the SPV client EMPTY (env.walletManager holds no wallets yet) + // and sync past the funding block. The client matches nothing — there + // is no wallet to match against. + try await env.walletManager.startSpv(config: env.spvConfig) + try await env.walletManager.waitUntilUpToDate( + height: try await env.coreRPC.getBlockCount(), + timeout: 60 + ) + + // 4. Import the funded wallet into the RUNNING client, birthHeight 0 so + // it is contractually expected to scan history from genesis. + let imported = try await env.walletManager.createWallet( + mnemonic: mnemonic, + network: .regtest, + name: "late-import", + createDefaultAccounts: true, + birthHeight: 0 + ) + + // 5. Give the running client generous time to backfill the new wallet's + // history. Don't fail on timeout here — let step 6 report the clean + // balance assertion. + let expected = fundingDuffs + _ = try? await Wait.until( + "imported wallet backfills its historical funding (\(expected) duffs)", + timeout: 60, + pollInterval: 0.25 + ) { + try imported.balance().spendable == expected + } + + // 6. Verify all funds were found. + let bal = try imported.balance() + XCTAssertEqual( + bal.spendable, + expected, + "backfill balance breakdown → spendable=\(bal.spendable) unconfirmed=\(bal.unconfirmed) immature=\(bal.immature) locked=\(bal.locked) total=\(bal.total)" + ) + } +}