-
Notifications
You must be signed in to change notification settings - Fork 460
Feature/BIP44 #719
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature/BIP44 #719
Changes from all commits
8d5411b
08ddc4f
43f339d
d31fb83
7cb0fb6
aa14c74
2f537c2
29cda62
48716c8
7d38122
3250d72
0842f35
b50550d
4369dcc
50f0986
73e2c1b
9e28c97
5088e51
7c9ca5f
10a3270
4783cb2
825f11d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,126 @@ | ||
// | ||
// BIP44.swift | ||
// Created by Alberto Penas Amor on 15/12/22. | ||
// | ||
|
||
import Foundation | ||
|
||
public protocol BIP44 { | ||
/** | ||
Derive an ``HDNode`` based on the provided path. The function will throw ``BIP44Error.warning`` if it was invoked with throwOnWarning equal to | ||
`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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. throwOnWarning wrap with backticks, please. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On line 12 as well. |
||
derived directly using the derive function of ``HDNode``. This function needs to query the blockchain history when throwOnWarning is `true`, so it can throw | ||
network errors. | ||
- Parameter path: valid BIP44 path. | ||
- Parameter throwOnWarning: `true` to use | ||
[Account Discovery](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#account-discovery) standard, | ||
otherwise it will dervive the key using the derive function of ``HDNode``. | ||
- Throws: ``BIP44Error.warning`` if the child key shouldn't be used according to | ||
[Account Discovery](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#account-discovery) standard. | ||
- Returns: an ``HDNode`` child key for the provided `path` if it can be created, otherwise `nil` | ||
*/ | ||
func derive(path: String, throwOnWarning: Bool, transactionChecker: TransactionChecker) async throws -> HDNode? | ||
} | ||
|
||
public enum BIP44Error: LocalizedError, Equatable { | ||
/// The selected path doesn't fulfill BIP44 standard, you can derive the root key anyway | ||
case warning | ||
albertopeam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
public var errorDescription: String? { | ||
switch self { | ||
case .warning: | ||
return "Couldn't derive key as it doesn't have a previous account with at least one transaction" | ||
} | ||
} | ||
} | ||
|
||
public protocol TransactionChecker { | ||
/** | ||
It verifies if the provided address has at least one transaction | ||
- Parameter address: to be queried | ||
- Throws: any error related to query the blockchain provider | ||
- Returns: `true` if the address has at least one transaction, `false` otherwise | ||
*/ | ||
func hasTransactions(address: String) async throws -> Bool | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's change the type of address to The fact that we implement Any thoughts are welcome! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks reasonable :) |
||
} | ||
|
||
extension HDNode: BIP44 { | ||
public func derive(path: String, throwOnWarning: Bool = true, transactionChecker: TransactionChecker) async throws -> HDNode? { | ||
guard throwOnWarning else { | ||
return derive(path: path, derivePrivateKey: true) | ||
} | ||
guard let account = path.accountFromPath else { | ||
return nil | ||
} | ||
if account == 0 { | ||
return derive(path: path, derivePrivateKey: true) | ||
} else { | ||
for searchAccount in 0..<account { | ||
let maxUnusedAddressIndexes = 20 | ||
var hasTransactions = false | ||
for searchAddressIndex in 0..<maxUnusedAddressIndexes { | ||
if let searchPath = path.newPath(account: searchAccount, addressIndex: searchAddressIndex), | ||
let childNode = derive(path: searchPath, derivePrivateKey: true), | ||
let ethAddress = Utilities.publicToAddress(childNode.publicKey) { | ||
hasTransactions = try await transactionChecker.hasTransactions(address: ethAddress.address) | ||
if hasTransactions { | ||
break | ||
albertopeam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} | ||
} | ||
if !hasTransactions { | ||
throw BIP44Error.warning | ||
} | ||
} | ||
return derive(path: path, derivePrivateKey: true) | ||
} | ||
} | ||
} | ||
|
||
extension String { | ||
/// Verifies if self matches BIP44 path | ||
var isBip44Path: Bool { | ||
do { | ||
let pattern = "^m/44'/\\d+'/\\d+'/[0-1]/\\d+$" | ||
let regex = try NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) | ||
let matches = regex.numberOfMatches(in: self, range: NSRange(location: 0, length: utf16.count)) | ||
return matches == 1 | ||
} catch { | ||
return false | ||
} | ||
} | ||
|
||
/// Returns the account from the path if self contains a well formed BIP44 path | ||
var accountFromPath: Int? { | ||
guard isBip44Path else { | ||
return nil | ||
} | ||
let components = components(separatedBy: "/") | ||
let accountIndex = 3 | ||
let rawAccount = components[accountIndex].trimmingCharacters(in: CharacterSet(charactersIn: "'")) | ||
guard let account = Int(rawAccount) else { | ||
return nil | ||
} | ||
return account | ||
} | ||
|
||
/** | ||
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` | ||
- Parameter account: the new account to use | ||
- Parameter addressIndex: the new addressIndex to use | ||
- Returns: a valid bip44 path with the provided account, addressIndex and external change or `nil` otherwise | ||
*/ | ||
func newPath(account: Int, addressIndex: Int) -> String? { | ||
guard isBip44Path else { | ||
return nil | ||
} | ||
var components = components(separatedBy: "/") | ||
let accountPosition = 3 | ||
components[accountPosition] = "\(account)'" | ||
let changePosition = 4 | ||
components[changePosition] = "0" | ||
let addressIndexPosition = 5 | ||
components[addressIndexPosition] = "\(addressIndex)" | ||
return components.joined(separator: "/") | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
// | ||
// EtherscanTransactionChecker.swift | ||
// Created by albertopeam on 28/12/22. | ||
// | ||
|
||
import Foundation | ||
|
||
public struct EtherscanTransactionChecker: TransactionChecker { | ||
albertopeam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
private let urlSession: URLSessionProxy | ||
private let apiKey: String | ||
|
||
public init(urlSession: URLSession, apiKey: String) { | ||
self.urlSession = URLSessionProxyImplementation(urlSession: urlSession) | ||
self.apiKey = apiKey | ||
} | ||
|
||
internal init(urlSession: URLSessionProxy, apiKey: String) { | ||
self.urlSession = urlSession | ||
self.apiKey = apiKey | ||
} | ||
|
||
public func hasTransactions(address: String) async throws -> Bool { | ||
let urlString = "https://api.etherscan.io/api?module=account&action=txlist&address=\(address)&startblock=0&page=1&offset=1&sort=asc&apikey=\(apiKey)" | ||
guard let url = URL(string: urlString) else { | ||
throw EtherscanTransactionCheckerError.invalidUrl(url: urlString) | ||
} | ||
let request = URLRequest(url: url) | ||
let result = try await urlSession.data(for: request) | ||
let response = try JSONDecoder().decode(Response.self, from: result.0) | ||
return !response.result.isEmpty | ||
} | ||
} | ||
|
||
extension EtherscanTransactionChecker { | ||
struct Response: Codable { | ||
let result: [Transaction] | ||
} | ||
struct Transaction: Codable {} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you add at least a field like I guess it is something like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apologies, I had no time yesterday. |
||
} | ||
|
||
public enum EtherscanTransactionCheckerError: LocalizedError, Equatable { | ||
case invalidUrl(url: String) | ||
|
||
public var errorDescription: String? { | ||
switch self { | ||
case let .invalidUrl(url): | ||
return "Couldn't create URL(string: \(url))" | ||
} | ||
} | ||
} | ||
|
||
internal protocol URLSessionProxy { | ||
func data(for request: URLRequest) async throws -> (Data, URLResponse) | ||
} | ||
|
||
internal struct URLSessionProxyImplementation: URLSessionProxy { | ||
let urlSession: URLSession | ||
|
||
func data(for request: URLRequest) async throws -> (Data, URLResponse) { | ||
try await urlSession.data(for: request) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,199 @@ | ||
// | ||
// BIP44Tests.swift | ||
// Created by Alberto Penas Amor on 15/12/22. | ||
// | ||
|
||
import XCTest | ||
import Web3Core | ||
@testable import web3swift | ||
|
||
final class BIP44Tests: XCTestCase { | ||
private var accountZeroScannedAddresses: [String] { | ||
[ | ||
"0x31a4aD7593D06D049b3Cc07aB5430264Bf7e069f", | ||
"0x2b4fb04d485446ade5889e77b0cbC2c71075209c", | ||
"0x93DDC6583D4BF6e9b309cfBdC681A78F8B5f37Ff", | ||
"0xab2bBC1392f957F7A5DDCE89b64f30064D39C08b", | ||
"0x5Ae1794fFD14bebF34e0BA65815dF9DCB0FD11a8", | ||
"0x4894C017C7fEfB53A9dc3Cf707d098EBCFD8BdF1", | ||
"0x29cC28Cd30e21e73B51389792453818DaCe33f65", | ||
"0x6B3cB8CFBC89ab7A1D9Ccb53537020c53dD4f6E0", | ||
"0xD5FD55fcB93a47Ef176062ac8265E28A5f09887D", | ||
"0xa8A99549A522aF52a2050e081100ef3D42228B55", | ||
"0x2007f83D32cd82b013b9d0d33Ac9e5Ae725367C5", | ||
"0x80a9A6Dd42D67Dd2EEC5c3D6568Fd16e7c964948", | ||
"0xC7781cd86F6336CfE56Fc243f1a9544595dC984E", | ||
"0x7E3eDEB0201D5A5cAF2b50749a7C7843374c312F", | ||
"0x800853194B31Bf5D621Be0b402E8c2b3b402a2Ed", | ||
"0x73BE98d0a3702E8279ca087B2564b6977389C242", | ||
"0x3eFC4765C5BaB65947864fDf4669b7fb8073d89B", | ||
"0xd521A57ea2bAA6396AE916aD2bC4972a9b3635EB", | ||
"0x561192570145C499f0951dEc0a4Df80D0D0A96bb", | ||
"0x4DdBe17BB1b0056941A1425739978e44D462D7DD"] | ||
} | ||
private var accountZeroAndOneScannedAddresses: [String] { | ||
[ | ||
"0x31a4aD7593D06D049b3Cc07aB5430264Bf7e069f", | ||
"0x3C7b0FadC415d0be5EBa971DC7Dcc39DdDcd4AF7", | ||
"0x73C13e421eF367c4F55BBC02a8e2a2b12e82f717", | ||
"0xE9D8f89452CF0a0d501B9C798cE696C3a1BAE535", | ||
"0x662e78FD3C77A9B8e693f5DC75398C9c0E7233a6", | ||
"0xBEDF61A3466b40f2591702c91cF888843C81e576", | ||
"0xb406aD2666D36716a847c27BAA6d742ECdA85F23", | ||
"0x069c7bF73d17aeb7b8Ff490177A6eefB7aCcb4a8", | ||
"0xa9dbD111007cAfF0804b98195F7f9231bcBEdf86", | ||
"0x2DDDf0447Eb85ae4B16815B010a7007cd30f0A64", | ||
"0x35ff1f3dcb02B6F137A654a419bFb66FE74dFDFE", | ||
"0xd3A77dE492A58386129546469D0E3D3C67Dd520E", | ||
"0x1c011fEfb24210EB1415DD87C161591f5040d71A", | ||
"0x6C289DCE390863ed58bBd56948950f4D96c7Ab8f", | ||
"0xbB13176bf7571D15E1600077F4da6eD22075676b", | ||
"0x618c1ddD96a3Dc2Bd1E90F7053bCc48986A412f7", | ||
"0x5220836980697693fE2137b64e545f926856feAe", | ||
"0xC49D7d886CA02C438c413ceabE6C1f8138ED6ef8", | ||
"0x049e9466CD2417A615e98DD7233eeec4Fcf5632D", | ||
"0x111FbB56b0B5c97F2896Ee722A917b261bCC77fC", | ||
"0xF3F66e5C119620eBDbD7Fb48B4b5d365De5c9750"] | ||
} | ||
private var mockTransactionChecker: MockTransactionChecker = .init() | ||
|
||
func testDeriveWithoutThrowOnWarning() async throws { | ||
let rootNode = try rootNode() | ||
|
||
let childNode = try await rootNode.derive(path: "m/44'/60'/8096'/0/1", throwOnWarning: false, transactionChecker: mockTransactionChecker) | ||
|
||
XCTAssertEqual(try XCTUnwrap(childNode).publicKey.toHexString(), "035785d4918449c87892371c0f9ccf6e4eda40a7fb0f773f1254c064d3bba64026") | ||
XCTAssertEqual(mockTransactionChecker.addresses.count, 0) | ||
} | ||
|
||
func testDeriveInvalidPath() async throws { | ||
let rootNode = try rootNode() | ||
|
||
let childNode = try? await rootNode.derive(path: "", throwOnWarning: true, transactionChecker: mockTransactionChecker) | ||
|
||
XCTAssertNil(childNode) | ||
XCTAssertEqual(mockTransactionChecker.addresses.count, 0) | ||
} | ||
|
||
// MARK: - address | ||
|
||
func testZeroAccountNeverThrow() async throws { | ||
let rootNode = try rootNode() | ||
|
||
let childNode = try await rootNode.derive(path: "m/44'/60'/0'/0/255", throwOnWarning: true, transactionChecker: mockTransactionChecker) | ||
|
||
XCTAssertEqual(try XCTUnwrap(childNode).publicKey.toHexString(), "0262fba1af8f149258123265318114066decf50d16c1222a9d657b7de2296c2734") | ||
XCTAssertEqual(mockTransactionChecker.addresses.count, 0) | ||
} | ||
|
||
func testFirstAccountWithNoPreviousTransactionHistory() async throws { | ||
do { | ||
let rootNode = try rootNode() | ||
let path = "m/44'/60'/1'/0/0" | ||
var results = false.times(n: 20) | ||
results.append(true) | ||
mockTransactionChecker.results = results | ||
|
||
_ = try await rootNode.derive(path: path, throwOnWarning: true, transactionChecker: mockTransactionChecker) | ||
|
||
XCTFail("Child must not be created using throwOnWarning true for the path: \(path)") | ||
} catch BIP44Error.warning { | ||
XCTAssertEqual(mockTransactionChecker.addresses, accountZeroScannedAddresses) | ||
} | ||
} | ||
|
||
func testFirstAccountWithPreviousTransactionHistory() async throws { | ||
do { | ||
let rootNode = try rootNode() | ||
let path = "m/44'/60'/1'/0/0" | ||
var results = false.times(n: 19) | ||
results.append(true) | ||
mockTransactionChecker.results = results | ||
|
||
let childNode = try await rootNode.derive(path: path, throwOnWarning: true, transactionChecker: mockTransactionChecker) | ||
|
||
XCTAssertEqual(try XCTUnwrap(childNode).publicKey.toHexString(), "036cd8f1bad46fa7caf7a80d48528b90db2a3b7a5c9a18d74d61b286e03850abf4") | ||
XCTAssertEqual(mockTransactionChecker.addresses, accountZeroScannedAddresses) | ||
} catch BIP44Error.warning { | ||
XCTFail("BIP44Error.warning must not be thrown") | ||
} | ||
} | ||
|
||
func testSecondAccountWithNoPreviousTransactionHistory() async throws { | ||
do { | ||
let rootNode = try rootNode() | ||
let path = "m/44'/60'/2'/0/0" | ||
var results: [Bool] = .init() | ||
results.append(true) | ||
results.append(contentsOf: false.times(n: 20)) | ||
mockTransactionChecker.results = results | ||
|
||
_ = try await rootNode.derive(path: path, throwOnWarning: true, transactionChecker: mockTransactionChecker) | ||
|
||
XCTFail("Child must not be created using throwOnWarning true for the path: \(path)") | ||
} catch BIP44Error.warning { | ||
XCTAssertEqual(mockTransactionChecker.addresses, accountZeroAndOneScannedAddresses) | ||
XCTAssertEqual(mockTransactionChecker.addresses.count, 21) | ||
} | ||
} | ||
|
||
// MARK: - change + addressIndex | ||
|
||
func testNotZeroChangeAndAddressIndexWithPreviousTransactionHistory() async throws { | ||
do { | ||
let rootNode = try rootNode() | ||
let path = "m/44'/60'/1'/1/128" | ||
var results = false.times(n: 19) | ||
results.append(true) | ||
mockTransactionChecker.results = results | ||
|
||
let childNode = try await rootNode.derive(path: path, throwOnWarning: true, transactionChecker: mockTransactionChecker) | ||
|
||
XCTAssertEqual(try XCTUnwrap(childNode).publicKey.toHexString(), "0282134e44d4c040a4b4c1a780d8302955096cf1d5e738b161c83f0ce1b863c14e") | ||
XCTAssertEqual(mockTransactionChecker.addresses, accountZeroScannedAddresses) | ||
} catch BIP44Error.warning { | ||
XCTFail("BIP44Error.warning must not be thrown") | ||
} | ||
} | ||
|
||
// MARK: - private | ||
|
||
private func rootNode() throws -> HDNode { | ||
let mnemonic = "fruit wave dwarf banana earth journey tattoo true farm silk olive fence" | ||
let seed = try XCTUnwrap(BIP39.seedFromMmemonics(mnemonic, password: "")) | ||
return try XCTUnwrap(HDNode(seed: seed)) | ||
} | ||
} | ||
|
||
// MARK: - BIP44ErrorTests | ||
|
||
final class BIP44ErrorTests: XCTestCase { | ||
func testLocalizedDescription() { | ||
let error = BIP44Error.warning | ||
XCTAssertEqual(error.localizedDescription, "Couldn't derive key as it doesn't have a previous account with at least one transaction") | ||
} | ||
} | ||
|
||
// MARK: - helper | ||
|
||
private extension Bool { | ||
func times(n: Int) -> [Bool] { | ||
var array: [Bool] = .init() | ||
(0..<n).forEach { _ in | ||
array.append(self) | ||
} | ||
return array | ||
} | ||
} | ||
|
||
// MARK: - test double | ||
|
||
private final class MockTransactionChecker: TransactionChecker { | ||
var addresses: [String] = .init() | ||
var results: [Bool] = .init() | ||
|
||
func hasTransactions(address: String) async throws -> Bool { | ||
addresses.append(address) | ||
return results.removeFirst() | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please, wrap throwOnWarning into backticks so that it stands out when reading this as compiled documentation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll highlight such issues as I go.