Skip to content

Commit 9538d65

Browse files
vws unstoppable support
1 parent fd12dac commit 9538d65

13 files changed

Lines changed: 816 additions & 94 deletions

Sources/Components/Inputs/CurrencyInput.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ class CurrencyInput: UITextField {
5858
}
5959

6060
public func setAmount(_ amount: NSNumber) {
61-
text = amount.toBlankCurrency(fractDigits: Constants.maximumFractionDigits)
61+
let safeAmount = amount.doubleValue.isFinite ? amount : NSNumber(value: 0.0)
62+
63+
text = safeAmount.toBlankCurrency(fractDigits: Constants.maximumFractionDigits)
6264
text = text?.replacingOccurrences(of: " ", with: "")
6365

6466
format()

Sources/Extensions/NSNumber+Currency.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ extension NSNumber {
1717
}
1818

1919
func toCurrency(currency: String? = nil, fractDigits: Int = 2, floating: Bool = true) -> String {
20+
guard doubleValue.isFinite else {
21+
return currency == "XVG" ? "0 XVG" : "0"
22+
}
23+
2024
let formatter = NumberFormatter()
2125
formatter.numberStyle = .currency
2226
if floating == false {
@@ -33,7 +37,7 @@ extension NSNumber {
3337
}
3438

3539
// Remove extra symbol space
36-
return "\(formatter.string(from: self)!)\(suffix)"
40+
return "\(formatter.string(from: self) ?? "0")\(suffix)"
3741
}
3842

3943
func toPairCurrency(fractDigits: Int = 2) -> String {
@@ -63,6 +67,10 @@ extension NSNumber {
6367
}
6468

6569
func toBlankCurrency(fractDigits: Int = 2, floating: Bool = true) -> String {
70+
guard doubleValue.isFinite else {
71+
return "0"
72+
}
73+
6674
let formatter = NumberFormatter()
6775
formatter.numberStyle = .currency
6876
if floating == false { // Freeze fractDigits number
@@ -73,6 +81,6 @@ extension NSNumber {
7381
formatter.currencySymbol = ""
7482

7583
// Remove extra symbol space
76-
return "\(formatter.string(from: self)!)"
84+
return formatter.string(from: self) ?? "0"
7785
}
7886
}

Sources/Providers/WalletServiceProvider.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class WalletServiceProvider: ServiceProvider {
2121
registerWalletClient() // Depends on Credentials & HttpSession
2222
registerTransactionRepository()
2323
registerTransactionFactory() // Depends on RatesClient & ApplicationRepository
24+
registerTxTransponder()
2425
registerTransactionManager()
2526
registerWalletTicker() // Depends on WalletClient & TransactionManager
2627
registerFiatRateTicker() // Depends on RatesClient & ApplicationRepository
@@ -52,6 +53,12 @@ class WalletServiceProvider: ServiceProvider {
5253
}
5354
}
5455

56+
func registerTxTransponder() {
57+
container.register(TxTransponderProtocol.self) { r in
58+
TxTransponder(walletClient: r.resolve(WalletClientProtocol.self)!)
59+
}
60+
}
61+
5562
// MARK: - Transaction Manager
5663
func registerTransactionManager() {
5764
container.register(TransactionManager.self) { r in

Sources/Wallet/AddressValidator.swift

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,44 @@ class AddressValidator {
2020
_ currency: String?
2121
) -> Void
2222

23-
23+
enum ResolutionError: Error {
24+
case missingApiToken
25+
case httpStatus(Int)
26+
case emptyResponse
27+
case noRecords
28+
case recordNotFound
29+
case invalidJson
30+
case invalidResolvedAddress
31+
}
32+
33+
private enum UnstoppableDomains {
34+
static let apiBaseUrl = "https://api.unstoppabledomains.com/resolve/domains/"
35+
static let recordKey = "crypto.XVG.address"
36+
37+
static var apiToken: String? {
38+
return Bundle.main.object(forInfoDictionaryKey: "UNSTOPPABLE_DOMAINS_API_TOKEN") as? String
39+
}
40+
41+
static func looksLikeDomain(_ value: String) -> Bool {
42+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
43+
44+
return trimmed.firstIndex(of: ".") != nil
45+
&& trimmed.firstIndex(of: ".") != trimmed.startIndex
46+
&& !trimmed.contains(" ")
47+
&& !trimmed.contains("://")
48+
&& !trimmed.contains("/")
49+
}
50+
}
51+
52+
private struct UnstoppableDomainsResponse: Decodable {
53+
let records: [String: String]?
54+
}
55+
2456
static func validate(address: String) -> Bool {
57+
if validateVergeLegacy(address: address) {
58+
return true
59+
}
60+
2561
// Try legacy first
2662
if let _ = try? BitcoinAddress(legacy: address) {
2763
return true
@@ -35,6 +71,17 @@ class AddressValidator {
3571
return false
3672
}
3773

74+
private static func validateVergeLegacy(address: String) -> Bool {
75+
guard let payload = Base58Check.decode(address), payload.count == 21 else {
76+
return false
77+
}
78+
79+
let versionByte = payload[0]
80+
81+
return versionByte == Network.mainnetXVG.pubkeyhash
82+
|| versionByte == Network.mainnetXVG.scripthash
83+
}
84+
3885

3986

4087
func validate(
@@ -45,21 +92,56 @@ class AddressValidator {
4592
}
4693

4794
func validate(string: String, completion: @escaping ValidationCompletion) {
95+
validate(string: string, resolveDomains: false) { valid, address, amount, label, currency, _ in
96+
completion(valid, address, amount, label, currency)
97+
}
98+
}
99+
100+
func validateOrResolve(string: String, completion: @escaping ValidationCompletion) {
101+
validateOrResolve(string: string) { valid, address, amount, label, currency, _ in
102+
completion(valid, address, amount, label, currency)
103+
}
104+
}
105+
106+
func validateOrResolve(
107+
string: String,
108+
completion: @escaping (
109+
_ valid: Bool,
110+
_ address: String?,
111+
_ amount: NSNumber?,
112+
_ label: String?,
113+
_ currency: String?,
114+
_ error: ResolutionError?
115+
) -> Void
116+
) {
117+
validate(string: string, resolveDomains: true, completion: completion)
118+
}
119+
120+
private func validate(
121+
string: String,
122+
resolveDomains: Bool,
123+
completion: @escaping (
124+
_ valid: Bool,
125+
_ address: String?,
126+
_ amount: NSNumber?,
127+
_ label: String?,
128+
_ currency: String?,
129+
_ error: ResolutionError?
130+
) -> Void
131+
) {
48132
var valid = false
49133
var address: String?
50134
var amount: NSNumber?
51135
var label: String?
52136
var currency: String?
53137

54138
let parameters = self.normalizeUrl(url: string)
139+
let addressParam = parameters["address"] ?? nil
55140

56-
guard let addressParam = parameters["address"], AddressValidator.validate(address: addressParam ?? "") else {
57-
return completion(valid, address, amount, label, currency)
141+
guard let recipient = addressParam else {
142+
return completion(valid, address, amount, label, currency, nil)
58143
}
59144

60-
address = addressParam
61-
valid = true
62-
63145
if let amountParam = parameters["amount"], amountParam != nil {
64146
amount = self.amountToNumber(stringAmount: amountParam!)
65147
}
@@ -72,7 +154,29 @@ class AddressValidator {
72154
currency = currencyParam?.uppercased() == "XVG" ? nil : currencyParam
73155
}
74156

75-
completion(valid, address, amount, label, currency)
157+
if AddressValidator.validate(address: recipient) {
158+
valid = true
159+
address = recipient
160+
161+
return completion(valid, address, amount, label, currency, nil)
162+
}
163+
164+
guard resolveDomains, UnstoppableDomains.looksLikeDomain(recipient) else {
165+
return completion(valid, address, amount, label, currency, nil)
166+
}
167+
168+
resolveUnstoppableDomain(recipient) { result in
169+
switch result {
170+
case .success(let resolvedAddress):
171+
guard AddressValidator.validate(address: resolvedAddress) else {
172+
return completion(false, nil, amount, label, currency, .invalidResolvedAddress)
173+
}
174+
175+
completion(true, resolvedAddress, amount, label, currency, nil)
176+
case .failure(let error):
177+
completion(false, nil, amount, label, currency, error)
178+
}
179+
}
76180
}
77181

78182
fileprivate func amountToNumber(stringAmount: String) -> NSNumber? {
@@ -104,4 +208,55 @@ class AddressValidator {
104208

105209
return parameters
106210
}
211+
212+
private func resolveUnstoppableDomain(_ domain: String, completion: @escaping (Result<String, ResolutionError>) -> Void) {
213+
guard let token = UnstoppableDomains.apiToken, !token.isEmpty else {
214+
return completion(.failure(.missingApiToken))
215+
}
216+
217+
let trimmedDomain = domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
218+
guard let encodedDomain = trimmedDomain.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
219+
let url = URL(string: UnstoppableDomains.apiBaseUrl + encodedDomain)
220+
else {
221+
return completion(.failure(.recordNotFound))
222+
}
223+
224+
var request = URLRequest(url: url)
225+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
226+
227+
URLSession.shared.dataTask(with: request) { data, response, _ in
228+
guard let httpResponse = response as? HTTPURLResponse else {
229+
return completion(.failure(.emptyResponse))
230+
}
231+
232+
guard httpResponse.statusCode == 200 else {
233+
return completion(.failure(.httpStatus(httpResponse.statusCode)))
234+
}
235+
236+
guard let data = data, !data.isEmpty else {
237+
return completion(.failure(.emptyResponse))
238+
}
239+
240+
guard let decoded = try? JSONDecoder().decode(UnstoppableDomainsResponse.self, from: data) else {
241+
return completion(.failure(.invalidJson))
242+
}
243+
244+
guard let records = decoded.records else {
245+
return completion(.failure(.noRecords))
246+
}
247+
248+
guard let address = records[UnstoppableDomains.recordKey], !address.isEmpty else {
249+
return completion(.failure(.recordNotFound))
250+
}
251+
252+
completion(.success(self.normalizedResolvedAddress(address)))
253+
}.resume()
254+
}
255+
256+
private func normalizedResolvedAddress(_ address: String) -> String {
257+
return address
258+
.trimmingCharacters(in: .whitespacesAndNewlines)
259+
.replacingOccurrences(of: "verge://", with: "")
260+
.replacingOccurrences(of: "verge:", with: "")
261+
}
107262
}

Sources/Wallet/Credentials.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import BitcoinKit
44

55
class Credentials {
66
static let vergeCoinType: UInt32 = 77
7+
static let legacyVwsCoinType: UInt32 = 0
78
static let defaultAccount: UInt32 = 0
89

910
enum CredentialsError: Error {
@@ -136,6 +137,19 @@ class Credentials {
136137
return privateKey1
137138
}
138139
}
140+
141+
// MARK: - Legacy VWS BIP44 account-level key (m/44'/0'/0')
142+
var legacyVwsBip44PrivateKey: HDPrivateKey1 {
143+
do {
144+
return try privateKey1
145+
.derived(at: 44, hardened: true)
146+
.derived(at: Self.legacyVwsCoinType, hardened: true)
147+
.derived(at: Self.defaultAccount, hardened: true)
148+
} catch {
149+
print("Failed to derive legacy VWS BIP44 private key: \(error)")
150+
return privateKey1
151+
}
152+
}
139153

140154
var publicKey: HDPublicKey {
141155
return bip44PrivateKey.extendedPublicKey()

Sources/Wallet/ElectrumXWalletClient.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,17 @@ final class ElectrumXWalletClient: WalletClientProtocol {
548548
}
549549
}
550550

551+
extension ElectrumXWalletClient.ElectrumXWalletClientError: LocalizedError {
552+
var errorDescription: String? {
553+
switch self {
554+
case .unsupportedOperation:
555+
return "Sending from 18-word ElectrumX wallets is not implemented yet."
556+
case .addressDerivationFailed:
557+
return "Could not derive an ElectrumX wallet address."
558+
}
559+
}
560+
}
561+
551562
final class RoutingWalletClient: WalletClientProtocol {
552563
private let applicationRepository: ApplicationRepository
553564
private let vwsClient: WalletClientProtocol

Sources/Wallet/NFCWalletTransactionFactory.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ class NFCWalletTransactionFactory: NSObject, NFCNDEFReaderSessionDelegate {
124124
return
125125
}
126126

127-
self.addressValidator.validate(string: url) { isValid, address, amount, label, currency in
127+
self.addressValidator.validateOrResolve(string: url) { isValid, address, amount, label, currency in
128128
let txFactory = self.sendTransactionDelegate.getSendTransaction()
129129

130130
if let address = address, isValid {
@@ -153,7 +153,9 @@ class NFCWalletTransactionFactory: NSObject, NFCNDEFReaderSessionDelegate {
153153
txFactory.fiatRate = nil
154154
txFactory.fiatRateFetchedAt = nil
155155

156-
self.sendTransactionDelegate.didChangeSendTransaction(txFactory)
156+
DispatchQueue.main.async {
157+
self.sendTransactionDelegate.didChangeSendTransaction(txFactory)
158+
}
157159
}
158160
}
159161
}

0 commit comments

Comments
 (0)