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 @@ -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
Expand All @@ -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
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.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<PersistentTransaction>())
XCTAssertEqual(allTxCount, expectedTotalTxs)
Comment on lines +64 to +67
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Carried-forward prior finding: global PersistentTransaction count is coupled to the process-shared SwiftData store

IntegrationTestCase.sharedEnv() memoises one IntegrationTestEnv per process, and IntegrationTestEnv.bootstrap() creates a single in-memory ModelContainer that every test reuses. tearDown() only purges Keychain mnemonics — SwiftData rows accumulate across tests. The assertion XCTAssertEqual(allTxCount, expectedTotalTxs) counts every PersistentTransaction in the shared store, so as soon as any second test persists transactions (or this test is rerun in the same process), it flakes. The per-wallet TXO counts below (lines 63–75) already filter by walletId and are the correct pattern — scope the total similarly (e.g. walletId == aliceWalletId || walletId == bobWalletId) or otherwise restrict to wallets created by this test.

source: ['claude', 'codex']

Comment on lines +64 to +67
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Carried-forward prior finding: global PersistentTransaction count is coupled to the process-shared SwiftData store

IntegrationTestCase.sharedEnv() memoises a single IntegrationTestEnv per process, and IntegrationTestEnv.bootstrap() builds one in-memory ModelContainer reused across every test. tearDown only calls purgeStoredMnemonics() (Keychain), so SwiftData rows accumulate. As soon as another test class writes PersistentTransaction rows to the same shared container, XCTAssertEqual(allTxCount, expectedTotalTxs) becomes order-dependent. Scope the assertion to the wallets under test (filter by walletId in [aliceWalletId, bobWalletId]) the same way the TXO counts on lines 63–75 already do.

source: ['claude', 'codex']


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 on lines +64 to +82
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Global PersistentTransaction count is coupled to a process-shared SwiftData store

IntegrationTestCase.sharedEnv() memoizes a single IntegrationTestEnv for the whole process, and IntegrationTestEnv owns one in-memory ModelContainer reused across every test (only Keychain mnemonics get cleaned up in tearDown). The assertion at line 61 runs an unfiltered fetchCount(FetchDescriptor<PersistentTransaction>()) == 2 + iterations over that shared store. Today only one SPV test exists so it happens to pass, but the moment a second SPV-using test (or a re-run within the same process) lands, prior transactions persist and this assertion flips to a confusing failure even when wallet-to-wallet send still works. The framework is explicitly meant to host more tests — fix the coupling now. Either filter PersistentTransaction by walletId (the way the PersistentTxo queries below already do) or hand each test a fresh ModelContainer from IntegrationTestEnv.

source: ['claude', 'codex']

Comment on lines +64 to +82
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Carried-forward prior finding: global PersistentTransaction count is coupled to the process-shared SwiftData store

IntegrationTestCase.sharedEnv() memoises a single IntegrationTestEnv for the whole test process and IntegrationTestEnv.bootstrap() builds one in-memory ModelContainer reused across every test. Only Keychain mnemonics are cleared in tearDown; SwiftData rows persist in the shared container. The assertion on line 61 (fetchCount(FetchDescriptor<PersistentTransaction>())) is unfiltered, so the moment a second integration test writes a transaction into the same container the equality check either fails spuriously or — worse — masks a regression because the global count happens to land on the expected value. The TXO counts on lines 63-75 are correctly scoped by walletId; the same scoping should apply to the transaction count.

Suggested change
try await MainActor.run {
let context = ModelContext(container)
let allTxCount = try context.fetchCount(FetchDescriptor<PersistentTransaction>())
XCTAssertEqual(allTxCount, expectedTotalTxs)
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)
}
try await MainActor.run {
let context = ModelContext(container)
let aliceTxCount = try context.fetchCount(FetchDescriptor<PersistentTransaction>(
predicate: #Predicate<PersistentTransaction>{
$0.walletId == aliceWalletId
}
))
let bobTxCount = try context.fetchCount(FetchDescriptor<PersistentTransaction>(
predicate: #Predicate<PersistentTransaction>{
$0.walletId == bobWalletId
}
))
XCTAssertEqual(aliceTxCount + bobTxCount, expectedTotalTxs)
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)
}

source: ['claude', 'codex']

}
}
Original file line number Diff line number Diff line change
@@ -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<PersistentTransaction>(
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))"
)
}
}

Loading
Loading