Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/swift-sdk/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ public class WalletStorage {
/// this single service so the keychain explorer + future
/// cross-item queries see one namespace. Legacy services are
/// scrubbed on launch by `cleanupLegacyItems`.
public static let keychainService = "org.dashfoundation.wallet"
public static let keychainService =
ProcessInfo.processInfo.environment["DASH_KEYCHAIN_SERVICE"]
?? "org.dashfoundation.wallet"

/// Per-instance alias that lets the rest of this file stay
/// short; the static constant is the one external callers
Expand Down
Original file line number Diff line number Diff line change
@@ -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.walletManager.startSpv(config: env.spvConfig)
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<PersistentTransaction>())
XCTAssertEqual(allTxCount, expectedTotalTxs)
Comment thread
ZocoLini marked this conversation as resolved.
Comment thread
ZocoLini marked this conversation as resolved.

let aliceTxoCount = try context.fetchCount(FetchDescriptor<PersistentTxo>(
predicate: #Predicate<PersistentTxo>{
$0.walletId == aliceWalletId
}
))
let bobTxoCount = try context.fetchCount(FetchDescriptor<PersistentTxo>(
predicate: #Predicate<PersistentTxo>{
$0.walletId == bobWalletId
}
))

XCTAssertEqual(aliceTxoCount, 1 + iterations)
XCTAssertEqual(bobTxoCount, 1 + iterations)
}
Comment thread
ZocoLini marked this conversation as resolved.
Comment thread
ZocoLini marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import XCTest
import SwiftData
import CryptoKit
@testable import SwiftDashSDK

/// This test ensures that the classification of a self-send transaction
/// as Internal / -fee survives a restart of the persister
final class PersisterRestartClassificationIntegrationTests: IntegrationTestCase {
private let fundingDash: Double = 0.5
private var fundingDuffs: UInt64 { UInt64(fundingDash * 1e8) }
private let sendAmount: UInt64 = 10_000

func testSelfSendClassificationSurvivesRestart() async throws {
try await env.walletManager.startSpv(config: env.spvConfig)
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 aliceSecondAddr = try alice.getCoreWallet().nextReceiveAddress()
let sendTxData = try alice.getCoreWallet().sendToAddresses(
recipients: [(address: aliceSecondAddr, amountDuffs: sendAmount)]
)
let sendTxid = Self.txid(ofRawTx: sendTxData)
try await waitForTxRow(sendTxid)

// First sighting must already classify as Internal / -fee.
try await assertSelfSendRow(txid: sendTxid, phase: "first sighting")

try await env.restartWalletManager()
try await env.walletManager.startSpv(config: env.spvConfig)
try await env.walletManager.waitUntilUpToDate(height: try await env.coreRPC.getBlockCount())

// Regression: classification must survive the restart.
try await assertSelfSendRow(txid: sendTxid, phase: "post-restart catch-up")
}

// MARK: - Helpers

/// `sha256d(tx_bytes)` in internal byte order — the identity
/// `PersistentTransaction.txid` stores (the persister copies Rust's
/// `Txid` verbatim).
private static func txid(ofRawTx data: Data) -> Data {
let first = Data(SHA256.hash(data: data))
return Data(SHA256.hash(data: first))
}

private func waitForTxRow(_ txid: Data, timeout: TimeInterval = 60) async throws {
try await Wait.until(
"PersistentTransaction row for \(txid.toHexString())",
timeout: timeout
) {
try await self.fetchTransaction(txid) != nil
}
}

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<PersistentTransaction>(
predicate: #Predicate { $0.txid == txid }
)).first else { return nil }
return TxSnapshot(
direction: row.direction,
netAmount: row.netAmount,
context: row.context
)
}
}

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)."
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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.walletManager.startSpv(config: env.spvConfig)
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)

// Restart the SPV client with explicit stop + start directly on
// the wallet manager — the same API a real app drives — then
// wait for it to catch back up before the next send.
try await env.walletManager.stopSpv()
try await env.walletManager.startSpv(config: env.spvConfig)
try await env.walletManager.waitUntilUpToDate(
height: try await env.coreRPC.getBlockCount(),
timeout: 30
)

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<PersistentTxo>(
predicate: #Predicate<PersistentTxo> { $0.walletId == aliceWalletId }
))
let bobTxoCount = try context.fetchCount(FetchDescriptor<PersistentTxo>(
predicate: #Predicate<PersistentTxo> { $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
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import XCTest
import SwiftData
@testable import SwiftDashSDK

final class SpvStartErrorIntegrationTests: IntegrationTestCase {
func testStartSpvSurfacesLockedDataDir() async throws {
try await env.walletManager.startSpv(config: env.spvConfig)

let sdk = env.sdk
let container = env.modelContainer
let other = try await MainActor.run {
try PlatformWalletManager(sdk: sdk, modelContainer: container)
}

do {
try await other.startSpv(config: env.spvConfig)
XCTFail("startSpv should throw: data dir is locked by the first manager")
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? "\(error)"
XCTAssertTrue(
message.localizedCaseInsensitiveContains("in use")
|| message.localizedCaseInsensitiveContains("lock"),
"expected a data-dir lock error, got: \(message)"
)
}
}
}
Loading
Loading