Skip to content

Commit 50d0d83

Browse files
Merge pull request #719 from albertopeam/feature/add-bip-44
feature: BIP44 implementation
2 parents 81bbd4a + 825f11d commit 50d0d83

File tree

5 files changed

+585
-0
lines changed

5 files changed

+585
-0
lines changed

Diff for: Sources/Web3Core/KeystoreManager/BIP44.swift

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
//
2+
// BIP44.swift
3+
// Created by Alberto Penas Amor on 15/12/22.
4+
//
5+
6+
import Foundation
7+
8+
public protocol BIP44 {
9+
/**
10+
Derive an ``HDNode`` based on the provided path. The function will throw ``BIP44Error.warning`` if it was invoked with throwOnWarning equal to
11+
`true` and the root key doesn't have a previous child with at least one transaction. If it is invoked with throwOnError equal to `false` the child node will be
12+
derived directly using the derive function of ``HDNode``. This function needs to query the blockchain history when throwOnWarning is `true`, so it can throw
13+
network errors.
14+
- Parameter path: valid BIP44 path.
15+
- Parameter throwOnWarning: `true` to use
16+
[Account Discovery](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#account-discovery) standard,
17+
otherwise it will dervive the key using the derive function of ``HDNode``.
18+
- Throws: ``BIP44Error.warning`` if the child key shouldn't be used according to
19+
[Account Discovery](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#account-discovery) standard.
20+
- Returns: an ``HDNode`` child key for the provided `path` if it can be created, otherwise `nil`
21+
*/
22+
func derive(path: String, throwOnWarning: Bool, transactionChecker: TransactionChecker) async throws -> HDNode?
23+
}
24+
25+
public enum BIP44Error: LocalizedError, Equatable {
26+
/// The selected path doesn't fulfill BIP44 standard, you can derive the root key anyway
27+
case warning
28+
29+
public var errorDescription: String? {
30+
switch self {
31+
case .warning:
32+
return "Couldn't derive key as it doesn't have a previous account with at least one transaction"
33+
}
34+
}
35+
}
36+
37+
public protocol TransactionChecker {
38+
/**
39+
It verifies if the provided address has at least one transaction
40+
- Parameter address: to be queried
41+
- Throws: any error related to query the blockchain provider
42+
- Returns: `true` if the address has at least one transaction, `false` otherwise
43+
*/
44+
func hasTransactions(address: String) async throws -> Bool
45+
}
46+
47+
extension HDNode: BIP44 {
48+
public func derive(path: String, throwOnWarning: Bool = true, transactionChecker: TransactionChecker) async throws -> HDNode? {
49+
guard throwOnWarning else {
50+
return derive(path: path, derivePrivateKey: true)
51+
}
52+
guard let account = path.accountFromPath else {
53+
return nil
54+
}
55+
if account == 0 {
56+
return derive(path: path, derivePrivateKey: true)
57+
} else {
58+
for searchAccount in 0..<account {
59+
let maxUnusedAddressIndexes = 20
60+
var hasTransactions = false
61+
for searchAddressIndex in 0..<maxUnusedAddressIndexes {
62+
if let searchPath = path.newPath(account: searchAccount, addressIndex: searchAddressIndex),
63+
let childNode = derive(path: searchPath, derivePrivateKey: true),
64+
let ethAddress = Utilities.publicToAddress(childNode.publicKey) {
65+
hasTransactions = try await transactionChecker.hasTransactions(address: ethAddress.address)
66+
if hasTransactions {
67+
break
68+
}
69+
}
70+
}
71+
if !hasTransactions {
72+
throw BIP44Error.warning
73+
}
74+
}
75+
return derive(path: path, derivePrivateKey: true)
76+
}
77+
}
78+
}
79+
80+
extension String {
81+
/// Verifies if self matches BIP44 path
82+
var isBip44Path: Bool {
83+
do {
84+
let pattern = "^m/44'/\\d+'/\\d+'/[0-1]/\\d+$"
85+
let regex = try NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
86+
let matches = regex.numberOfMatches(in: self, range: NSRange(location: 0, length: utf16.count))
87+
return matches == 1
88+
} catch {
89+
return false
90+
}
91+
}
92+
93+
/// Returns the account from the path if self contains a well formed BIP44 path
94+
var accountFromPath: Int? {
95+
guard isBip44Path else {
96+
return nil
97+
}
98+
let components = components(separatedBy: "/")
99+
let accountIndex = 3
100+
let rawAccount = components[accountIndex].trimmingCharacters(in: CharacterSet(charactersIn: "'"))
101+
guard let account = Int(rawAccount) else {
102+
return nil
103+
}
104+
return account
105+
}
106+
107+
/**
108+
Transforms a bip44 path into a new one changing account & index. The resulting one will have the change value equal to `0` to represent the external chain. The format will be `m/44'/coin_type'/account'/change/address_index`
109+
- Parameter account: the new account to use
110+
- Parameter addressIndex: the new addressIndex to use
111+
- Returns: a valid bip44 path with the provided account, addressIndex and external change or `nil` otherwise
112+
*/
113+
func newPath(account: Int, addressIndex: Int) -> String? {
114+
guard isBip44Path else {
115+
return nil
116+
}
117+
var components = components(separatedBy: "/")
118+
let accountPosition = 3
119+
components[accountPosition] = "\(account)'"
120+
let changePosition = 4
121+
components[changePosition] = "0"
122+
let addressIndexPosition = 5
123+
components[addressIndexPosition] = "\(addressIndex)"
124+
return components.joined(separator: "/")
125+
}
126+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
//
2+
// EtherscanTransactionChecker.swift
3+
// Created by albertopeam on 28/12/22.
4+
//
5+
6+
import Foundation
7+
8+
public struct EtherscanTransactionChecker: TransactionChecker {
9+
private let urlSession: URLSessionProxy
10+
private let apiKey: String
11+
12+
public init(urlSession: URLSession, apiKey: String) {
13+
self.urlSession = URLSessionProxyImplementation(urlSession: urlSession)
14+
self.apiKey = apiKey
15+
}
16+
17+
internal init(urlSession: URLSessionProxy, apiKey: String) {
18+
self.urlSession = urlSession
19+
self.apiKey = apiKey
20+
}
21+
22+
public func hasTransactions(address: String) async throws -> Bool {
23+
let urlString = "https://api.etherscan.io/api?module=account&action=txlist&address=\(address)&startblock=0&page=1&offset=1&sort=asc&apikey=\(apiKey)"
24+
guard let url = URL(string: urlString) else {
25+
throw EtherscanTransactionCheckerError.invalidUrl(url: urlString)
26+
}
27+
let request = URLRequest(url: url)
28+
let result = try await urlSession.data(for: request)
29+
let response = try JSONDecoder().decode(Response.self, from: result.0)
30+
return !response.result.isEmpty
31+
}
32+
}
33+
34+
extension EtherscanTransactionChecker {
35+
struct Response: Codable {
36+
let result: [Transaction]
37+
}
38+
struct Transaction: Codable {}
39+
}
40+
41+
public enum EtherscanTransactionCheckerError: LocalizedError, Equatable {
42+
case invalidUrl(url: String)
43+
44+
public var errorDescription: String? {
45+
switch self {
46+
case let .invalidUrl(url):
47+
return "Couldn't create URL(string: \(url))"
48+
}
49+
}
50+
}
51+
52+
internal protocol URLSessionProxy {
53+
func data(for request: URLRequest) async throws -> (Data, URLResponse)
54+
}
55+
56+
internal struct URLSessionProxyImplementation: URLSessionProxy {
57+
let urlSession: URLSession
58+
59+
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
60+
try await urlSession.data(for: request)
61+
}
62+
}

Diff for: Tests/web3swiftTests/localTests/BIP44Tests.swift

+199
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
//
2+
// BIP44Tests.swift
3+
// Created by Alberto Penas Amor on 15/12/22.
4+
//
5+
6+
import XCTest
7+
import Web3Core
8+
@testable import web3swift
9+
10+
final class BIP44Tests: XCTestCase {
11+
private var accountZeroScannedAddresses: [String] {
12+
[
13+
"0x31a4aD7593D06D049b3Cc07aB5430264Bf7e069f",
14+
"0x2b4fb04d485446ade5889e77b0cbC2c71075209c",
15+
"0x93DDC6583D4BF6e9b309cfBdC681A78F8B5f37Ff",
16+
"0xab2bBC1392f957F7A5DDCE89b64f30064D39C08b",
17+
"0x5Ae1794fFD14bebF34e0BA65815dF9DCB0FD11a8",
18+
"0x4894C017C7fEfB53A9dc3Cf707d098EBCFD8BdF1",
19+
"0x29cC28Cd30e21e73B51389792453818DaCe33f65",
20+
"0x6B3cB8CFBC89ab7A1D9Ccb53537020c53dD4f6E0",
21+
"0xD5FD55fcB93a47Ef176062ac8265E28A5f09887D",
22+
"0xa8A99549A522aF52a2050e081100ef3D42228B55",
23+
"0x2007f83D32cd82b013b9d0d33Ac9e5Ae725367C5",
24+
"0x80a9A6Dd42D67Dd2EEC5c3D6568Fd16e7c964948",
25+
"0xC7781cd86F6336CfE56Fc243f1a9544595dC984E",
26+
"0x7E3eDEB0201D5A5cAF2b50749a7C7843374c312F",
27+
"0x800853194B31Bf5D621Be0b402E8c2b3b402a2Ed",
28+
"0x73BE98d0a3702E8279ca087B2564b6977389C242",
29+
"0x3eFC4765C5BaB65947864fDf4669b7fb8073d89B",
30+
"0xd521A57ea2bAA6396AE916aD2bC4972a9b3635EB",
31+
"0x561192570145C499f0951dEc0a4Df80D0D0A96bb",
32+
"0x4DdBe17BB1b0056941A1425739978e44D462D7DD"]
33+
}
34+
private var accountZeroAndOneScannedAddresses: [String] {
35+
[
36+
"0x31a4aD7593D06D049b3Cc07aB5430264Bf7e069f",
37+
"0x3C7b0FadC415d0be5EBa971DC7Dcc39DdDcd4AF7",
38+
"0x73C13e421eF367c4F55BBC02a8e2a2b12e82f717",
39+
"0xE9D8f89452CF0a0d501B9C798cE696C3a1BAE535",
40+
"0x662e78FD3C77A9B8e693f5DC75398C9c0E7233a6",
41+
"0xBEDF61A3466b40f2591702c91cF888843C81e576",
42+
"0xb406aD2666D36716a847c27BAA6d742ECdA85F23",
43+
"0x069c7bF73d17aeb7b8Ff490177A6eefB7aCcb4a8",
44+
"0xa9dbD111007cAfF0804b98195F7f9231bcBEdf86",
45+
"0x2DDDf0447Eb85ae4B16815B010a7007cd30f0A64",
46+
"0x35ff1f3dcb02B6F137A654a419bFb66FE74dFDFE",
47+
"0xd3A77dE492A58386129546469D0E3D3C67Dd520E",
48+
"0x1c011fEfb24210EB1415DD87C161591f5040d71A",
49+
"0x6C289DCE390863ed58bBd56948950f4D96c7Ab8f",
50+
"0xbB13176bf7571D15E1600077F4da6eD22075676b",
51+
"0x618c1ddD96a3Dc2Bd1E90F7053bCc48986A412f7",
52+
"0x5220836980697693fE2137b64e545f926856feAe",
53+
"0xC49D7d886CA02C438c413ceabE6C1f8138ED6ef8",
54+
"0x049e9466CD2417A615e98DD7233eeec4Fcf5632D",
55+
"0x111FbB56b0B5c97F2896Ee722A917b261bCC77fC",
56+
"0xF3F66e5C119620eBDbD7Fb48B4b5d365De5c9750"]
57+
}
58+
private var mockTransactionChecker: MockTransactionChecker = .init()
59+
60+
func testDeriveWithoutThrowOnWarning() async throws {
61+
let rootNode = try rootNode()
62+
63+
let childNode = try await rootNode.derive(path: "m/44'/60'/8096'/0/1", throwOnWarning: false, transactionChecker: mockTransactionChecker)
64+
65+
XCTAssertEqual(try XCTUnwrap(childNode).publicKey.toHexString(), "035785d4918449c87892371c0f9ccf6e4eda40a7fb0f773f1254c064d3bba64026")
66+
XCTAssertEqual(mockTransactionChecker.addresses.count, 0)
67+
}
68+
69+
func testDeriveInvalidPath() async throws {
70+
let rootNode = try rootNode()
71+
72+
let childNode = try? await rootNode.derive(path: "", throwOnWarning: true, transactionChecker: mockTransactionChecker)
73+
74+
XCTAssertNil(childNode)
75+
XCTAssertEqual(mockTransactionChecker.addresses.count, 0)
76+
}
77+
78+
// MARK: - address
79+
80+
func testZeroAccountNeverThrow() async throws {
81+
let rootNode = try rootNode()
82+
83+
let childNode = try await rootNode.derive(path: "m/44'/60'/0'/0/255", throwOnWarning: true, transactionChecker: mockTransactionChecker)
84+
85+
XCTAssertEqual(try XCTUnwrap(childNode).publicKey.toHexString(), "0262fba1af8f149258123265318114066decf50d16c1222a9d657b7de2296c2734")
86+
XCTAssertEqual(mockTransactionChecker.addresses.count, 0)
87+
}
88+
89+
func testFirstAccountWithNoPreviousTransactionHistory() async throws {
90+
do {
91+
let rootNode = try rootNode()
92+
let path = "m/44'/60'/1'/0/0"
93+
var results = false.times(n: 20)
94+
results.append(true)
95+
mockTransactionChecker.results = results
96+
97+
_ = try await rootNode.derive(path: path, throwOnWarning: true, transactionChecker: mockTransactionChecker)
98+
99+
XCTFail("Child must not be created using throwOnWarning true for the path: \(path)")
100+
} catch BIP44Error.warning {
101+
XCTAssertEqual(mockTransactionChecker.addresses, accountZeroScannedAddresses)
102+
}
103+
}
104+
105+
func testFirstAccountWithPreviousTransactionHistory() async throws {
106+
do {
107+
let rootNode = try rootNode()
108+
let path = "m/44'/60'/1'/0/0"
109+
var results = false.times(n: 19)
110+
results.append(true)
111+
mockTransactionChecker.results = results
112+
113+
let childNode = try await rootNode.derive(path: path, throwOnWarning: true, transactionChecker: mockTransactionChecker)
114+
115+
XCTAssertEqual(try XCTUnwrap(childNode).publicKey.toHexString(), "036cd8f1bad46fa7caf7a80d48528b90db2a3b7a5c9a18d74d61b286e03850abf4")
116+
XCTAssertEqual(mockTransactionChecker.addresses, accountZeroScannedAddresses)
117+
} catch BIP44Error.warning {
118+
XCTFail("BIP44Error.warning must not be thrown")
119+
}
120+
}
121+
122+
func testSecondAccountWithNoPreviousTransactionHistory() async throws {
123+
do {
124+
let rootNode = try rootNode()
125+
let path = "m/44'/60'/2'/0/0"
126+
var results: [Bool] = .init()
127+
results.append(true)
128+
results.append(contentsOf: false.times(n: 20))
129+
mockTransactionChecker.results = results
130+
131+
_ = try await rootNode.derive(path: path, throwOnWarning: true, transactionChecker: mockTransactionChecker)
132+
133+
XCTFail("Child must not be created using throwOnWarning true for the path: \(path)")
134+
} catch BIP44Error.warning {
135+
XCTAssertEqual(mockTransactionChecker.addresses, accountZeroAndOneScannedAddresses)
136+
XCTAssertEqual(mockTransactionChecker.addresses.count, 21)
137+
}
138+
}
139+
140+
// MARK: - change + addressIndex
141+
142+
func testNotZeroChangeAndAddressIndexWithPreviousTransactionHistory() async throws {
143+
do {
144+
let rootNode = try rootNode()
145+
let path = "m/44'/60'/1'/1/128"
146+
var results = false.times(n: 19)
147+
results.append(true)
148+
mockTransactionChecker.results = results
149+
150+
let childNode = try await rootNode.derive(path: path, throwOnWarning: true, transactionChecker: mockTransactionChecker)
151+
152+
XCTAssertEqual(try XCTUnwrap(childNode).publicKey.toHexString(), "0282134e44d4c040a4b4c1a780d8302955096cf1d5e738b161c83f0ce1b863c14e")
153+
XCTAssertEqual(mockTransactionChecker.addresses, accountZeroScannedAddresses)
154+
} catch BIP44Error.warning {
155+
XCTFail("BIP44Error.warning must not be thrown")
156+
}
157+
}
158+
159+
// MARK: - private
160+
161+
private func rootNode() throws -> HDNode {
162+
let mnemonic = "fruit wave dwarf banana earth journey tattoo true farm silk olive fence"
163+
let seed = try XCTUnwrap(BIP39.seedFromMmemonics(mnemonic, password: ""))
164+
return try XCTUnwrap(HDNode(seed: seed))
165+
}
166+
}
167+
168+
// MARK: - BIP44ErrorTests
169+
170+
final class BIP44ErrorTests: XCTestCase {
171+
func testLocalizedDescription() {
172+
let error = BIP44Error.warning
173+
XCTAssertEqual(error.localizedDescription, "Couldn't derive key as it doesn't have a previous account with at least one transaction")
174+
}
175+
}
176+
177+
// MARK: - helper
178+
179+
private extension Bool {
180+
func times(n: Int) -> [Bool] {
181+
var array: [Bool] = .init()
182+
(0..<n).forEach { _ in
183+
array.append(self)
184+
}
185+
return array
186+
}
187+
}
188+
189+
// MARK: - test double
190+
191+
private final class MockTransactionChecker: TransactionChecker {
192+
var addresses: [String] = .init()
193+
var results: [Bool] = .init()
194+
195+
func hasTransactions(address: String) async throws -> Bool {
196+
addresses.append(address)
197+
return results.removeFirst()
198+
}
199+
}

0 commit comments

Comments
 (0)