Skip to content

Commit aa14c74

Browse files
committed
added TransactionChecker protocol and implement logic to verify that previous accounts have transactions
1 parent 7cb0fb6 commit aa14c74

File tree

2 files changed

+129
-13
lines changed

2 files changed

+129
-13
lines changed

Sources/Web3Core/KeystoreManager/BIP44.swift

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,53 @@ public protocol BIP44 {
1414
- Throws: `BIP44Error.warning` if the child key shouldn't be used according to BIP44 standard.
1515
- Returns: an HDNode child key for the provided `path` if it can be created, otherwise nil
1616
*/
17-
func derive(path: String, warns: Bool) async throws -> HDNode?
17+
func derive(path: String, warns: Bool, transactionChecker: TransactionChecker) async throws -> HDNode?
1818
}
1919

20-
public enum BIP44Error: Equatable {
20+
public enum BIP44Error: Error, Equatable {
2121
/// The selected path doesn't fulfill BIP44 standard, you can derive the root key anyway
2222
case warning
2323
}
2424

25+
public protocol TransactionChecker {
26+
/**
27+
It verifies if the provided `address` has at least one transaction
28+
29+
- Parameter address: to be queried
30+
- Throws: any error related to query the blockchain provider
31+
- Returns: true if the `address` has at least one transaction, false otherwise
32+
*/
33+
func hasTransactions(address: String) async throws -> Bool
34+
}
35+
2536
extension HDNode: BIP44 {
26-
public func derive(path: String, warns: Bool = true) async throws -> HDNode? {
37+
public func derive(path: String, warns: Bool = true, transactionChecker: TransactionChecker) async throws -> HDNode? {
2738
if warns {
2839
guard let account = path.accountFromPath else {
2940
return nil
3041
}
3142
if account == 0 {
3243
return derive(path: path, derivePrivateKey: true)
44+
} else {
45+
for searchAccount in 0..<account {
46+
let maxUnusedAddressIndexes = 20
47+
var hasTransactions = false
48+
for searchAddressIndex in 0..<maxUnusedAddressIndexes {
49+
if let searchPath = path.newPath(account: searchAccount, addressIndex: searchAddressIndex),
50+
let childNode = derive(path: searchPath, derivePrivateKey: true),
51+
let ethAddress = Utilities.publicToAddress(childNode.publicKey) {
52+
hasTransactions = try await transactionChecker.hasTransactions(address: ethAddress.address)
53+
if hasTransactions {
54+
break
55+
}
56+
}
57+
}
58+
if !hasTransactions {
59+
throw BIP44Error.warning
60+
}
61+
}
62+
return derive(path: path, derivePrivateKey: true)
3363
}
34-
var accountIndex = 0
35-
return nil
3664
} else {
3765
return derive(path: path, derivePrivateKey: true)
3866
}
@@ -70,7 +98,7 @@ extension String {
7098
Transforms a bip44 path into a new one changing `account` & `index`. The resulting one will have the change value equal to `0` to represent external chain. Format `m/44'/coin_type'/account'/change/address_index`
7199
- Parameter account: the new `account` to use
72100
- Parameter addressIndex: the new `addressIndex` to use
73-
- Returns: a valid bip44 path or nil otherwise
101+
- Returns: a valid bip44 path with the provided `account`, `addressIndex`and external `change` or nil otherwise
74102
*/
75103
func newPath(account: Int, addressIndex: Int) -> String? {
76104
guard isBip44Path else {
@@ -83,8 +111,6 @@ extension String {
83111
components[changePosition] = "0"
84112
let addressIndexPosition = 5
85113
components[addressIndexPosition] = "\(addressIndex)"
86-
let result = components.joined(separator: "/")
87-
return result
114+
return components.joined(separator: "/")
88115
}
89116
}
90-

Tests/web3swiftTests/localTests/BIP44Tests.swift

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,93 @@ import Web3Core
88
@testable import web3swift
99

1010
final class BIP44Tests: LocalTestCase {
11+
private var mockTransactionChecker: MockTransactionChecker!
12+
13+
override func setUpWithError() throws {
14+
try super.setUpWithError()
15+
mockTransactionChecker = MockTransactionChecker()
16+
}
17+
18+
override func tearDownWithError() throws {
19+
try super.tearDownWithError()
20+
mockTransactionChecker = nil
21+
}
1122

23+
//MARK: - warns false
24+
1225
func testDeriveNoWarn() async throws {
1326
let rootNode = try rootNode()
1427

15-
let childNode = try await rootNode.derive(path: "m/44'/60'/8096'/0/1", warns: false)
28+
let childNode = try await rootNode.derive(path: "m/44'/60'/8096'/0/1", warns: false, transactionChecker: mockTransactionChecker)
1629

1730
XCTAssertEqual(try XCTUnwrap(childNode).publicKey.toHexString(), "035785d4918449c87892371c0f9ccf6e4eda40a7fb0f773f1254c064d3bba64026")
1831
}
19-
20-
func testAccountZeroCanBeDerived() async throws {
32+
33+
//MARK: - warns true
34+
35+
func testDeriveInvalidPath() async throws {
2136
let rootNode = try rootNode()
2237

23-
let childNode = try await rootNode.derive(path: "m/44'/60'/0'/0/255", warns: true)
38+
let childNode = try? await rootNode.derive(path: "", warns: true, transactionChecker: mockTransactionChecker)
39+
40+
XCTAssertNil(childNode)
41+
}
42+
43+
func testAccountZeroCanBeDerivedAlways() async throws {
44+
let rootNode = try rootNode()
45+
46+
let childNode = try await rootNode.derive(path: "m/44'/60'/0'/0/255", warns: true, transactionChecker: mockTransactionChecker)
2447

2548
XCTAssertEqual(try XCTUnwrap(childNode).publicKey.toHexString(), "0262fba1af8f149258123265318114066decf50d16c1222a9d657b7de2296c2734")
2649
}
2750

51+
func testAccountOneWithoutTransactionsInAccountZeroWarns() async throws {
52+
do {
53+
let rootNode = try rootNode()
54+
let path = "m/44'/60'/1'/0/0"
55+
mockTransactionChecker.results = false.times(n: 20)
56+
57+
let _ = try await rootNode.derive(path: path, warns: true, transactionChecker: mockTransactionChecker)
58+
59+
XCTFail("Child must not be created usign warns true for the path: \(path)")
60+
} catch BIP44Error.warning {
61+
XCTAssertTrue(true)
62+
}
63+
}
64+
65+
func testAccountOneWithTransactionsInAccountZeroNotWarns() async throws {
66+
do {
67+
let rootNode = try rootNode()
68+
let path = "m/44'/60'/1'/0/0"
69+
var results = false.times(n: 19)
70+
results.append(true)
71+
mockTransactionChecker.results = results
72+
73+
let childNode = try await rootNode.derive(path: path, warns: true, transactionChecker: mockTransactionChecker)
74+
75+
XCTAssertEqual(try XCTUnwrap(childNode).publicKey.toHexString(), "036cd8f1bad46fa7caf7a80d48528b90db2a3b7a5c9a18d74d61b286e03850abf4")
76+
} catch BIP44Error.warning {
77+
XCTFail("BIP44Error.warning must not be thrown")
78+
}
79+
}
80+
81+
func testAccountTwoWithTransactionsInAccountZeroButNotInOneWarns() async throws {
82+
do {
83+
let rootNode = try rootNode()
84+
let path = "m/44'/60'/2'/0/0"
85+
var results: Array<Bool> = .init()
86+
results.append(true)
87+
results.append(contentsOf: false.times(n: 20))
88+
mockTransactionChecker.results = results
89+
90+
let _ = try await rootNode.derive(path: path, warns: true, transactionChecker: mockTransactionChecker)
91+
92+
XCTFail("Child must not be created usign warns true for the path: \(path)")
93+
} catch BIP44Error.warning {
94+
XCTAssertTrue(true)
95+
}
96+
}
97+
2898
// MARK: - private
2999

30100
private func rootNode() throws -> HDNode {
@@ -33,3 +103,23 @@ final class BIP44Tests: LocalTestCase {
33103
return try XCTUnwrap(HDNode(seed: seed))
34104
}
35105
}
106+
107+
extension Bool {
108+
func times(n: Int) -> Array<Bool> {
109+
var array: Array<Bool> = .init()
110+
(0..<n).forEach { _ in
111+
array.append(self)
112+
}
113+
return array
114+
}
115+
}
116+
117+
private final class MockTransactionChecker: TransactionChecker {
118+
var addresses: [String] = .init()
119+
var results: [Bool] = .init()
120+
121+
func hasTransactions(address: String) async throws -> Bool {
122+
addresses.append(address)
123+
return results.removeFirst()
124+
}
125+
}

0 commit comments

Comments
 (0)