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

Commit

Permalink
Fix #6182: Send to Unstoppable Domain wallet address (#7104)
Browse files Browse the repository at this point in the history
* Support for Unstoppable Domain wallet address resolution

* Unit tests for resolving Unstoppable Domain wallet address, verifying `resolvedAddress` will be updated when `selectedSendToken` changes.
  • Loading branch information
StephenHeaps committed Mar 23, 2023
1 parent ce4f2f8 commit 0767e16
Show file tree
Hide file tree
Showing 4 changed files with 306 additions and 1 deletion.
36 changes: 36 additions & 0 deletions Sources/BraveWallet/Crypto/Stores/SendTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public class SendTokenStore: ObservableObject {
@Published var selectedSendToken: BraveWallet.BlockchainToken? {
didSet {
update() // need to update `selectedSendTokenBalance` and `selectedSendNFTMetadata`
// Unstoppable Domains are resolved based on currently selected token.
validateSendAddress()
}
}
/// The current selected NFT metadata. Default with nil value.
Expand Down Expand Up @@ -75,6 +77,7 @@ public class SendTokenStore: ObservableObject {
case notSolAddress
case snsError(domain: String)
case ensError(domain: String)
case udError(domain: String)

var errorDescription: String? {
switch self {
Expand All @@ -94,6 +97,8 @@ public class SendTokenStore: ObservableObject {
return String.localizedStringWithFormat(Strings.Wallet.sendErrorDomainNotRegistered, BraveWallet.CoinType.sol.localizedTitle)
case .ensError:
return String.localizedStringWithFormat(Strings.Wallet.sendErrorDomainNotRegistered, BraveWallet.CoinType.eth.localizedTitle)
case .udError:
return String.localizedStringWithFormat(Strings.Wallet.sendErrorDomainNotRegistered, BraveWallet.CoinType.eth.localizedTitle)
}
}
}
Expand Down Expand Up @@ -264,6 +269,7 @@ public class SendTokenStore: ObservableObject {
let normalizedFromAddress = fromAddress.lowercased()
let normalizedToAddress = sendAddress.lowercased()
let isSupportedENSExtension = sendAddress.endsWithSupportedENSExtension
let isSupportedUDExtension = sendAddress.endsWithSupportedUDExtension
if isSupportedENSExtension {
self.resolvedAddress = nil
self.isResolvingAddress = true
Expand Down Expand Up @@ -292,6 +298,8 @@ public class SendTokenStore: ObservableObject {
// store address for sending
resolvedAddress = address
addressError = nil
} else if isSupportedUDExtension {
await resolveUnstoppableDomain(sendAddress)
} else {
if !sendAddress.isETHAddress {
// 1. check if send address is a valid eth address
Expand Down Expand Up @@ -320,6 +328,31 @@ public class SendTokenStore: ObservableObject {
}
}

@MainActor private func resolveUnstoppableDomain(_ domain: String) async {
guard selectedSendToken != nil else {
// token is required for `unstoppableDomainsGetWalletAddr`
// else it returns `invalidParams` error immediately
return
}
self.resolvedAddress = nil
self.isResolvingAddress = true
defer { self.isResolvingAddress = false }
let token = selectedSendToken
let (address, status, _) = await rpcService.unstoppableDomainsGetWalletAddr(domain, token: token)
guard !Task.isCancelled else { return }
if status != .success || address.isEmpty {
addressError = .udError(domain: sendAddress)
return
}
guard domain == sendAddress, token == selectedSendToken, !Task.isCancelled else {
// address changed while resolving, or validation cancelled.
return
}
// store address for sending
resolvedAddress = address
addressError = nil
}

public func enableENSOffchainLookup() {
Task { @MainActor in
rpcService.setEnsOffchainLookupResolveMethod(.enabled)
Expand All @@ -332,6 +365,7 @@ public class SendTokenStore: ObservableObject {
let normalizedFromAddress = fromAddress.lowercased()
let normalizedToAddress = sendAddress.lowercased()
let isSupportedSNSExtension = sendAddress.endsWithSupportedSNSExtension
let isSupportedUDExtension = sendAddress.endsWithSupportedUDExtension
if isSupportedSNSExtension {
self.resolvedAddress = nil
self.isResolvingAddress = true
Expand All @@ -355,6 +389,8 @@ public class SendTokenStore: ObservableObject {
// store address for sending
resolvedAddress = address
addressError = nil
} else if isSupportedUDExtension {
await resolveUnstoppableDomain(sendAddress)
} else { // not supported SNS extension, validate address
let isValid = await walletService.isBase58EncodedSolanaPubkey(sendAddress)
if !isValid {
Expand Down
5 changes: 5 additions & 0 deletions Sources/BraveWallet/Extensions/BraveWalletExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,11 @@ public extension String {
var endsWithSupportedSNSExtension: Bool {
WalletConstants.supportedSNSExtensions.contains(where: hasSuffix)
}

/// Returns true if the string ends with a supported UD extension.
var endsWithSupportedUDExtension: Bool {
WalletConstants.supportedUDExtensions.contains(where: hasSuffix)
}
}

public extension URL {
Expand Down
2 changes: 2 additions & 0 deletions Sources/BraveWallet/WalletConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ struct WalletConstants {
static let supportedENSExtensions = [".eth"]
/// The supported Solana Name Service (SNS) extensions
static let supportedSNSExtensions = [".sol"]
/// The supported Unstoppable Domain (UD) extensions
static let supportedUDExtensions = [".crypto", ".x", ".nft", ".dao", ".wallet", ".888", ".blockchain", ".bitcoin"]

/// The supported IPFS schemes
static let supportedIPFSSchemes = ["ipfs", "ipns"]
Expand Down
264 changes: 263 additions & 1 deletion Tests/BraveWalletTests/SendTokenStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ class SendTokenStoreTests: XCTestCase {
solanaBalance: UInt64 = 0,
splTokenBalance: String = "0",
snsGetSolAddr: String = "",
ensGetEthAddr: String = ""
ensGetEthAddr: String = "",
unstoppableDomainsGetWalletAddr: String = ""
) -> (BraveWallet.TestKeyringService, BraveWallet.TestJsonRpcService, BraveWallet.TestBraveWalletService, BraveWallet.TestEthTxManagerProxy, BraveWallet.TestSolanaTxManagerProxy) {
let keyringService = BraveWallet.TestKeyringService()
keyringService._addObserver = { _ in }
Expand All @@ -46,6 +47,9 @@ class SendTokenStoreTests: XCTestCase {
rpcService._ensGetEthAddr = { _, completion in
completion(ensGetEthAddr, false, .success, "")
}
rpcService._unstoppableDomainsGetWalletAddr = { _, _, completion in
completion(unstoppableDomainsGetWalletAddr, .success, "")
}
rpcService._erc721Metadata = { _, _, _, completion in
let metadata = """
{
Expand Down Expand Up @@ -963,4 +967,262 @@ class SendTokenStoreTests: XCTestCase {
XCTAssertNil(error)
}
}

/// Test `resolvedAddress` will be assigned the address returned from `unstoppableDomainsGetWalletAddr` when a Ethereum network is selected.
func testUDAddressResolutionEthNetwork() {
let domain = "brave.crypto"
let expectedAddress = "0xxxxxxxxxxxyyyyyyyyyyzzzzzzzzzz0000000000"

let (keyringService, rpcService, walletService, ethTxManagerProxy, solTxManagerProxy) = setupServices(
selectedCoin: .eth,
unstoppableDomainsGetWalletAddr: expectedAddress
)

let store = SendTokenStore(
keyringService: keyringService,
rpcService: rpcService,
walletService: walletService,
txService: MockTxService(),
blockchainRegistry: MockBlockchainRegistry(),
ethTxManagerProxy: ethTxManagerProxy,
solTxManagerProxy: solTxManagerProxy,
prefilledToken: .previewToken,
ipfsApi: nil
)

let waitForPrefilledTokenExpectation = expectation(description: "waitForPrefilledToken")
store.$selectedSendToken
.dropFirst()
.sink { selectedSendToken in
defer { waitForPrefilledTokenExpectation.fulfill() }
XCTAssertEqual(selectedSendToken, .previewToken)
}
.store(in: &cancellables)
store.update()
// wait for store to be setup with given `prefilledToken`
wait(for: [waitForPrefilledTokenExpectation], timeout: 1)
// release above sink on `selectedSendToken`
// to avoid repeated calls to expectation
cancellables.removeAll()

let resolvedAddressExpectation = expectation(description: "sendTokenStore-resolvedAddress")
XCTAssertNil(store.resolvedAddress) // Initial state
store.$resolvedAddress
.dropFirst(3) // Initial value, reset to nil in `sendAddress` didSet, reset to nil in `validateEthereumSendAddress`
.sink { resolvedAddress in
defer { resolvedAddressExpectation.fulfill() }
XCTAssertEqual(resolvedAddress, expectedAddress)
}.store(in: &cancellables)

store.sendAddress = domain

waitForExpectations(timeout: 1) { error in
XCTAssertNil(error)
}
}

/// Test `addressError` will be assigned an `ensError` if error is returned from `unstoppableDomainsGetWalletAddr`.
func testUDAddressResolutionFailure() {
let domain = "brave.eth"
let (keyringService, rpcService, walletService, ethTxManagerProxy, solTxManagerProxy) = setupServices(
selectedCoin: .eth
)
rpcService._unstoppableDomainsGetWalletAddr = { _, _, completion in
completion("", .internalError, "Something went wrong")
}

let store = SendTokenStore(
keyringService: keyringService,
rpcService: rpcService,
walletService: walletService,
txService: MockTxService(),
blockchainRegistry: MockBlockchainRegistry(),
ethTxManagerProxy: ethTxManagerProxy,
solTxManagerProxy: solTxManagerProxy,
prefilledToken: .previewToken,
ipfsApi: nil
)

let waitForPrefilledTokenExpectation = expectation(description: "waitForPrefilledToken")
store.$selectedSendToken
.dropFirst()
.sink { selectedSendToken in
defer { waitForPrefilledTokenExpectation.fulfill() }
XCTAssertEqual(selectedSendToken, .previewToken)
}
.store(in: &cancellables)
store.update()
// wait for store to be setup with given `prefilledToken`
wait(for: [waitForPrefilledTokenExpectation], timeout: 1)
// release above sink on `selectedSendToken`
// to avoid repeated calls to expectation
cancellables.removeAll()

let resolvedAddressExpectation = expectation(description: "sendTokenStore-resolvedAddress")
XCTAssertNil(store.resolvedAddress) // Initial state
store.$resolvedAddress
.dropFirst(2) // Initial value, reset to nil in `validateEthereumSendAddress`
.sink { resolvedAddress in
defer { resolvedAddressExpectation.fulfill() }
XCTAssertNil(resolvedAddress)
}.store(in: &cancellables)

let addressErrorExpectation = expectation(description: "sendTokenStore-addressError")
XCTAssertNil(store.resolvedAddress) // Initial state
store.$addressError
.dropFirst() // Initial value
.sink { addressError in
defer { addressErrorExpectation.fulfill() }
XCTAssertEqual(addressError, .ensError(domain: domain))
}.store(in: &cancellables)

store.sendAddress = domain

waitForExpectations(timeout: 1) { error in
XCTAssertNil(error)
}
}

/// Test `resolvedAddress` will be assigned the address returned from `unstoppableDomainsGetWalletAddr` when a Solana network is selected.
func testUDAddressResolutionSolNetwork() {
let domain = "brave.crypto"
let expectedAddress = "xxxxxxxxxxyyyyyyyyyyzzzzzzzzzz0000000000"

let (keyringService, rpcService, walletService, ethTxManagerProxy, solTxManagerProxy) = setupServices(
selectedCoin: .sol,
selectedNetwork: .mockSolana,
unstoppableDomainsGetWalletAddr: expectedAddress
)

let store = SendTokenStore(
keyringService: keyringService,
rpcService: rpcService,
walletService: walletService,
txService: MockTxService(),
blockchainRegistry: MockBlockchainRegistry(),
ethTxManagerProxy: ethTxManagerProxy,
solTxManagerProxy: solTxManagerProxy,
prefilledToken: .mockSolToken,
ipfsApi: nil
)

let waitForPrefilledTokenExpectation = expectation(description: "waitForPrefilledToken")
store.$selectedSendToken
.dropFirst()
.sink { selectedSendToken in
defer { waitForPrefilledTokenExpectation.fulfill() }
XCTAssertEqual(selectedSendToken, .mockSolToken)
}
.store(in: &cancellables)
store.update()
// wait for store to be setup with given `prefilledToken`
wait(for: [waitForPrefilledTokenExpectation], timeout: 1)
// release above sink on `selectedSendToken`
// to avoid repeated calls to expectation
cancellables.removeAll()

let resolvedAddressExpectation = expectation(description: "sendTokenStore-resolvedAddress")
XCTAssertNil(store.resolvedAddress) // Initial state
store.$resolvedAddress
.dropFirst(3) // Initial value, reset to nil in `sendAddress` didSet, reset to nil in `validateSolanaSendAddress`
.sink { resolvedAddress in
defer { resolvedAddressExpectation.fulfill() }
XCTAssertEqual(resolvedAddress, expectedAddress)
}.store(in: &cancellables)

store.sendAddress = domain

waitForExpectations(timeout: 1) { error in
XCTAssertNil(error)
}
}

/// Test `resolvedAddress` will be assigned the address returned from `unstoppableDomainsGetWalletAddr`, then if the `selectedSendToken` changes will call `unstoppableDomainsGetWalletAddr` and assign the new `resolvedAddress`.
func testUDAddressResolutionTokenChange() {
let domain = "brave.crypto"
let expectedAddress = "0xxxxxxxxxxxyyyyyyyyyyzzzzzzzzzz0000000000"
let expectedAddressUSDC = "0x1111111111222222222233333333330000000000"

let (keyringService, rpcService, walletService, ethTxManagerProxy, solTxManagerProxy) = setupServices(
selectedCoin: .eth
)
rpcService._unstoppableDomainsGetWalletAddr = { domain, token, completion in
if token == .mockUSDCToken { // simulate special address for USDC
completion(expectedAddressUSDC, .success, "")
} else {
completion(expectedAddress, .success, "")
}
}

let store = SendTokenStore(
keyringService: keyringService,
rpcService: rpcService,
walletService: walletService,
txService: MockTxService(),
blockchainRegistry: MockBlockchainRegistry(),
ethTxManagerProxy: ethTxManagerProxy,
solTxManagerProxy: solTxManagerProxy,
prefilledToken: .previewToken,
ipfsApi: nil
)

let waitForPrefilledTokenExpectation = expectation(description: "waitForPrefilledToken")
store.$selectedSendToken
.dropFirst()
.sink { selectedSendToken in
defer { waitForPrefilledTokenExpectation.fulfill() }
XCTAssertEqual(selectedSendToken, .previewToken)
}
.store(in: &cancellables)
store.update()
// wait for store to be setup with given `prefilledToken`
wait(for: [waitForPrefilledTokenExpectation], timeout: 1)
// release above sink on `selectedSendToken`
// to avoid repeated calls to expectation
cancellables.removeAll()

// Wait for `resolvedAddress` to be populated with address for the `prefilledToken`
let resolvedAddressExpectation = expectation(description: "sendTokenStore-resolvedAddress")
XCTAssertNil(store.resolvedAddress) // Initial state
store.$resolvedAddress
.collect(6) // Initial value, reset to nil in `sendAddress` didSet, reset to nil in `resolveUnstoppableDomain`, assigned address in `resolveUnstoppableDomain`, reset to nil in `resolveUnstoppableDomain`, assigned address in `resolveUnstoppableDomain`
.sink { resolvedAddresses in
guard let resolvedAddress = resolvedAddresses.last else {
XCTFail("Expected >0 resolved address assignments")
return
}
XCTAssertEqual(store.selectedSendToken, .previewToken)
XCTAssertEqual(resolvedAddress, expectedAddress)
resolvedAddressExpectation.fulfill()
}.store(in: &cancellables)

store.sendAddress = domain
wait(for: [resolvedAddressExpectation], timeout: 1)
// release above sink on `selectedSendToken`
// to avoid repeated calls to expectation
cancellables.removeAll()

// Verify change from the resolved address for `previewToken`
// will change to the resolved address for `.mockUSDCToken`
let resolvedUSDCAddressExpectation = expectation(description: "sendTokenStore-resolvedUSDCAddress")
store.$resolvedAddress
.collect(3)
.sink { resolvedAddresses in
// initial value of sink
XCTAssertEqual(resolvedAddresses[safe: 0], expectedAddress)
// reset in `resolveUnstoppableDomain`
XCTAssertEqual(resolvedAddresses[safe: 1], Optional<String>.none)
// new value assigned after resolving for new token
XCTAssertEqual(resolvedAddresses[safe: 2], expectedAddressUSDC)
XCTAssertEqual(store.selectedSendToken, .mockUSDCToken)
resolvedUSDCAddressExpectation.fulfill()
}.store(in: &cancellables)

// change token from `.previewToken` to `.mockUSDCToken`
store.selectedSendToken = .mockUSDCToken

waitForExpectations(timeout: 1) { error in
XCTAssertNil(error)
}
}
}

0 comments on commit 0767e16

Please sign in to comment.