Skip to content
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

Merged
merged 22 commits into from Jan 19, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8d5411b
added BI44 protocol and direct derive without checking blockchain his…
albertopeam Dec 17, 2022
08ddc4f
added string utils to obtain the external address of a path
albertopeam Dec 18, 2022
43f339d
added string utils to extract the account from a bip44 path
albertopeam Dec 19, 2022
d31fb83
added derive without warns, added skip discovery if account is zero
albertopeam Dec 20, 2022
7cb0fb6
added string extension function to obtain a new path with new account…
albertopeam Dec 20, 2022
aa14c74
added TransactionChecker protocol and implement logic to verify that …
albertopeam Dec 25, 2022
2f537c2
added unit test + assertions
albertopeam Dec 27, 2022
29cda62
added EtherscanTransactionChecker
albertopeam Dec 28, 2022
48716c8
removed swiftlint warnings
albertopeam Dec 28, 2022
7d38122
removed swiftlint issues
albertopeam Jan 3, 2023
3250d72
improved doc, code, naming and implement LocalizedError
albertopeam Jan 14, 2023
0842f35
fixed typo
albertopeam Jan 14, 2023
b50550d
removed extra space
albertopeam Jan 14, 2023
4369dcc
make test util extension as private
albertopeam Jan 14, 2023
50f0986
replaced BIP44Error inheritance from Error to LocalizedError
albertopeam Jan 14, 2023
73e2c1b
added some MARK
albertopeam Jan 15, 2023
9e28c97
EtherscanTransactionCheckerError conforms to LicalizedError, changed …
albertopeam Jan 15, 2023
5088e51
improved test names, removed unuseful asserts
albertopeam Jan 15, 2023
7c9ca5f
replaced throwOnError with throwOnWarning
albertopeam Jan 15, 2023
10a3270
BIP44 doc break at 120 columns
albertopeam Jan 16, 2023
4783cb2
remove swiftlint warnings
albertopeam Jan 16, 2023
825f11d
removed import _Concurrency
albertopeam Jan 17, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
120 changes: 120 additions & 0 deletions Sources/Web3Core/KeystoreManager/BIP44.swift
@@ -0,0 +1,120 @@
//
// 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 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.
albertopeam marked this conversation as resolved.
Show resolved Hide resolved
- 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
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's change the type of address to EthereumAddress.
This way we reduce one potential error - an invalid ethereum address.

The fact that we implement TransactionChecker and before we call hasTransactions function we check that the address is valid we do not provide safety for others if they want to implement a version of TransactionChecker themselves.

Any thoughts are welcome!

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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: "/")
}
}
63 changes: 63 additions & 0 deletions Sources/Web3Core/KeystoreManager/EtherscanTransactionChecker.swift
@@ -0,0 +1,63 @@
//
// EtherscanTransactionChecker.swift
// Created by albertopeam on 28/12/22.
//

import Foundation
import _Concurrency
albertopeam marked this conversation as resolved.
Show resolved Hide resolved

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 {}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could you add at least a field like hash to the Transaction struct?
Or maybe a swift doc to that struct explaining why it's empty to make sure we won't spend time in the future thinking too much about why it's empty. 🙂

I guess it is something like We are not interested in the contents of the transaction object but rather the availability of the transaction per see for the sake of counting the number of transactions for a specific account..

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
}
}
199 changes: 199 additions & 0 deletions Tests/web3swiftTests/localTests/BIP44Tests.swift
@@ -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()
}
}