diff --git a/packages/swift-sdk/Package.swift b/packages/swift-sdk/Package.swift index 03fbc1a3306..aad573f2a4a 100644 --- a/packages/swift-sdk/Package.swift +++ b/packages/swift-sdk/Package.swift @@ -28,12 +28,20 @@ let package = Package( linkerSettings: [.linkedFramework("SystemConfiguration")] ), - // Tests + // Unit tests (offline, hermetic) .testTarget( name: "SwiftDashSDKTests", dependencies: ["SwiftDashSDK"], path: "SwiftTests/SwiftDashSDKTests" - ) + ), + + // Integration tests against a local dashmate devnet. + // Gated by env var `RUN_INTEGRATION_TESTS=1` + .testTarget( + name: "SwiftDashSDKIntegrationTests", + dependencies: ["SwiftDashSDK"], + path: "SwiftTests/SwiftDashSDKIntegrationTests" + ), ], swiftLanguageModes: [.v6] ) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift index d8a5751fb06..a13305cfbff 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift @@ -707,12 +707,20 @@ extension KeychainManager { /// and this type's state is `let` — safe to call from the FFI /// trampoline on any Tokio worker thread. public nonisolated func retrieveIdentityPrivateKey(publicKeyHex: String) -> Data? { + // Two-step lookup. macOS's legacy file keychain returns NOTHING + // for a `kSecMatchLimitAll` query that also asks for + // `kSecReturnData` on generic passwords (iOS returns the rows + // fine). So scan ATTRIBUTES ONLY to resolve the matching item's + // account name, then fetch its bytes with a single-item + // `kSecMatchLimitOne` query (the path `retrieveKeyData` uses, + // which works on both platforms). Keeping the secret out of the + // bulk scan also avoids materializing every identity key's bytes + // just to find one. var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecMatchLimit as String: kSecMatchLimitAll, kSecReturnAttributes as String: true, - kSecReturnData as String: true, ] if let accessGroup = accessGroup { query[kSecAttrAccessGroup as String] = accessGroup @@ -739,7 +747,7 @@ extension KeychainManager { // Case-insensitive hex compare — both producers downcase // their hex but be defensive against future writers. if metadata.publicKey.caseInsensitiveCompare(publicKeyHex) == .orderedSame { - return item[kSecValueData as String] as? Data + return retrieveKeyData(identifier: account) } } return nil diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/CoreSendIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/CoreSendIntegrationTests.swift new file mode 100644 index 00000000000..58b2b96affd --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/CoreSendIntegrationTests.swift @@ -0,0 +1,84 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +final class CoreSendIntegrationTests: IntegrationTestCase { + private let fundingDash: Double = 0.5 + private var fundingDuffs: UInt64 { + UInt64(fundingDash * 1e8) + } + + func testWalletToWalletViaSpv() async throws { + try await env.ensureSPVStarted() + let alice = try await env.makeTestWallet(name: "core-send-alice") + let bob = try await env.makeTestWallet(name: "core-send-bob") + + let aliceAddress = try alice.getCoreWallet().nextReceiveAddress() + _ = try await env.fund(address: aliceAddress, dash: fundingDash) + let bobAddress = try bob.getCoreWallet().nextReceiveAddress() + _ = try await env.fund(address: bobAddress, dash: fundingDash) + try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90) + try await bob.waitForSpendable(exactly: fundingDuffs, timeout: 90) + + let iterations = 5 + let amount: UInt64 = 100_000 // 0.001 DASH per hop + + for i in 0 ..< iterations { + let aliceSends = (i % 2 == 0) + let sender = aliceSends ? alice: bob + let receiver = aliceSends ? bob: alice + + let receiverBalanceBefore = try receiver.getPlatformWallet().balance().spendable + let recipientAddress = try receiver.getCoreWallet().nextReceiveAddress() + + let beforeTxids = try await readTxids() + _ = try sender.getCoreWallet().sendToAddresses( + recipients: [(address: recipientAddress, amountDuffs: amount)] + ) + guard let sendTxid = try await waitForNewTxid(notIn: beforeTxids) else { + XCTFail("send PersistentTransaction row never appeared on iteration \(i)") + return + } + _ = try await env.mine(1, including: sendTxid) + + try await Wait.until( + "receiver +\(amount) after iteration \(i)", + timeout: 60, + pollInterval: 0.01 + ) { + try receiver.getPlatformWallet().balance().spendable + == receiverBalanceBefore + amount + } + } + + let aliceFinal = try alice.getPlatformWallet().balance().spendable + let bobFinal = try bob.getPlatformWallet().balance().spendable + XCTAssertLessThanOrEqual(aliceFinal + bobFinal, 2 * fundingDuffs) + + // Validate via the SwiftData + let expectedTotalTxs = 2 + iterations + let aliceWalletId = alice.getPlatformWallet().walletId + let bobWalletId = bob.getPlatformWallet().walletId + let container = env.modelContainer + + try await MainActor.run { + let context = ModelContext(container) + let allTxCount = try context.fetchCount(FetchDescriptor()) + XCTAssertEqual(allTxCount, expectedTotalTxs) + + let aliceTxoCount = try context.fetchCount(FetchDescriptor( + predicate: #Predicate{ + $0.walletId == aliceWalletId + } + )) + let bobTxoCount = try context.fetchCount(FetchDescriptor( + predicate: #Predicate{ + $0.walletId == bobWalletId + } + )) + + XCTAssertEqual(aliceTxoCount, 1 + iterations) + XCTAssertEqual(bobTxoCount, 1 + iterations) + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/PersisterRestartClassificationIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/PersisterRestartClassificationIntegrationTests.swift new file mode 100644 index 00000000000..7c7754dc821 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/PersisterRestartClassificationIntegrationTests.swift @@ -0,0 +1,189 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +/// Regression guard for the "self-send flips to phantom-incoming after +/// a mid-flight restart" bug. The mempool sighting taints +/// `PersistentTxo.isSpent` for the input; the persister's load +/// callback then filters that row out of the restored UTXO set, so +/// the catch-up classifier on the next launch sees a missing input, +/// emits `direction=Incoming`, and rewrites `netAmount` to the sum of +/// the wallet's own outputs in the tx. +final class PersisterRestartClassificationIntegrationTests: IntegrationTestCase { + private let fundingDash: Double = 0.5 + private var fundingDuffs: UInt64 { UInt64(fundingDash * 1e8) } + private let sendAmount: UInt64 = 10_000 + + func testSelfSendClassificationSurvivesMidFlightRestart() async throws { + try await env.ensureSPVStarted() + let alice = try await env.makeTestWallet(name: "restart-class-alice") + + let aliceFundingAddr = try alice.getCoreWallet().nextReceiveAddress() + _ = try await env.fund(address: aliceFundingAddr, dash: fundingDash) + try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90) + + let beforeTxids = try await readTxids() + + let aliceSecondAddr = try alice.getCoreWallet().nextReceiveAddress() + _ = try alice.getCoreWallet().sendToAddresses( + recipients: [(address: aliceSecondAddr, amountDuffs: sendAmount)] + ) + + guard let sendTxid = try await waitForNewTxid(notIn: beforeTxids) else { + XCTFail("self-send PersistentTransaction row never appeared within 60s") + return + } + + // Control: mempool sighting must already be Internal/-fee + // (Rust still holds the input in memory at this point). + try await assertSelfSendRow( + txid: sendTxid, + phase: "mempool sighting" + ) + + try await env.restartWalletManager() + _ = try await env.mine(1, including: sendTxid) + try await env.ensureSPVStarted() + try await env.waitForSPVUpToDate() + + try await assertTxIsMined( + txid: sendTxid, + phase: "post-restart catch-up" + ) + + // Regression: classification must survive the restart. Pre-fix + // the row flips to Incoming / +sum_of_outputs. + try await assertSelfSendRow( + txid: sendTxid, + phase: "post-restart catch-up" + ) + } + + /// Same regression as the test above, but the self-send tx never + /// gets a confirming block — it stays in the mempool across the + /// restart. Exercises the catch-up classifier on the mempool-only + /// path: after the SPV reconnects, the masternodes replay the + /// wallet's own tx via INV/mempool and the classifier reprocesses + /// it without any new block to anchor it. + func testSelfSendClassificationSurvivesMempoolOnlyRestart() async throws { + try await env.ensureSPVStarted() + let alice = try await env.makeTestWallet(name: "restart-class-alice-no-mine") + + let aliceFundingAddr = try alice.getCoreWallet().nextReceiveAddress() + _ = try await env.fund(address: aliceFundingAddr, dash: fundingDash) + try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90) + + let beforeTxids = try await readTxids() + + let aliceSecondAddr = try alice.getCoreWallet().nextReceiveAddress() + _ = try alice.getCoreWallet().sendToAddresses( + recipients: [(address: aliceSecondAddr, amountDuffs: sendAmount)] + ) + + guard let sendTxid = try await waitForNewTxid(notIn: beforeTxids) else { + XCTFail("self-send PersistentTransaction row never appeared within 60s") + return + } + + try await assertSelfSendRow( + txid: sendTxid, + phase: "mempool sighting" + ) + + try await env.restartWalletManager() + try await env.ensureSPVStarted() + + try await assertTxIsInMempool( + txid: sendTxid, + phase: "post-restart mempool-only catch-up" + ) + + try await assertSelfSendRow( + txid: sendTxid, + phase: "post-restart mempool-only catch-up" + ) + } + + // MARK: - Helpers + + /// Sendable snapshot of the columns the test cares about. + /// `PersistentTransaction` itself can't cross the `MainActor.run` + /// boundary because `@Model` types are not Sendable. + private struct TxSnapshot: Sendable { + let direction: UInt32 + let netAmount: Int64 + let context: UInt32 + } + + private func fetchTransaction(_ txid: Data) async throws -> TxSnapshot? { + let container = env.modelContainer + return try await MainActor.run { + let ctx = ModelContext(container) + guard let row = try ctx.fetch(FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + )).first else { return nil } + return TxSnapshot( + direction: row.direction, + netAmount: row.netAmount, + context: row.context + ) + } + } + + /// Asserts the row is in a mined state: inBlock (2) + private func assertTxIsMined(txid: Data, phase: String) async throws { + guard let row = try await fetchTransaction(txid) else { + XCTFail("\(phase): tx row missing for \(txid.toHexString()) (expected mined)") + return + } + + let inBlock = TransactionContextType.inBlock.rawValue + XCTAssertTrue( + row.context == inBlock, + "\(phase): context=\(row.context) — expected inBlock(\(inBlock))" + ) + } + + /// Asserts the row is in a mempool-equivalent state: mempool (0) + /// or instantSend (1) — i.e., observed but not yet in a block. + /// Used after the variant that restarts without mining. + private func assertTxIsInMempool(txid: Data, phase: String) async throws { + guard let row = try await fetchTransaction(txid) else { + XCTFail("\(phase): tx row missing for \(txid.toHexString()) (expected mempool)") + return + } + + let mempool = TransactionContextType.mempool.rawValue + let instantSend = TransactionContextType.instantSend.rawValue + + XCTAssertTrue( + row.context == mempool || row.context == instantSend, + "\(phase): context=\(row.context) — expected mempool(\(mempool)) or instantSend(\(instantSend))" + ) + } + + private func assertSelfSendRow(txid: Data, phase: String) async throws { + guard let row = try await fetchTransaction(txid) else { + XCTFail("\(phase): row missing for txid \(txid.toHexString())") + return + } + + XCTAssertEqual( + row.direction, 2, + "\(phase): direction=\(row.direction) (expected 2=Internal). " + + "context=\(row.context), netAmount=\(row.netAmount). " + + "Positive netAmount with direction=0 is the phantom-incoming signature." + ) + + XCTAssertLessThan( + row.netAmount, 0, + "\(phase): netAmount=\(row.netAmount) (expected <0; self-send only leaves the fee)." + ) + + XCTAssertLessThan( + abs(row.netAmount), 100_000, + "\(phase): fee too large: |netAmount|=\(abs(row.netAmount))" + ) + } +} + diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvRestartIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvRestartIntegrationTests.swift new file mode 100644 index 00000000000..5df6d83764c --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvRestartIntegrationTests.swift @@ -0,0 +1,75 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +/// Verifies that the persisted wallet state survives an SPV stop/start +/// cycle +final class SpvRestartIntegrationTests: IntegrationTestCase { + private let fundingDash: Double = 0.5 + private var fundingDuffs: UInt64 { UInt64(fundingDash * 1e8) } + private let amount: UInt64 = 100_000 + + func testTxosSurviveSpvRestart() async throws { + try await env.ensureSPVStarted() + let alice = try await env.makeTestWallet(name: "spv-restart-alice") + let bob = try await env.makeTestWallet(name: "spv-restart-bob") + + let aliceAddress = try alice.getCoreWallet().nextReceiveAddress() + _ = try await env.fund(address: aliceAddress, dash: fundingDash) + + let bobAddress = try bob.getCoreWallet().nextReceiveAddress() + _ = try await env.fund(address: bobAddress, dash: fundingDash) + + try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90) + try await bob.waitForSpendable(exactly: fundingDuffs, timeout: 90) + + try await sendAndConfirm(from: alice, to: bob) + try await env.restartSPV() + try await sendAndConfirm(from: alice, to: bob) + + let aliceWalletId = alice.getPlatformWallet().walletId + let bobWalletId = bob.getPlatformWallet().walletId + let container = env.modelContainer + + try await MainActor.run { + let context = ModelContext(container) + + let aliceTxoCount = try context.fetchCount(FetchDescriptor( + predicate: #Predicate { $0.walletId == aliceWalletId } + )) + let bobTxoCount = try context.fetchCount(FetchDescriptor( + predicate: #Predicate { $0.walletId == bobWalletId } + )) + + XCTAssertEqual(aliceTxoCount, 3) + XCTAssertEqual(bobTxoCount, 3) + } + } + + private func sendAndConfirm( + from sender: TestWalletWrapper, + to receiver: TestWalletWrapper + ) async throws { + let receiverBalanceBefore = try receiver.getPlatformWallet().balance().spendable + let recipientAddress = try receiver.getCoreWallet().nextReceiveAddress() + + let beforeTxids = try await readTxids() + _ = try sender.getCoreWallet().sendToAddresses( + recipients: [(address: recipientAddress, amountDuffs: amount)] + ) + guard let sendTxid = try await waitForNewTxid(notIn: beforeTxids) else { + XCTFail("send PersistentTransaction row never appeared") + return + } + _ = try await env.mine(1, including: sendTxid) + + try await Wait.until( + "receiver balance advances by \(amount)", + timeout: 60, + pollInterval: 0.01 + ) { + try receiver.getPlatformWallet().balance().spendable + == receiverBalanceBefore + amount + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Platform/CoreToPlatformIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Platform/CoreToPlatformIntegrationTests.swift new file mode 100644 index 00000000000..5c1dc151b14 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Platform/CoreToPlatformIntegrationTests.swift @@ -0,0 +1,115 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +/// Core→Platform funding coverage, mirroring the Core-to-Core send +/// test (`CoreSendIntegrationTests`) but exercising the two ways Core +/// coins cross into Platform: funding a wallet address, and +/// asset-lock-funding (registering) an identity. +final class CoreToPlatformIntegrationTests: IntegrationTestCase { + private let fundingDash: Double = 0.5 + private var fundingDuffs: UInt64 { UInt64(fundingDash * 1e8) } + + /// Fund a Core receive address and assert the + /// wallet observes the spendable balance and persists exactly one + /// TXO — the Core→wallet half every Platform flow builds on. + func testFundAddressReflectsInWallet() async throws { + try await env.ensureSPVStarted() + let alice = try await env.makeTestWallet(name: "c2p-fund-address") + + let address = try alice.getCoreWallet().nextReceiveAddress() + _ = try await env.fund(address: address, dash: fundingDash) + try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90) + + // Balance surfaced through the platform wallet's core side. + let spendable = try alice.getPlatformWallet().balance().spendable + XCTAssertEqual(spendable, fundingDuffs) + + // The single funding output persisted exactly one TXO row. + let walletId = alice.getPlatformWallet().walletId + let container = env.modelContainer + try await MainActor.run { + let ctx = ModelContext(container) + let txoCount = try ctx.fetchCount(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + XCTAssertEqual(txoCount, 1) + } + } + + /// Fund an identity: build an asset lock from the Core wallet's + /// spendable UTXOs and register a fresh Platform identity, then + /// assert it carries a credit balance and persisted a + /// `PersistentIdentity` row. + func testRegisterIdentityWithCoreFunding() async throws { + try await env.ensureSPVStarted() + let alice = try await env.makeTestWallet(name: "c2p-fund-identity") + + let address = try alice.getCoreWallet().nextReceiveAddress() + _ = try await env.fund(address: address, dash: fundingDash) + try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90) + + // The asset-lock proof resolves via InstantSend / ChainLock + // signatures from the masternodes, so the SPV masternode list + // must be synced before registering. + try await env.waitForSPVUpToDate(timeout: 30) + + let wallet = alice.getPlatformWallet() + let identityIndex: UInt32 = 0 + let keyCount: UInt32 = 3 + + // Single-FFI derive + Keychain-persist of the identity auth + // keys; Rust owns the master/HIGH key policy and hands back + // the ready-to-register pubkey rows. + let pubkeys = try wallet.prePersistIdentityKeysForRegistration( + identityIndex: identityIndex, + keyCount: keyCount, + network: .regtest + ) + XCTAssertEqual(pubkeys.count, Int(keyCount)) + + // The signer reads identity-key private material back from the + // Keychain (and resolves platform-address keys from the + // mnemonic) when Rust asks the state transition to be signed. + let signer = KeychainSigner( + modelContainer: env.modelContainer, + network: .regtest + ) + + // 0.1 DASH of credits — comfortably above the ~221.5k-duff + // floor for three keys. + let assetLockDuffs: UInt64 = 10_000_000 + let (identityId, identity) = try await wallet.registerIdentityWithFunding( + amountDuffs: assetLockDuffs, + accountIndex: 0, + identityIndex: identityIndex, + identityPubkeys: pubkeys, + signer: signer + ) + + XCTAssertEqual(identityId.count, 32) + + // The registered identity carries the asset-lock credits + // (minus the registration cost). + let credits = try identity.getBalance() + XCTAssertGreaterThan(credits, 0) + + // The identity persister writes the `PersistentIdentity` row on + // a background context, so poll rather than read once. + let container = env.modelContainer + try await Wait.until( + "PersistentIdentity row for the registered identity", + timeout: 30, + pollInterval: 0.1 + ) { + try await MainActor.run { + let ctx = ModelContext(container) + let rows = try ctx.fetch(FetchDescriptor( + predicate: #Predicate { $0.identityId == identityId } + )) + guard let row = rows.first else { return false } + return row.balance > 0 + } + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/CoreRPCClient.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/CoreRPCClient.swift new file mode 100644 index 00000000000..8acb4184f57 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/CoreRPCClient.swift @@ -0,0 +1,158 @@ +import Foundation + +/// Minimal JSON-RPC client for dashd, covering only the calls the +/// integration framework needs. +struct CoreRPCClient { + private(set) var url: URL + let username: String + let password: String + + init(port: Int, username: String, password: String) { + var components = URLComponents() + components.scheme = "http" + components.host = "127.0.0.1" + components.port = port + guard let url = components.url else { + preconditionFailure("Invalid Core RPC port: \(port)") + } + self.url = url + self.username = username + self.password = password + } + + // MARK: - Typed methods + + /// Throws RPC error -32601 if dashd was built without the miner. + @discardableResult + func generateToAddress(count: Int, address: String) async throws -> [String] { + try await call("generatetoaddress", params: [.int(count), .string(address)]) + } + + func getNewAddress(walletName: String = "main") async throws -> String { + try await wallet(walletName).call("getnewaddress") + } + + /// `amount` is in DASH (not duffs). Returns the txid. + @discardableResult + func sendToAddress(amount: Double, address: String, walletName: String = "main") async throws -> String { + try await wallet(walletName).call( + "sendtoaddress", + params: [.string(address), .double(amount)] + ) + } + + /// Current best-known chain tip height. + func getBlockCount() async throws -> Int { + try await call("getblockcount") + } + + /// `getmempoolentry` fields the integration framework consumes. + /// dashd serializes the `instantlock` flag as a JSON *string* + /// (`"true"` / `"false"`) rather than a bool — match the wire + /// shape exactly or decoding throws. + struct MempoolEntry: Decodable { + let instantlock: String + let unbroadcast: Bool + } + + func getMempoolEntry(_ txid: String) async throws -> MempoolEntry { + try await call("getmempoolentry", params: [.string(txid)]) + } + + /// Switches to the per-wallet RPC endpoint (`/wallet/`). + func wallet(_ name: String) -> CoreRPCClient { + var copy = self + copy.url = url.appendingPathComponent("wallet").appendingPathComponent(name) + return copy + } + + // MARK: - Generic JSON-RPC + + enum Param: Sendable { + case string(String) + case int(Int) + case double(Double) + } + + enum RPCError: Error, CustomStringConvertible { + case http(Int) + case rpc(code: Int, message: String) + case decode(String) + + var description: String { + switch self { + case let .http(code): return "HTTP \(code)" + case let .rpc(code, msg): return "RPC error \(code): \(msg)" + case let .decode(msg): return "Decode error: \(msg)" + } + } + } + + func call(_ method: String, params: [Param] = []) async throws -> T { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let credentials = "\(username):\(password)".data(using: .utf8)!.base64EncodedString() + request.setValue("Basic \(credentials)", forHTTPHeaderField: "Authorization") + + let body: [String: Any] = [ + "jsonrpc": "1.0", + "id": "swift-integration", + "method": method, + "params": encodeParams(params), + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + // dashd returns the RPC error in the body even on 500; only + // bail on HTTP errors if we can't parse the JSON-RPC body. + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + if let decoded = try? decodeRPCResponse(data, T.self) { + return try unwrap(decoded) + } + throw RPCError.http(http.statusCode) + } + let decoded = try decodeRPCResponse(data, T.self) + return try unwrap(decoded) + } + + private func decodeRPCResponse(_ data: Data, _: T.Type) throws -> RPCResponse { + do { + return try JSONDecoder().decode(RPCResponse.self, from: data) + } catch { + let raw = String(data: data, encoding: .utf8) ?? "" + throw RPCError.decode("\(error) — body: \(raw)") + } + } + + private func unwrap(_ response: RPCResponse) throws -> T { + if let error = response.error { + throw RPCError.rpc(code: error.code, message: error.message) + } + guard let result = response.result else { + throw RPCError.decode("RPC response had neither `result` nor `error`") + } + return result + } + + private func encodeParams(_ params: [Param]) -> [Any] { + params.map { param -> Any in + switch param { + case let .string(s): return s + case let .int(i): return i + case let .double(d): return d + } + } + } + + private struct RPCResponse: Decodable { + let result: T? + let error: RPCErrorBody? + } + + private struct RPCErrorBody: Decodable { + let code: Int + let message: String + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/IntegrationTestCase.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/IntegrationTestCase.swift new file mode 100644 index 00000000000..1395bef6291 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/IntegrationTestCase.swift @@ -0,0 +1,69 @@ +import Foundation +import SwiftData +import XCTest +@testable import SwiftDashSDK + +open class IntegrationTestCase: XCTestCase { + private(set) var env: IntegrationTestEnv! + + // XCTest serialises class-level setUps; only one path touches + // this latch. + nonisolated(unsafe) private static var bootstrapResult: Result? + + open override func setUp() async throws { + try await super.setUp() + try skipIfDisabled() + env = try await Self.sharedEnv() + } + + open override func tearDown() async throws { + env?.purgeStoredMnemonics() + try await super.tearDown() + } + + private func skipIfDisabled() throws { + let enabled = ProcessInfo.processInfo.environment["RUN_INTEGRATION_TESTS"] == "1" + try XCTSkipUnless( + enabled, + "Integration tests skipped — set RUN_INTEGRATION_TESTS=1 to enable" + ) + } + + private static func sharedEnv() async throws -> IntegrationTestEnv { + if let cached = bootstrapResult { + return try cached.get() + } + do { + let env = try await IntegrationTestEnv.bootstrap() + bootstrapResult = .success(env) + return env + } catch { + bootstrapResult = .failure(error) + throw error + } + } + + + /// All txids currently in `PersistentTransaction` + func readTxids() async throws -> Set { + let container = env.modelContainer + return try await MainActor.run { + let ctx = ModelContext(container) + return Set(try ctx.fetch(FetchDescriptor()).map { $0.txid }) + } + } + + /// Polls `readTxids()` until a txid not in `before` shows up, + /// then returns it. Returns nil on timeout (60s). + func waitForNewTxid(notIn before: Set) async throws -> Data? { + let deadline = Date().addingTimeInterval(60) + while Date() < deadline { + let after = try await readTxids() + if let found = after.subtracting(before).first { + return found + } + try await Task.sleep(nanoseconds: 50_000_000) + } + return nil + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/IntegrationTestEnv.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/IntegrationTestEnv.swift new file mode 100644 index 00000000000..2d9ad816a43 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/IntegrationTestEnv.swift @@ -0,0 +1,293 @@ +import Foundation +import XCTest +import SwiftData +import SwiftDashSDK + +enum IntegrationTestEnvError: LocalizedError { + case timeout(String) + + var errorDescription: String? { + switch self { + case .timeout(let msg): return msg + } + } +} + +/// Suite-wide handle built once per process from +/// `IntegrationTestCase.setUp` and reused across every test. +final class IntegrationTestEnv: @unchecked Sendable { + let coreRPC: CoreRPCClient + let sdk: SDK + let modelContainer: ModelContainer + + private let endpoints: LocalDevnet.Endpoints + private var walletManager: PlatformWalletManager + private let mineRewardAddress: String + + nonisolated(unsafe) private var spvStarted = false + nonisolated(unsafe) private var spvDataDir: String? + nonisolated(unsafe) private var trackedWalletIds: [Data] = [] + + private init( + endpoints: LocalDevnet.Endpoints, + coreRPC: CoreRPCClient, + sdk: SDK, + modelContainer: ModelContainer, + walletManager: PlatformWalletManager, + mineRewardAddress: String + ) { + self.endpoints = endpoints + self.coreRPC = coreRPC + self.sdk = sdk + self.modelContainer = modelContainer + self.walletManager = walletManager + self.mineRewardAddress = mineRewardAddress + } + + /// Build the env. Assumes `run_integration_tests.sh` has already + /// brought dashmate online. + static func bootstrap() async throws -> IntegrationTestEnv { + let repoRoot = try locateRepoRoot() + let configName = ProcessInfo.processInfo.environment["DASHMATE_CONFIG"] ?? "local_seed" + let endpoints = try LocalDevnet(repoRoot: repoRoot, configName: configName).discoverEndpoints() + + let coreRPC = CoreRPCClient( + port: endpoints.coreRPC, + username: endpoints.rpcUsername, + password: endpoints.rpcPassword + ) + + // The SDK reads `platformDAPIAddresses` from UserDefaults to + // override its built-in regtest default — matches SwiftExampleApp. + UserDefaults.standard.set( + "http://127.0.0.1:\(endpoints.platformDAPI)", + forKey: "platformDAPIAddresses" + ) + let sdk = try SDK(network: .regtest) + let modelContainer = try DashModelContainer.createInMemory() + let walletManager = try await MainActor.run { + try PlatformWalletManager(sdk: sdk, modelContainer: modelContainer) + } + + return IntegrationTestEnv( + endpoints: endpoints, + coreRPC: coreRPC, + sdk: sdk, + modelContainer: modelContainer, + walletManager: walletManager, + mineRewardAddress: try await coreRPC.getNewAddress() + ) + } + + /// Idempotent. Reuses `spvDataDir` across stop/start cycles so + /// restart tests resume against persisted headers / filters / TXOs. + func ensureSPVStarted() async throws { + if spvStarted { return } + + if spvDataDir == nil { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("swift-sdk-it-spv-\(UUID().uuidString.prefix(8))", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + spvDataDir = dir.path + } + + try await startSPV(dataDir: spvDataDir!) + + spvStarted = true + } + + func restartSPV() async throws { + guard let dataDir = spvDataDir else { + fatalError("restartSPV() called before ensureSPVStarted()") + } + + try await MainActor.run { try walletManager.stopSpv() } + + try await startSPV(dataDir: dataDir) + + try await waitForSPVRunning() + try await waitForSPVUpToDate(timeout: 30) + } + + /// Poll `walletManager.isSpvRunning()` until the background + /// `startSpv` task has installed the client. + func waitForSPVRunning(timeout: TimeInterval = 10) async throws { + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + if try await MainActor.run(body: { try walletManager.isSpvRunning() }) { + return + } + try await Task.sleep(nanoseconds: 50_000_000) + } + + throw IntegrationTestEnvError.timeout( + "SPV client did not report running within \(timeout)s" + ) + } + + /// Mirror the app's cold-start sequence: stop SPV, drop the + /// current `PlatformWalletManager`, build a fresh one against the + /// same `modelContainer`, and re-hydrate it via `loadFromPersistor`. + /// SPV is left STOPPED so callers can drive state changes (e.g. + /// mine a block) before the next `ensureSPVStarted` resumes against + /// the same dataDir. + func restartWalletManager() async throws { + if spvStarted { + let stopping = walletManager + try? await MainActor.run { try stopping.stopSpv() } + } + + let sdk = self.sdk + let container = self.modelContainer + + walletManager = try await MainActor.run { () -> PlatformWalletManager in + let mgr = try PlatformWalletManager(sdk: sdk, modelContainer: container) + _ = try mgr.loadFromPersistor() + return mgr + } + + spvStarted = false + } + + private func startSPV(dataDir: String) async throws { + let config = PlatformSpvStartConfig( + dataDir: dataDir, + network: .regtest, + userAgent: "swift-sdk-integration-tests/0.1", + peers: ["127.0.0.1:\(endpoints.coreP2P)"], + restrictToConfiguredPeers: true, + startFromHeight: 0 + ) + + try await MainActor.run { try walletManager.startSpv(config: config) } + } + + /// Generates a fresh mnemonic each call and persists it to Keychain. + func makeTestWallet(name: String? = nil) async throws -> TestWalletWrapper { + let mnemonic = try Mnemonic.generate(wordCount: 24) + + let wallet = try await MainActor.run { + try walletManager.createWallet( + mnemonic: mnemonic, + network: .regtest, + name: name, + createDefaultAccounts: true + ) + } + + try WalletStorage().storeMnemonic(mnemonic, for: wallet.walletId) + trackedWalletIds.append(wallet.walletId) + + return TestWalletWrapper(wallet: wallet, core: try wallet.coreWallet()) + } + + /// Delete every mnemonic this env wrote to Keychain in the test. + func purgeStoredMnemonics() { + let storage = WalletStorage() + + for id in trackedWalletIds { + try? storage.deleteMnemonic(for: id) + } + + trackedWalletIds.removeAll() + } + + /// Mine `count` blocks. When `including` is provided, block first + /// until dashd has the InstantSend lock for that txid + @discardableResult + func mine(_ count: Int, including txid: Data? = nil) async throws -> [String] { + if let txid { + let displayHex = Data(txid.reversed()).toHexString() + try await waitForInstantSendLock(txid: displayHex) + } + + let preMineHeight = try await coreRPC.getBlockCount() + let addr = try await coreRPC.generateToAddress(count: count, address: mineRewardAddress) + let postMineHeight = try await coreRPC.getBlockCount() + + XCTAssertEqual( + postMineHeight, preMineHeight + count, + "mine(\(count)) did not advance chain tip: \(preMineHeight) -> \(postMineHeight)" + ) + + return addr + } + + /// Polls `walletManager.syncProgress()` until headers AND filters + /// have reached `targetHeight` + func waitForSPVUpToDate(timeout: TimeInterval = 10) async throws { + let deadline = Date().addingTimeInterval(timeout) + let targetHeight = try await coreRPC.getBlockCount() + var lastHeaders: UInt32 = 0 + var lastFilters: UInt32 = 0 + + while Date() < deadline { + let progress = try await MainActor.run { try walletManager.syncProgress() } + lastHeaders = progress.headers?.currentHeight ?? 0 + lastFilters = progress.filters?.currentHeight ?? 0 + + let atTarget = lastHeaders >= targetHeight && lastFilters >= targetHeight + if atTarget { return } + + try await Task.sleep(nanoseconds: 100_000_000) + } + + throw IntegrationTestEnvError.timeout( + "SPV did not reach height \(targetHeight) within \(timeout)s — " + + "headers=\(lastHeaders), filters=\(lastFilters)" + ) + } + + /// `sendtoaddress` from the dashmate mining wallet, wait for the + /// InstantSend lock so the next mine actually includes the tx, + /// then mine 1 block. Returns the Core txid + @discardableResult + func fund(address: String, dash: Double) async throws -> String { + let txid = try await coreRPC.sendToAddress(amount: dash, address: address) + try await waitForInstantSendLock(txid: txid) + _ = try await mine(1) + return txid + } + + /// Block until dashd reports `instantlock=true` for the given tx + func waitForInstantSendLock(txid: String, timeout: TimeInterval = 30) async throws { + let deadline = Date().addingTimeInterval(timeout) + var lastError: Error? + + while Date() < deadline { + do { + let entry = try await coreRPC.getMempoolEntry(txid) + if entry.instantlock == "true" { return } + } catch { + lastError = error + } + try await Task.sleep(nanoseconds: 50_000_000) + } + + throw IntegrationTestEnvError.timeout( + "tx \(txid) did not reach instantlock=true within \(timeout)s" + + (lastError.map { " (last RPC error: \($0))" } ?? "") + ) + } + + private static func locateRepoRoot() throws -> URL { + if let override = ProcessInfo.processInfo.environment["PLATFORM_REPO_ROOT"] { + return URL(fileURLWithPath: override) + } + + let fm = FileManager.default + + var candidate = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + + for _ in 0..<8 { + if fm.fileExists(atPath: candidate.appendingPathComponent("package.json").path), + fm.fileExists(atPath: candidate.appendingPathComponent("packages/dashmate").path) { + return candidate + } + candidate.deleteLastPathComponent() + } + + fatalError("Could not locate platform repo root. Set PLATFORM_REPO_ROOT to override.") + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/LocalDevnet.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/LocalDevnet.swift new file mode 100644 index 00000000000..573a63b8c95 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/LocalDevnet.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Reads port + credential info from a running dashmate devnet. +/// Lifecycle (starting/stopping dashmate) is the script's job, +/// `run_integration_tests.sh` brings it up before invoking the test +/// binary. This type just reads config out of the running stack. +struct LocalDevnet { + let repoRoot: URL + let configName: String + + struct Endpoints { + let coreRPC: Int + let coreP2P: Int + let platformDAPI: Int + let rpcUsername: String + let rpcPassword: String + } + + init(repoRoot: URL, configName: String) { + self.repoRoot = repoRoot + self.configName = configName + } + + // MARK: - Endpoint discovery + + /// Reads ports + RPC creds via `yarn dashmate config get`, which + /// returns the merged base/local/preset chain — pulling from + /// `~/.dashmate/config.json` directly would miss preset overrides. + func discoverEndpoints() throws -> Endpoints { + let coreRPC = try readConfigInt("core.rpc.port") + let coreP2P = try readConfigInt("core.p2p.port") + let platformDAPI = try readConfigInt("platform.gateway.listeners.dapiAndDrive.port") + let rpcUser = try readConfigString("core.rpc.users.dashmate.username", fallback: "dashmate") + let rpcPass = try readConfigString("core.rpc.users.dashmate.password", fallback: "rpcpassword") + return Endpoints( + coreRPC: coreRPC, + coreP2P: coreP2P, + platformDAPI: platformDAPI, + rpcUsername: rpcUser, + rpcPassword: rpcPass + ) + } + + private func readConfigInt(_ path: String) throws -> Int { + let raw = try readConfigRaw(path) + guard let value = Int(raw) else { + throw DevnetError.malformedConfig("Expected int at `\(path)`, got: \(raw)") + } + return value + } + + private func readConfigString(_ path: String, fallback: String) throws -> String { + do { + return try readConfigRaw(path) + } catch { + return fallback + } + } + + private func readConfigRaw(_ path: String) throws -> String { + let result = try Shell.runChecked( + "/usr/bin/env", + ["yarn", "dashmate", "config", "get", path, "--config=\(configName)"], + cwd: repoRoot, + timeout: 30 + ) + // yarn prepends its own banner lines; `dashmate config get` + // emits the value as the last non-empty line. + let lines = result.stdout + .split(separator: "\n", omittingEmptySubsequences: true) + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard let value = lines.last else { + throw DevnetError.malformedConfig("Empty `dashmate config get \(path)` output") + } + return value + } + + enum DevnetError: Error, CustomStringConvertible { + case malformedConfig(String) + + var description: String { + switch self { + case let .malformedConfig(msg): return "Devnet config malformed: \(msg)" + } + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/Shell.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/Shell.swift new file mode 100644 index 00000000000..b2128992d21 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/Shell.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Subprocess wrapper used by the dashmate driver. Captures +/// stdout / stderr and the exit code. +enum Shell { + struct Result { + let exitCode: Int32 + let stdout: String + let stderr: String + + var ok: Bool { exitCode == 0 } + } + + enum Error: Swift.Error, CustomStringConvertible { + case nonZeroExit(command: String, exitCode: Int32, stdout: String, stderr: String) + case spawnFailed(command: String, underlying: Swift.Error) + + var description: String { + switch self { + case let .nonZeroExit(command, code, stdout, stderr): + // yarn often surfaces failure context on stdout; include + // both streams so the captured data isn't discarded. + return """ + Shell command failed (exit \(code)): \(command) + stdout: \(stdout) + stderr: \(stderr) + """ + case let .spawnFailed(command, err): + return "Failed to spawn `\(command)`: \(err)" + } + } + } + + /// Run `command args...` in `cwd`, inheriting the parent + /// environment. + static func run( + _ command: String, + _ arguments: [String] = [], + cwd: URL? = nil, + timeout: TimeInterval? = nil + ) throws -> Result { + let process = Process() + process.executableURL = URL(fileURLWithPath: command) + process.arguments = arguments + if let cwd { process.currentDirectoryURL = cwd } + process.environment = ProcessInfo.processInfo.environment + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + throw Error.spawnFailed( + command: "\(command) \(arguments.joined(separator: " "))", + underlying: error + ) + } + + if let timeout { + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + if process.isRunning { + process.terminate() + Thread.sleep(forTimeInterval: 0.5) + if process.isRunning { kill(process.processIdentifier, SIGKILL) } + } + } + + process.waitUntilExit() + + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + + return Result( + exitCode: process.terminationStatus, + stdout: String(data: stdoutData, encoding: .utf8) ?? "", + stderr: String(data: stderrData, encoding: .utf8) ?? "" + ) + } + + /// Like `run` but throws on non-zero exit. + @discardableResult + static func runChecked( + _ command: String, + _ arguments: [String] = [], + cwd: URL? = nil, + timeout: TimeInterval? = nil + ) throws -> Result { + let result = try run(command, arguments, cwd: cwd, timeout: timeout) + guard result.ok else { + throw Error.nonZeroExit( + command: "\(command) \(arguments.joined(separator: " "))", + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr + ) + } + return result + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift new file mode 100644 index 00000000000..cc86bcddf57 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift @@ -0,0 +1,51 @@ +import Foundation +import SwiftDashSDK + +/// Wraps a `ManagedPlatformWallet` and a `ManagedCoreWallet` +final class TestWalletWrapper { + private let core: ManagedCoreWallet + private let wallet: ManagedPlatformWallet + + init(wallet: ManagedPlatformWallet, core: ManagedCoreWallet) { + self.wallet = wallet + self.core = core + } + + func getCoreWallet() -> ManagedCoreWallet { + core + } + + func getPlatformWallet() -> ManagedPlatformWallet { + wallet + } + + func waitForSpendable(exactly duffs: UInt64, timeout: TimeInterval = 60) async throws { + try await Wait.until( + "wallet spendable == \(duffs) duffs", + timeout: timeout, + pollInterval: 0.01 + ) { + try wallet.balance().spendable == duffs + } + } +} + +enum Wait { + struct TimeoutError: Error, CustomStringConvertible { + let description: String + } + + static func until( + _ message: @autoclosure () -> String, + timeout: TimeInterval = 60, + pollInterval: TimeInterval = 0.5, + _ condition: () async throws -> Bool + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if try await condition() { return } + try await Task.sleep(nanoseconds: UInt64(pollInterval * 1_000_000_000)) + } + throw TimeoutError(description: "Timed out after \(timeout)s waiting for: \(message())") + } +} \ No newline at end of file diff --git a/packages/swift-sdk/run_integration_tests.sh b/packages/swift-sdk/run_integration_tests.sh new file mode 100755 index 00000000000..617524ea9c3 --- /dev/null +++ b/packages/swift-sdk/run_integration_tests.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -euo pipefail + +# Run the Swift SDK integration tests against a local dashmate devnet. +# Brings dashmate up if it isn't already, builds the macOS slice of +# the FFI, and invokes the test runner with Address Sanitizer enabled. +# +# One-time setup: `yarn dashmate setup --preset local`. + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +REPO_ROOT="$( cd "$SCRIPT_DIR/../.." && pwd )" + +cd "$SCRIPT_DIR" + +# dashmate pins Node 20–22; bail early instead of failing several +# minutes into the dashmate startup with a cryptic error. +node_major=$(node -p "process.versions.node.split('.')[0]" 2>/dev/null || echo 0) +if [ "$node_major" -gt 22 ] || [ "$node_major" -lt 20 ]; then + echo "Node $(node --version 2>/dev/null || echo '') is not supported by dashmate." + echo "Use Node 20–22 (e.g. \`nvm use 22\`) and re-run." + exit 1 +fi + +# Bootstrap JS workspace once. Both are idempotent skip-fast paths. +[ -d "$REPO_ROOT/.yarn/unplugged" ] || (cd "$REPO_ROOT" && yarn install) +[ -f "$REPO_ROOT/packages/wasm-dpp/dist/index.js" ] || (cd "$REPO_ROOT" && yarn build) + +# Bring dashmate up if DAPI isn't already listening. +STOP_LOCAL_NET=false +if ! nc -z 127.0.0.1 2443 >/dev/null 2>&1; then + echo "starting dashmate devnet group (~1-2 min)…" + (cd "$REPO_ROOT" && yarn dashmate group stop --group=local 2>/dev/null || true) + (cd "$REPO_ROOT" && yarn dashmate group start --group=local --wait-for-readiness) + STOP_LOCAL_NET=true +fi + +bash build_ios.sh --target all --profile dev + +RUN_INTEGRATION_TESTS=1 swift test --sanitize=address --filter SwiftDashSDKIntegrationTests "$@" + +if [ "$STOP_LOCAL_NET" == true ]; then + (cd "$REPO_ROOT" && yarn dashmate group stop --group=local 2>/dev/null) +fi \ No newline at end of file