Skip to content

Commit 5a06551

Browse files
committed
test(swift-sdk): integration test framework against local network
1 parent 620b80b commit 5a06551

15 files changed

Lines changed: 1460 additions & 6 deletions

packages/rs-platform-wallet/src/spv/runtime.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,10 @@ use dash_spv::storage::DiskStorageManager;
1313
use dash_spv::sync::SyncProgress;
1414
use dash_spv::{ClientConfig, DashSpvClient, EventHandler, Hash};
1515

16-
use key_wallet_manager::WalletManager;
17-
1816
use crate::error::PlatformWalletError;
1917
use crate::events::PlatformEventManager;
2018
use crate::wallet::platform_wallet::PlatformWalletInfo;
19+
use key_wallet_manager::WalletManager;
2120

2221
type SpvClient =
2322
DashSpvClient<WalletManager<PlatformWalletInfo>, PeerNetworkManager, DiskStorageManager>;

packages/swift-sdk/Package.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,20 @@ let package = Package(
2828
linkerSettings: [.linkedFramework("SystemConfiguration")]
2929
),
3030

31-
// Tests
31+
// Unit tests (offline, hermetic)
3232
.testTarget(
3333
name: "SwiftDashSDKTests",
3434
dependencies: ["SwiftDashSDK"],
3535
path: "SwiftTests/SwiftDashSDKTests"
36-
)
36+
),
37+
38+
// Integration tests against a local dashmate devnet.
39+
// Gated by env var `RUN_INTEGRATION_TESTS=1`
40+
.testTarget(
41+
name: "SwiftDashSDKIntegrationTests",
42+
dependencies: ["SwiftDashSDK"],
43+
path: "SwiftTests/SwiftDashSDKIntegrationTests"
44+
),
3745
],
3846
swiftLanguageModes: [.v6]
3947
)

packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -707,12 +707,20 @@ extension KeychainManager {
707707
/// and this type's state is `let` — safe to call from the FFI
708708
/// trampoline on any Tokio worker thread.
709709
public nonisolated func retrieveIdentityPrivateKey(publicKeyHex: String) -> Data? {
710+
// Two-step lookup. macOS's legacy file keychain returns NOTHING
711+
// for a `kSecMatchLimitAll` query that also asks for
712+
// `kSecReturnData` on generic passwords (iOS returns the rows
713+
// fine). So scan ATTRIBUTES ONLY to resolve the matching item's
714+
// account name, then fetch its bytes with a single-item
715+
// `kSecMatchLimitOne` query (the path `retrieveKeyData` uses,
716+
// which works on both platforms). Keeping the secret out of the
717+
// bulk scan also avoids materializing every identity key's bytes
718+
// just to find one.
710719
var query: [String: Any] = [
711720
kSecClass as String: kSecClassGenericPassword,
712721
kSecAttrService as String: serviceName,
713722
kSecMatchLimit as String: kSecMatchLimitAll,
714723
kSecReturnAttributes as String: true,
715-
kSecReturnData as String: true,
716724
]
717725
if let accessGroup = accessGroup {
718726
query[kSecAttrAccessGroup as String] = accessGroup
@@ -739,7 +747,7 @@ extension KeychainManager {
739747
// Case-insensitive hex compare — both producers downcase
740748
// their hex but be defensive against future writers.
741749
if metadata.publicKey.caseInsensitiveCompare(publicKeyHex) == .orderedSame {
742-
return item[kSecValueData as String] as? Data
750+
return retrieveKeyData(identifier: account)
743751
}
744752
}
745753
return nil
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import XCTest
2+
import SwiftData
3+
@testable import SwiftDashSDK
4+
5+
final class CoreSendIntegrationTests: IntegrationTestCase {
6+
private let fundingDash: Double = 0.5
7+
private var fundingDuffs: UInt64 {
8+
UInt64(fundingDash * 1e8)
9+
}
10+
11+
func testWalletToWalletViaSpv() async throws {
12+
try await env.walletManager.startSpv(config: env.spvConfig)
13+
let alice = try await env.makeTestWallet(name: "core-send-alice")
14+
let bob = try await env.makeTestWallet(name: "core-send-bob")
15+
16+
let aliceAddress = try alice.getCoreWallet().nextReceiveAddress()
17+
_ = try await env.fund(address: aliceAddress, dash: fundingDash)
18+
let bobAddress = try bob.getCoreWallet().nextReceiveAddress()
19+
_ = try await env.fund(address: bobAddress, dash: fundingDash)
20+
try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90)
21+
try await bob.waitForSpendable(exactly: fundingDuffs, timeout: 90)
22+
23+
let iterations = 5
24+
let amount: UInt64 = 100_000 // 0.001 DASH per hop
25+
26+
for i in 0 ..< iterations {
27+
let aliceSends = (i % 2 == 0)
28+
let sender = aliceSends ? alice: bob
29+
let receiver = aliceSends ? bob: alice
30+
31+
let receiverBalanceBefore = try receiver.getPlatformWallet().balance().spendable
32+
let recipientAddress = try receiver.getCoreWallet().nextReceiveAddress()
33+
34+
let beforeTxids = try await readTxids()
35+
_ = try sender.getCoreWallet().sendToAddresses(
36+
recipients: [(address: recipientAddress, amountDuffs: amount)]
37+
)
38+
guard let sendTxid = try await waitForNewTxid(notIn: beforeTxids) else {
39+
XCTFail("send PersistentTransaction row never appeared on iteration \(i)")
40+
return
41+
}
42+
_ = try await env.mine(1, including: sendTxid)
43+
44+
try await Wait.until(
45+
"receiver +\(amount) after iteration \(i)",
46+
timeout: 60,
47+
pollInterval: 0.01
48+
) {
49+
try receiver.getPlatformWallet().balance().spendable
50+
== receiverBalanceBefore + amount
51+
}
52+
}
53+
54+
let aliceFinal = try alice.getPlatformWallet().balance().spendable
55+
let bobFinal = try bob.getPlatformWallet().balance().spendable
56+
XCTAssertLessThanOrEqual(aliceFinal + bobFinal, 2 * fundingDuffs)
57+
58+
// Validate via the SwiftData
59+
let expectedTotalTxs = 2 + iterations
60+
let aliceWalletId = alice.getPlatformWallet().walletId
61+
let bobWalletId = bob.getPlatformWallet().walletId
62+
let container = env.modelContainer
63+
64+
try await MainActor.run {
65+
let context = ModelContext(container)
66+
let allTxCount = try context.fetchCount(FetchDescriptor<PersistentTransaction>())
67+
XCTAssertEqual(allTxCount, expectedTotalTxs)
68+
69+
let aliceTxoCount = try context.fetchCount(FetchDescriptor<PersistentTxo>(
70+
predicate: #Predicate<PersistentTxo>{
71+
$0.walletId == aliceWalletId
72+
}
73+
))
74+
let bobTxoCount = try context.fetchCount(FetchDescriptor<PersistentTxo>(
75+
predicate: #Predicate<PersistentTxo>{
76+
$0.walletId == bobWalletId
77+
}
78+
))
79+
80+
XCTAssertEqual(aliceTxoCount, 1 + iterations)
81+
XCTAssertEqual(bobTxoCount, 1 + iterations)
82+
}
83+
}
84+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import XCTest
2+
import SwiftData
3+
@testable import SwiftDashSDK
4+
5+
/// Regression guard for the "self-send flips to phantom-incoming after
6+
/// a mid-flight restart" bug. The mempool sighting taints
7+
/// `PersistentTxo.isSpent` for the input; the persister's load
8+
/// callback then filters that row out of the restored UTXO set, so
9+
/// the catch-up classifier on the next launch sees a missing input,
10+
/// emits `direction=Incoming`, and rewrites `netAmount` to the sum of
11+
/// the wallet's own outputs in the tx.
12+
final class PersisterRestartClassificationIntegrationTests: IntegrationTestCase {
13+
private let fundingDash: Double = 0.5
14+
private var fundingDuffs: UInt64 { UInt64(fundingDash * 1e8) }
15+
private let sendAmount: UInt64 = 10_000
16+
17+
func testSelfSendClassificationSurvivesMidFlightRestart() async throws {
18+
try await env.walletManager.startSpv(config: env.spvConfig)
19+
let alice = try await env.makeTestWallet(name: "restart-class-alice")
20+
21+
let aliceFundingAddr = try alice.getCoreWallet().nextReceiveAddress()
22+
_ = try await env.fund(address: aliceFundingAddr, dash: fundingDash)
23+
try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90)
24+
25+
let beforeTxids = try await readTxids()
26+
27+
let aliceSecondAddr = try alice.getCoreWallet().nextReceiveAddress()
28+
_ = try alice.getCoreWallet().sendToAddresses(
29+
recipients: [(address: aliceSecondAddr, amountDuffs: sendAmount)]
30+
)
31+
32+
guard let sendTxid = try await waitForNewTxid(notIn: beforeTxids) else {
33+
XCTFail("self-send PersistentTransaction row never appeared within 60s")
34+
return
35+
}
36+
37+
// Control: mempool sighting must already be Internal/-fee
38+
// (Rust still holds the input in memory at this point).
39+
try await assertSelfSendRow(
40+
txid: sendTxid,
41+
phase: "mempool sighting"
42+
)
43+
44+
try await env.restartWalletManager()
45+
_ = try await env.mine(1, including: sendTxid)
46+
try await env.walletManager.startSpv(config: env.spvConfig)
47+
try await env.walletManager.waitUntilUpToDate(height: try await env.coreRPC.getBlockCount())
48+
49+
try await assertTxIsMined(
50+
txid: sendTxid,
51+
phase: "post-restart catch-up"
52+
)
53+
54+
// Regression: classification must survive the restart. Pre-fix
55+
// the row flips to Incoming / +sum_of_outputs.
56+
try await assertSelfSendRow(
57+
txid: sendTxid,
58+
phase: "post-restart catch-up"
59+
)
60+
}
61+
62+
/// Same regression as the test above, but the self-send tx never
63+
/// gets a confirming block — it stays in the mempool across the
64+
/// restart. Exercises the catch-up classifier on the mempool-only
65+
/// path: after the SPV reconnects, the masternodes replay the
66+
/// wallet's own tx via INV/mempool and the classifier reprocesses
67+
/// it without any new block to anchor it.
68+
func testSelfSendClassificationSurvivesMempoolOnlyRestart() async throws {
69+
try await env.walletManager.startSpv(config: env.spvConfig)
70+
let alice = try await env.makeTestWallet(name: "restart-class-alice-no-mine")
71+
72+
let aliceFundingAddr = try alice.getCoreWallet().nextReceiveAddress()
73+
_ = try await env.fund(address: aliceFundingAddr, dash: fundingDash)
74+
try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90)
75+
76+
let beforeTxids = try await readTxids()
77+
78+
let aliceSecondAddr = try alice.getCoreWallet().nextReceiveAddress()
79+
_ = try alice.getCoreWallet().sendToAddresses(
80+
recipients: [(address: aliceSecondAddr, amountDuffs: sendAmount)]
81+
)
82+
83+
guard let sendTxid = try await waitForNewTxid(notIn: beforeTxids) else {
84+
XCTFail("self-send PersistentTransaction row never appeared within 60s")
85+
return
86+
}
87+
88+
try await assertSelfSendRow(
89+
txid: sendTxid,
90+
phase: "mempool sighting"
91+
)
92+
93+
try await env.restartWalletManager()
94+
try await env.walletManager.startSpv(config: env.spvConfig)
95+
96+
try await assertTxIsInMempool(
97+
txid: sendTxid,
98+
phase: "post-restart mempool-only catch-up"
99+
)
100+
101+
try await assertSelfSendRow(
102+
txid: sendTxid,
103+
phase: "post-restart mempool-only catch-up"
104+
)
105+
}
106+
107+
// MARK: - Helpers
108+
109+
/// Sendable snapshot of the columns the test cares about.
110+
/// `PersistentTransaction` itself can't cross the `MainActor.run`
111+
/// boundary because `@Model` types are not Sendable.
112+
private struct TxSnapshot: Sendable {
113+
let direction: UInt32
114+
let netAmount: Int64
115+
let context: UInt32
116+
}
117+
118+
private func fetchTransaction(_ txid: Data) async throws -> TxSnapshot? {
119+
let container = env.modelContainer
120+
return try await MainActor.run {
121+
let ctx = ModelContext(container)
122+
guard let row = try ctx.fetch(FetchDescriptor<PersistentTransaction>(
123+
predicate: #Predicate { $0.txid == txid }
124+
)).first else { return nil }
125+
return TxSnapshot(
126+
direction: row.direction,
127+
netAmount: row.netAmount,
128+
context: row.context
129+
)
130+
}
131+
}
132+
133+
/// Asserts the row is in a mined state: inBlock (2)
134+
private func assertTxIsMined(txid: Data, phase: String) async throws {
135+
guard let row = try await fetchTransaction(txid) else {
136+
XCTFail("\(phase): tx row missing for \(txid.toHexString()) (expected mined)")
137+
return
138+
}
139+
140+
let inBlock = TransactionContextType.inBlock.rawValue
141+
XCTAssertTrue(
142+
row.context == inBlock,
143+
"\(phase): context=\(row.context) — expected inBlock(\(inBlock))"
144+
)
145+
}
146+
147+
/// Asserts the row is in a mempool-equivalent state: mempool (0)
148+
/// or instantSend (1) — i.e., observed but not yet in a block.
149+
/// Used after the variant that restarts without mining.
150+
private func assertTxIsInMempool(txid: Data, phase: String) async throws {
151+
guard let row = try await fetchTransaction(txid) else {
152+
XCTFail("\(phase): tx row missing for \(txid.toHexString()) (expected mempool)")
153+
return
154+
}
155+
156+
let mempool = TransactionContextType.mempool.rawValue
157+
let instantSend = TransactionContextType.instantSend.rawValue
158+
159+
XCTAssertTrue(
160+
row.context == mempool || row.context == instantSend,
161+
"\(phase): context=\(row.context) — expected mempool(\(mempool)) or instantSend(\(instantSend))"
162+
)
163+
}
164+
165+
private func assertSelfSendRow(txid: Data, phase: String) async throws {
166+
guard let row = try await fetchTransaction(txid) else {
167+
XCTFail("\(phase): row missing for txid \(txid.toHexString())")
168+
return
169+
}
170+
171+
XCTAssertEqual(
172+
row.direction, 2,
173+
"\(phase): direction=\(row.direction) (expected 2=Internal). " +
174+
"context=\(row.context), netAmount=\(row.netAmount). " +
175+
"Positive netAmount with direction=0 is the phantom-incoming signature."
176+
)
177+
178+
XCTAssertLessThan(
179+
row.netAmount, 0,
180+
"\(phase): netAmount=\(row.netAmount) (expected <0; self-send only leaves the fee)."
181+
)
182+
183+
XCTAssertLessThan(
184+
abs(row.netAmount), 100_000,
185+
"\(phase): fee too large: |netAmount|=\(abs(row.netAmount))"
186+
)
187+
}
188+
}
189+

0 commit comments

Comments
 (0)