Skip to content
This repository has been archived by the owner on May 10, 2024. It is now read-only.

Commit

Permalink
Fix #5787: Send to ENS wallet address (#7030)
Browse files Browse the repository at this point in the history
* Support sending to an ENS domain in a native send transaction, including offchain ENS.

* Add ENS Offchain preference and hyperlink to `Web3DomainSettingsView`

* Unit tests for verifying ENS resolution, ENS Offchain consent required, ENS failure, creating Ethereum transaction using resolved address.

* Use iOS 15+ open url action now that iOS 14 support is dropped. Update UI/UX for ENS preference to use markdown
  • Loading branch information
StephenHeaps authored Mar 9, 2023
1 parent 6a9f2ba commit efac58e
Show file tree
Hide file tree
Showing 9 changed files with 431 additions and 90 deletions.
13 changes: 12 additions & 1 deletion Sources/Brave/Frontend/Settings/SettingsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,18 @@ class SettingsViewController: TableViewController {
Row(
text: Strings.Wallet.web3,
selection: { [unowned self] in
let web3SettingsView = Web3SettingsView(settingsStore: settingsStore, networkStore: cryptoStore?.networkStore, keyringStore: keyringStore, ipfsAPI: ipfsAPI)
let web3SettingsView = Web3SettingsView(
settingsStore: settingsStore,
networkStore: cryptoStore?.networkStore,
keyringStore: keyringStore,
ipfsAPI: ipfsAPI
).environment(\.openURL, .init(handler: { [weak self] url in
guard let self = self else { return .discarded }
(self.presentingViewController ?? self).dismiss(animated: true) { [self] in
self.settingsDelegate?.settingsOpenURLInNewTab(url)
}
return .handled
}))
let vc = UIHostingController(rootView: web3SettingsView)
self.navigationController?.pushViewController(vc, animated: true)
},
Expand Down
49 changes: 42 additions & 7 deletions Sources/BraveWallet/Crypto/BuySendSwap/SendTokenView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ struct SendTokenView: View {
@ScaledMetric private var length: CGFloat = 16.0

@Environment(\.appRatingRequestAction) private var appRatingRequest
@Environment(\.openURL) private var openURL

var completion: ((_ success: Bool) -> Void)?
var onDismiss: () -> Void
Expand All @@ -33,6 +34,11 @@ struct SendTokenView: View {
sendTokenStore.sendError == nil else {
return true
}
if sendTokenStore.isOffchainResolveRequired {
// if offchain resolve is required, the send button will show 'Use ENS Domain'
// and will enable ens offchain, instead of attempting to create send tx.
return false
}
if token.isErc721 || token.isNft {
return balance < 1
}
Expand All @@ -49,6 +55,8 @@ struct SendTokenView: View {
private var sendButtonTitle: String {
if let error = sendTokenStore.sendError {
return error.localizedDescription
} else if sendTokenStore.isOffchainResolveRequired {
return Strings.Wallet.ensOffchainGatewayButton
} else {
return Strings.Wallet.sendCryptoSendButtonTitle
}
Expand Down Expand Up @@ -190,6 +198,29 @@ struct SendTokenView: View {
if sendTokenStore.isResolvingAddress {
ProgressView()
}
if sendTokenStore.isOffchainResolveRequired {
VStack(alignment: .leading, spacing: 8) {
Divider()
Text(Strings.Wallet.ensOffchainGatewayTitle)
.font(.body)
.fontWeight(.bold)
.foregroundColor(Color(.braveLabel))
.fixedSize(horizontal: false, vertical: true)
Text(Strings.Wallet.ensOffchainGatewayDesc)
.font(.body)
.foregroundColor(Color(.secondaryBraveLabel))
.fixedSize(horizontal: false, vertical: true)
Button(action: {
openURL(WalletConstants.braveWalletENSOffchainURL)
}) {
Text(Strings.Wallet.learnMoreButton)
.foregroundColor(Color(.braveBlurpleTint))
}
}
.font(.subheadline)
.padding(.top, 8) // padding between sendAddress & divider
.frame(maxWidth: .infinity)
}
if let resolvedAddress = sendTokenStore.resolvedAddress {
AddressView(address: resolvedAddress) {
Text(resolvedAddress)
Expand All @@ -207,14 +238,18 @@ struct SendTokenView: View {
WalletLoadingButton(
isLoading: sendTokenStore.isLoading || sendTokenStore.isMakingTx,
action: {
sendTokenStore.sendToken(
amount: sendTokenStore.sendAmount
) { success, _ in
isShowingError = !success
if success {
appRatingRequest?()
if sendTokenStore.isOffchainResolveRequired {
sendTokenStore.enableENSOffchainLookup()
} else {
sendTokenStore.sendToken(
amount: sendTokenStore.sendAmount
) { success, _ in
isShowingError = !success
if success {
appRatingRequest?()
}
completion?(success)
}
completion?(success)
}
},
label: {
Expand Down
89 changes: 65 additions & 24 deletions Sources/BraveWallet/Crypto/Stores/SendTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class SendTokenStore: ObservableObject {
didSet {
if oldValue != sendAddress {
resolvedAddress = nil
isOffchainResolveRequired = false
}
sendAddressUpdatedTimer?.invalidate()
sendAddressUpdatedTimer = Timer.scheduledTimer(
Expand Down Expand Up @@ -62,6 +63,8 @@ public class SendTokenStore: ObservableObject {
@Published private(set) var isResolvingAddress: Bool = false
/// The address returned from SNS / ENS
@Published private(set) var resolvedAddress: String?
/// If the current `sendAddress` needs to be resolved offchain
@Published private(set) var isOffchainResolveRequired: Bool = false

enum AddressError: LocalizedError, Equatable {
case sameAsFromAddress
Expand All @@ -71,6 +74,7 @@ public class SendTokenStore: ObservableObject {
case invalidChecksum
case notSolAddress
case snsError(domain: String)
case ensError(domain: String)

var errorDescription: String? {
switch self {
Expand All @@ -86,8 +90,10 @@ public class SendTokenStore: ObservableObject {
return Strings.Wallet.sendWarningAddressInvalidChecksum
case .notSolAddress:
return Strings.Wallet.sendWarningSolAddressNotValid
case .snsError(let domain):
return String.localizedStringWithFormat(Strings.Wallet.sendErrorDomainNotRegistered, domain)
case .snsError:
return String.localizedStringWithFormat(Strings.Wallet.sendErrorDomainNotRegistered, BraveWallet.CoinType.sol.localizedTitle)
case .ensError:
return String.localizedStringWithFormat(Strings.Wallet.sendErrorDomainNotRegistered, BraveWallet.CoinType.eth.localizedTitle)
}
}
}
Expand Down Expand Up @@ -257,41 +263,72 @@ public class SendTokenStore: ObservableObject {
@MainActor private func validateEthereumSendAddress(fromAddress: String) async {
let normalizedFromAddress = fromAddress.lowercased()
let normalizedToAddress = sendAddress.lowercased()
// TODO: Support ENS #5787
if !sendAddress.isETHAddress {
// 1. check if send address is a valid eth address
addressError = .notEthAddress
} else if normalizedFromAddress == normalizedToAddress {
// 2. check if send address is the same as the from address
addressError = .sameAsFromAddress
} else if (userAssets.first(where: { $0.contractAddress.lowercased() == normalizedToAddress }) != nil)
|| (allTokens.first(where: { $0.contractAddress.lowercased() == normalizedToAddress }) != nil) {
// 3. check if send address is a contract address
addressError = .contractAddress
let isSupportedENSExtension = sendAddress.endsWithSupportedENSExtension
if isSupportedENSExtension {
self.isResolvingAddress = true
defer { self.isResolvingAddress = false }
let (address, isOffchainConsentRequired, status, _) = await rpcService.ensGetEthAddr(sendAddress)
guard !Task.isCancelled else { return }
if isOffchainConsentRequired {
self.isOffchainResolveRequired = true
self.addressError = nil
return // do not continue unless ens is enabled
}
if status != .success || address.isEmpty {
addressError = .ensError(domain: sendAddress)
return
}
// If found address is the same as the selectedAccounts Wallet Address
if address.lowercased() == normalizedFromAddress {
addressError = .sameAsFromAddress
return
}
// store address for sending
resolvedAddress = address
addressError = nil
} else {
let checksumAddress = await keyringService.checksumEthAddress(sendAddress)
if sendAddress == checksumAddress {
// 4. check if send address is the same as the checksum address from the `KeyringService`
addressError = nil
} else if sendAddress.removingHexPrefix.lowercased() == sendAddress.removingHexPrefix || sendAddress.removingHexPrefix.uppercased() == sendAddress.removingHexPrefix {
// 5. check if send address has each of the alphabetic character as uppercase, or has each of
// the alphabeic character as lowercase
addressError = .missingChecksum
if !sendAddress.isETHAddress {
// 1. check if send address is a valid eth address
addressError = .notEthAddress
} else if normalizedFromAddress == normalizedToAddress {
// 2. check if send address is the same as the from address
addressError = .sameAsFromAddress
} else if (userAssets.first(where: { $0.contractAddress.lowercased() == normalizedToAddress }) != nil)
|| (allTokens.first(where: { $0.contractAddress.lowercased() == normalizedToAddress }) != nil) {
// 3. check if send address is a contract address
addressError = .contractAddress
} else {
// 6. send address has mixed with uppercase and lowercase and does not match with the checksum address
addressError = .invalidChecksum
let checksumAddress = await keyringService.checksumEthAddress(sendAddress)
if sendAddress == checksumAddress {
// 4. check if send address is the same as the checksum address from the `KeyringService`
addressError = nil
} else if sendAddress.removingHexPrefix.lowercased() == sendAddress.removingHexPrefix || sendAddress.removingHexPrefix.uppercased() == sendAddress.removingHexPrefix {
// 5. check if send address has each of the alphabetic character as uppercase, or has each of
// the alphabeic character as lowercase
addressError = .missingChecksum
} else {
// 6. send address has mixed with uppercase and lowercase and does not match with the checksum address
addressError = .invalidChecksum
}
}
}
}

public func enableENSOffchainLookup() {
Task { @MainActor in
rpcService.setEnsOffchainLookupResolveMethod(.enabled)
self.isOffchainResolveRequired = false
await self.validateEthereumSendAddress(fromAddress: sendAddress)
}
}

@MainActor private func validateSolanaSendAddress(fromAddress: String) async {
let normalizedFromAddress = fromAddress.lowercased()
let normalizedToAddress = sendAddress.lowercased()
let isSupportedSNSExtension = sendAddress.endsWithSupportedSNSExtension
if isSupportedSNSExtension {
self.isResolvingAddress = true
defer { self.isResolvingAddress = false }
// If value ends with a supported SNS extension, will call findSNSAddress.
let (address, status, _) = await rpcService.snsGetSolAddr(sendAddress)
guard !Task.isCancelled else { return }
if status != .success || address.isEmpty {
Expand Down Expand Up @@ -521,6 +558,10 @@ extension SendTokenStore: BraveWalletJsonRpcServiceObserver {
public func chainChangedEvent(_ chainId: String, coin: BraveWallet.CoinType) {
selectedSendToken = nil
selectedSendTokenBalance = nil
if coin != .eth { // if changed to ethereum coin network, address is still valid
// offchain resolve is for ENS-only, for other coin types the address is invalid
isOffchainResolveRequired = false
}
update() // `selectedSendToken` & `selectedSendTokenBalance` need updated for new chain
validateSendAddress() // `sendAddress` may no longer be valid if coin type changed
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/BraveWallet/Crypto/Stores/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ public class SettingsStore: ObservableObject {
walletService.setDefaultBaseCurrency(currencyCode.code)
}
}

/// The current ENS Offchain Resolve Method preference (Ask / Enabled / Disabled)
@Published var ensOffchainResolveMethod: BraveWallet.ResolveMethod = .ask {
didSet {
rpcService.setEnsOffchainLookupResolveMethod(ensOffchainResolveMethod)
}
}

/// The current SNS Resolve Method preference (Ask / Enabled / Disabled)
@Published var snsResolveMethod: BraveWallet.ResolveMethod = .ask {
Expand Down Expand Up @@ -74,6 +81,7 @@ public class SettingsStore: ObservableObject {
let autoLockMinutes = await keyringService.autoLockMinutes()
self.autoLockInterval = .init(value: autoLockMinutes)

self.ensOffchainResolveMethod = await rpcService.ensOffchainLookupResolveMethod()
self.snsResolveMethod = await rpcService.snsResolveMethod()
}
}
Expand Down
21 changes: 21 additions & 0 deletions Sources/BraveWallet/Settings/Web3SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -281,11 +281,32 @@ private struct Web3DomainSettingsView: View {
Section(header: Text(Strings.Wallet.web3DomainOptionsHeader)) {
Group {
snsResolveMethodPreference
ensOffchainResolveMethodPreference
}
.listRowBackground(Color(.secondaryBraveGroupedBackground))
}
}

@ViewBuilder private var ensOffchainResolveMethodPreference: some View {
Picker(selection: $settingsStore.ensOffchainResolveMethod) {
ForEach(BraveWallet.ResolveMethod.allCases) { option in
Text(option.name)
.foregroundColor(Color(.secondaryBraveLabel))
.tag(option)
}
} label: {
VStack(alignment: .leading, spacing: 6) {
Text(Strings.Wallet.ensOffchainResolveMethodTitle)
.foregroundColor(Color(.braveLabel))
Text(LocalizedStringKey(String.localizedStringWithFormat(Strings.Wallet.ensOffchainResolveMethodDescription, WalletConstants.braveWalletENSOffchainURL.absoluteDisplayString)))
.foregroundColor(Color(.secondaryBraveLabel))
.tint(Color(.braveBlurpleTint))
.font(.footnote)
}
.padding(.vertical, 4)
}
}

@ViewBuilder private var snsResolveMethodPreference: some View {
Picker(selection: $settingsStore.snsResolveMethod) {
ForEach(BraveWallet.ResolveMethod.allCases) { option in
Expand Down
3 changes: 3 additions & 0 deletions Sources/BraveWallet/WalletConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ struct WalletConstants {

/// The url to Brave Help Center for Wallet.
static let braveWalletSupportURL = URL(string: "https://support.brave.com/hc/en-us/categories/360001059151-Brave-Wallet")!

/// The url to learn more about ENS off-chain lookups
static let braveWalletENSOffchainURL = URL(string: "https://github.com/brave/brave-browser/wiki/ENS-offchain-lookup")!

/// The currently supported test networks.
static let supportedTestNetworkChainIds = [
Expand Down
52 changes: 47 additions & 5 deletions Sources/BraveWallet/WalletStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1797,8 +1797,8 @@ extension Strings {
"wallet.sendErrorDomainNotRegistered",
tableName: "BraveWallet",
bundle: .module,
value: "%@ is not registered",
comment: "An error that appears below the send crypto address text field, when the input `To` domain/url that we cannot resolve to a wallet address. Ex. `Stephen.sol`"
value: "Domain doesn\'t have a linked %@ address",
comment: "An error that appears below the send crypto address text field, when the input `To` domain/url that we cannot resolve to a wallet address. The '%@' will be replaced with the coin type Ex. `Domain doesn\'t have a linked ETH address`"
)
public static let customNetworkChainIdTitle = NSLocalizedString(
"wallet.customNetworkChainIdTitle",
Expand Down Expand Up @@ -3520,21 +3520,42 @@ extension Strings {
tableName: "BraveWallet",
bundle: .module,
value: "Ask",
comment: "One of the options for Brave to handle Solana Name Service domain name. 'Ask' means Brave will ask user first before enable or disable resolving SNS domain name."
comment: "One of the options for Brave to handle Ethereum/Solana Name Service domain name. 'Ask' means Brave will ask user first before enable or disable resolving ENS/SNS domain name."
)
public static let web3DomainOptionEnabled = NSLocalizedString(
"wallet.web3DomainOptionEnabled",
tableName: "BraveWallet",
bundle: .module,
value: "Enabled",
comment: "One of the options for Brave to handle Solana Name Service domain name. 'Enabled' means Brave will enable resolving SNS domain name."
comment: "One of the options for Brave to handle Ethereum/Solana Name Service domain name. 'Enabled' means Brave will enable resolving ENS/SNS domain name."
)
public static let web3DomainOptionDisabled = NSLocalizedString(
"wallet.web3DomainOptionDisabled",
tableName: "BraveWallet",
bundle: .module,
value: "Disabled",
comment: "One of the options for Brave to handle Solana Name Service domain name. 'Disabled' means Brave will disable resolving SNS domain name."
comment: "One of the options for Brave to handle Ethereum/Solana Name Service domain name. 'Disabled' means Brave will disable resolving ENS/SNS domain name."
)
public static let ensResolveMethodTitle = NSLocalizedString(
"wallet.ensResolveMethodTitle",
tableName: "BraveWallet",
bundle: .module,
value: "Resolve Ethereum Name Service (ENS) Domain Names",
comment: "The title for the options to resolve Ethereum Name service domain names."
)
public static let ensOffchainResolveMethodTitle = NSLocalizedString(
"wallet.ensOffchainResolveMethodTitle",
tableName: "BraveWallet",
bundle: .module,
value: "Allow ENS Offchain Lookup",
comment: "The title for the options to allow Ethereum Name service domain names offchain."
)
public static let ensOffchainResolveMethodDescription = NSLocalizedString(
"wallet.ensOffchainResolveMethodTitle",
tableName: "BraveWallet",
bundle: .module,
value: "[Learn more](%@) about ENS offchain lookup privacy considerations.",
comment: "The description for the options to allow Ethereum Name service domain names offchain. '%@' will be replaced with a url to explain more about ENS offchain lookup."
)
public static let snsResolveMethodTitle = NSLocalizedString(
"wallet.web3DomainOptionsTitle",
Expand Down Expand Up @@ -3690,5 +3711,26 @@ extension Strings {
value: "Please save the error message for future reference.",
comment: "A description of the view that will display the error message."
)
public static let ensOffchainGatewayTitle = NSLocalizedString(
"wallet.ensOffchainGatewayTitle",
tableName: "BraveWallet",
bundle: .module,
value: "Brave supports using offchain gateways to resolve .eth domains.",
comment: "Title shown send address / ENS domain when requesting to do an ENS off chain lookup."
)
public static let ensOffchainGatewayDesc = NSLocalizedString(
"wallet.ensOffchainGatewayDesc",
tableName: "BraveWallet",
bundle: .module,
value: "It looks like you've entered an ENS address. We'll need to use a third-party resolver to resolve this request. This helps ensure your .eth domain isn't leaked, and that your transaction is secure.",
comment: "Description shown send address / ENS domain when requesting to do an ENS off chain lookup."
)
public static let ensOffchainGatewayButton = NSLocalizedString(
"wallet.ensOffchainGatewayButton",
tableName: "BraveWallet",
bundle: .module,
value: "Use ENS Domain",
comment: "Button title when requesting to do an ENS off chain lookup."
)
}
}
Loading

0 comments on commit efac58e

Please sign in to comment.