diff --git a/Sources/BraveWallet/Crypto/BuySendSwap/SwapCryptoView.swift b/Sources/BraveWallet/Crypto/BuySendSwap/SwapCryptoView.swift index 7a48fe3f57d..a85891dbc9c 100644 --- a/Sources/BraveWallet/Crypto/BuySendSwap/SwapCryptoView.swift +++ b/Sources/BraveWallet/Crypto/BuySendSwap/SwapCryptoView.swift @@ -240,7 +240,7 @@ struct SwapCryptoView: View { } private var isSwapButtonDisabled: Bool { - guard !swapTokensStore.isMakingTx else { + guard !swapTokensStore.isMakingTx && !swapTokensStore.isUpdatingPriceQuote else { return true } switch swapTokensStore.state { @@ -427,32 +427,10 @@ struct SwapCryptoView: View { Section( header: VStack(spacing: 16) { - Text( - String.localizedStringWithFormat( - Strings.Wallet.braveSwapFeeDisclaimer, - { - let formatter = NumberFormatter() - formatter.numberStyle = .percent - formatter.minimumFractionDigits = 3 - let value: Double - switch dexAggregator { - case .zeroX: // 0.875 - value = WalletConstants.braveSwapFee - formatter.maximumFractionDigits = 3 - case .jupiter: // 0.85 - value = WalletConstants.braveSwapJupiterFee - formatter.maximumFractionDigits = 2 - } - return formatter.string( - from: NSNumber( - value: value - )) ?? "" - }()) - ) - .foregroundColor(Color(.braveLabel)) - .font(.footnote) + feesFooter + WalletLoadingButton( - isLoading: swapTokensStore.isMakingTx, + isLoading: swapTokensStore.isMakingTx || swapTokensStore.isUpdatingPriceQuote, action: { Task { @MainActor in let success = await swapTokensStore.createSwapTransaction() @@ -504,6 +482,25 @@ struct SwapCryptoView: View { .listRowBackground(Color(.braveGroupedBackground)) } } + + @ViewBuilder private var feesFooter: some View { + if swapTokensStore.braveFeeForDisplay != nil || swapTokensStore.protocolFeeForDisplay != nil { + VStack(spacing: 4) { + if let braveFeeForDisplay = swapTokensStore.braveFeeForDisplay { + if swapTokensStore.isBraveFeeVoided { + Text(String.localizedStringWithFormat(Strings.Wallet.braveFeeLabel, Strings.Wallet.braveSwapFree) + " ") + Text(braveFeeForDisplay).strikethrough() + } else { + Text(String.localizedStringWithFormat(Strings.Wallet.braveFeeLabel, braveFeeForDisplay)) + } + } + if let protocolFee = swapTokensStore.protocolFeeForDisplay { + Text(String.localizedStringWithFormat(Strings.Wallet.protocolFeeLabel, protocolFee)) + } + } + .font(.footnote) + .foregroundColor(Color(.braveLabel)) + } + } enum OrderType { case market diff --git a/Sources/BraveWallet/Crypto/Stores/SwapTokenStore.swift b/Sources/BraveWallet/Crypto/Stores/SwapTokenStore.swift index d2b2716bbde..688bfe991e0 100644 --- a/Sources/BraveWallet/Crypto/Stores/SwapTokenStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/SwapTokenStore.swift @@ -16,6 +16,9 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { /// The current selected token to swap from. Default with nil value. @Published var selectedFromToken: BraveWallet.BlockchainToken? { didSet { + if oldValue != selectedFromToken { + clearAllAmount() + } if let token = selectedFromToken { fetchTokenBalance(for: token) { [weak self] balance in self?.selectedFromTokenBalance = balance @@ -26,6 +29,9 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { /// The current selected token to swap to. Default with nil value @Published var selectedToToken: BraveWallet.BlockchainToken? { didSet { + if oldValue != selectedToToken { + clearAllAmount() + } if let token = selectedToToken { fetchTokenBalance(for: token) { [weak self] balance in self?.selectedToTokenBalance = balance @@ -50,12 +56,17 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { /// The sell amount in this swap @Published var sellAmount = "" { didSet { - jupiterQuote = nil // sell amount changed, new jupiterQuote is needed + if sellAmount != oldValue { + // sell amount changed, new quotes are needed + swapResponse = nil + jupiterQuote = nil + braveFee = nil + } guard !sellAmount.isEmpty, BDouble(sellAmount.normalizedDecimals) != nil else { state = .idle return } - if oldValue != sellAmount && !updatingPriceQuote && !isMakingTx { + if oldValue != sellAmount && !isUpdatingPriceQuote && !isMakingTx { timer?.invalidate() timer = Timer.scheduledTimer( withTimeInterval: 0.25, repeats: false, @@ -72,7 +83,7 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { state = .idle return } - if oldValue != buyAmount && !updatingPriceQuote && !isMakingTx { + if oldValue != buyAmount && !isUpdatingPriceQuote && !isMakingTx { timer?.invalidate() timer = Timer.scheduledTimer( withTimeInterval: 0.25, repeats: false, @@ -98,8 +109,51 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { } } } - /// A boolean indicates if this store is making an unapproved tx + /// A boolean indicating if this store is making an unapproved tx @Published var isMakingTx = false + /// A boolean indicating if this store is fetching updated price quote + @Published var isUpdatingPriceQuote = false + /// The brave fee for the current price quote + @Published var braveFee: BraveWallet.BraveSwapFeeResponse? + /// The SwapResponse / price quote currently being displayed for Ethereum swap. + /// The quote needs preserved to know when to show `protocolFeesForDisplay` fees. + @Published var swapResponse: BraveWallet.SwapResponse? + + /// If the Brave Fee is voided for this swap. + var isBraveFeeVoided: Bool { + guard let braveFee else { return false } + return braveFee.discountCode != .none && !braveFee.hasBraveFee + } + + /// The brave fee percentage for this swap. + /// When `isBraveFeeVoided`, will return the fee being voided (so Free can be displayed beside % value voided) + var braveFeeForDisplay: String? { + guard let braveFee else { return nil } + let fee: String + if braveFee.discountCode == .none { + fee = braveFee.effectiveFeePct + } else { + if braveFee.hasBraveFee { + fee = braveFee.effectiveFeePct + } else { + // Display as `Free ~braveFee.braveFeePct%~` + fee = braveFee.braveFeePct + } + } + return String(format: "%@%%", fee.trimmingTrailingZeros) + } + + /// The protocol fee percentage for this swap + var protocolFeeForDisplay: String? { + guard let braveFee, + let protocolFeePct = Double(braveFee.protocolFeePct), + !protocolFeePct.isZero else { return nil } + if let swapResponse, swapResponse.fees.zeroExFee == nil { + // `protocolFeePct` should only be surfaced to users if `zeroExFee` is non-null. + return nil + } + return String(format: "%@%%", braveFee.protocolFeePct.trimmingTrailingZeros) + } private let keyringService: BraveWalletKeyringService private let blockchainRegistry: BraveWalletBlockchainRegistry @@ -121,7 +175,6 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { }) } } - private var updatingPriceQuote = false private var timer: Timer? private let batSymbol = "BAT" private let daiSymbol = "DAI" @@ -364,13 +417,17 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { } private func clearAllAmount() { - sellAmount = "0" - buyAmount = "0" + sellAmount = "" + buyAmount = "" selectedFromTokenPrice = "0" } /// Update price market and sell/buy amount fields based on `SwapParamsBase` - @MainActor private func handlePriceQuoteResponse(_ response: BraveWallet.SwapResponse, base: SwapParamsBase) async { + @MainActor private func handleEthPriceQuoteResponse( + _ response: BraveWallet.SwapResponse, + base: SwapParamsBase, + swapParams: BraveWallet.SwapParams + ) async { let weiFormatter = WeiFormatter(decimalFormatStyle: .decimals(precision: 18)) switch base { case .perSellAsset: @@ -404,6 +461,22 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { } } } + + self.swapResponse = response + let network = await rpcService.network(selectedFromToken?.coin ?? .eth, origin: nil) + let (braveSwapFeeResponse, _) = await swapService.braveFee( + .init( + chainId: network.chainId, + swapParams: swapParams + ) + ) + if let braveSwapFeeResponse { + self.braveFee = braveSwapFeeResponse + } else { + self.state = .error(Strings.Wallet.unknownError) + self.clearAllAmount() + return + } await checkBalanceShowError(swapResponse: response) } @@ -571,13 +644,9 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { } @MainActor private func fetchSolPriceQuote( - base: SwapParamsBase, swapParams: BraveWallet.SwapParams, network: BraveWallet.NetworkInfo ) async { - guard base == .perSellAsset else { - return // entering buy amount is disabled for Solana swap - } // 0.5% is 50bps. We store 0.5% as 0.005, so multiply by 10_000 let slippageBps = Int32(swapParams.slippagePercentage * 10_000) let jupiterQuoteParams: BraveWallet.JupiterQuoteParams = .init( @@ -587,11 +656,11 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { slippageBps: slippageBps, userPublicKey: swapParams.takerAddress ) - self.updatingPriceQuote = true + self.isUpdatingPriceQuote = true let (jupiterQuote, swapErrorResponse, _) = await swapService.jupiterQuote(jupiterQuoteParams) - defer { self.updatingPriceQuote = false } + defer { self.isUpdatingPriceQuote = false } if let jupiterQuote { - await self.handleSolPriceQuoteResponse(jupiterQuote, base: base) + await self.handleSolPriceQuoteResponse(jupiterQuote, swapParams: swapParams) } else if let swapErrorResponse { // check balance first because error can be caused by insufficient balance if let sellTokenBalance = self.selectedFromTokenBalance, @@ -613,11 +682,13 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { } } - @MainActor private func handleSolPriceQuoteResponse(_ response: BraveWallet.JupiterQuote, base: SwapParamsBase) async { - guard let route = response.routes.first, - base == .perSellAsset // entering buy amount is disabled for Solana swap - else { return } + @MainActor private func handleSolPriceQuoteResponse( + _ response: BraveWallet.JupiterQuote, + swapParams: BraveWallet.SwapParams + ) async { + guard let route = response.routes.first else { return } self.jupiterQuote = response + let formatter = WeiFormatter(decimalFormatStyle: .balance) if let selectedToToken { buyAmount = formatter.decimalString(for: "\(route.otherAmountThreshold)", radix: .decimal, decimals: Int(selectedToToken.decimals)) ?? "" @@ -635,6 +706,21 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { selectedFromTokenPrice = rate.decimalDescription } + let network = await rpcService.network(selectedFromToken?.coin ?? .sol, origin: nil) + let (braveSwapFeeResponse, _) = await swapService.braveFee( + .init( + chainId: network.chainId, + swapParams: swapParams + ) + ) + if let braveSwapFeeResponse { + self.braveFee = braveSwapFeeResponse + } else { + self.state = .error(Strings.Wallet.unknownError) + self.clearAllAmount() + return + } + await checkBalanceShowError(jupiterQuote: response) } @@ -754,14 +840,22 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { func fetchPriceQuote(base: SwapParamsBase) { Task { @MainActor in - // reset jupiter quote before fetching new quote - self.jupiterQuote = nil + // reset quotes before fetching new quote + swapResponse = nil + jupiterQuote = nil + braveFee = nil guard let accountInfo else { self.state = .idle return } let network = await rpcService.network(accountInfo.coin, origin: nil) - guard let swapParams = self.swapParameters(for: base, in: network) else { + // Entering a buy amount is disabled for Solana swaps, always use + // `SwapParamsBase.perSellAsset` to fetch quote based on the sell amount. + // `SwapParamsBase.perBuyAsset` is sent when `selectedToToken` is changed. + guard let swapParams = self.swapParameters( + for: accountInfo.coin == .sol ? .perSellAsset : base, + in: network + ) else { self.state = .idle return } @@ -769,7 +863,7 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { case .eth: await fetchEthPriceQuote(base: base, swapParams: swapParams, network: network) case .sol: - await fetchSolPriceQuote(base: base, swapParams: swapParams, network: network) + await fetchSolPriceQuote(swapParams: swapParams, network: network) default: break } @@ -781,11 +875,11 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { swapParams: BraveWallet.SwapParams, network: BraveWallet.NetworkInfo ) async { - self.updatingPriceQuote = true - defer { self.updatingPriceQuote = false } + self.isUpdatingPriceQuote = true + defer { self.isUpdatingPriceQuote = false } let (swapResponse, swapErrorResponse, _) = await swapService.priceQuote(swapParams) if let swapResponse = swapResponse { - await self.handlePriceQuoteResponse(swapResponse, base: base) + await self.handleEthPriceQuoteResponse(swapResponse, base: base, swapParams: swapParams) } else if let swapErrorResponse = swapErrorResponse { // check balance first because error can cause by insufficient balance if let sellTokenBalance = self.selectedFromTokenBalance, @@ -801,6 +895,9 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore { } self.state = .error(Strings.Wallet.unknownError) self.clearAllAmount() + } else { // unknown error, ex failed parsing zerox quote. + self.state = .error(Strings.Wallet.unknownError) + self.clearAllAmount() } } diff --git a/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift b/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift index 04c71dd2495..31a1bfe167b 100644 --- a/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift +++ b/Sources/BraveWallet/Extensions/BraveWalletExtensions.swift @@ -431,6 +431,17 @@ extension BraveWallet.KeyringId { } } +extension BraveWallet.BraveSwapFeeParams { + convenience init(chainId: String, swapParams: BraveWallet.SwapParams) { + self.init( + chainId: chainId, + inputToken: swapParams.sellToken, + outputToken: swapParams.buyToken, + taker: swapParams.takerAddress + ) + } +} + public extension String { /// Returns true if the string ends with a supported ENS extension. var endsWithSupportedENSExtension: Bool { diff --git a/Sources/BraveWallet/WalletStrings.swift b/Sources/BraveWallet/WalletStrings.swift index 0e3716c7897..f5972caea0c 100644 --- a/Sources/BraveWallet/WalletStrings.swift +++ b/Sources/BraveWallet/WalletStrings.swift @@ -1234,6 +1234,27 @@ extension Strings { value: "Swap selected tokens", comment: "An accessibility message for the swap button below from amount shortcut grids for users to swap the two selected tokens." ) + public static let braveFeeLabel = NSLocalizedString( + "wallet.braveFeeLabel", + tableName: "BraveWallet", + bundle: .module, + value: "Brave Fee: %@", + comment: "The title for Brave Fee label in Swap. The fee percentage is displayed beside the label." + ) + public static let protocolFeeLabel = NSLocalizedString( + "wallet.protocolFeeLabel", + tableName: "BraveWallet", + bundle: .module, + value: "Protocol Fee: %@", + comment: "The title for Protocol Fee label in Swap. The fee percentage is displayed beside the label." + ) + public static let braveSwapFree = NSLocalizedString( + "wallet.braveSwapFree", + tableName: "BraveWallet", + bundle: .module, + value: "Free", + comment: "The text beside the striked-through percentage Brave would normally charge for a swap." + ) public static let transactionCount = NSLocalizedString( "wallet.transactionCount", tableName: "BraveWallet", diff --git a/Tests/BraveWalletTests/SwapTokenStoreTests.swift b/Tests/BraveWalletTests/SwapTokenStoreTests.swift index 95507b64e90..720340cd73b 100644 --- a/Tests/BraveWalletTests/SwapTokenStoreTests.swift +++ b/Tests/BraveWalletTests/SwapTokenStoreTests.swift @@ -249,6 +249,9 @@ class SwapStoreTests: XCTestCase { let swapService = BraveWallet.TestSwapService() swapService._priceQuote = { $1(.init(), nil, "") } swapService._transactionPayload = { $1(.init(), nil, "") } + swapService._braveFee = { params, completion in + completion(nil, "") + } let txService = BraveWallet.TestTxService() txService._addUnapprovedTransaction = { $4(true, "tx-meta-id", "") } let walletService = BraveWallet.TestBraveWalletService() @@ -269,8 +272,27 @@ class SwapStoreTests: XCTestCase { swapService._priceQuote = { _, completion in let swapResponse: BraveWallet.SwapResponse = .init() swapResponse.buyAmount = "2000000000000000000" + // protocol fee should only be shown when non-nil + swapResponse.fees.zeroExFee = .init( + feeType: "", + feeToken: "", + feeAmount: "", + billingType: "" + ) completion(swapResponse, nil, "") } + swapService._braveFee = { params, completion in + let feeResponse = BraveWallet.BraveSwapFeeResponse( + feeParam: "0.00875", + protocolFeePct: "0.15", + braveFeePct: "0.875", + discountOnBraveFeePct: "0", + effectiveFeePct: "0.875", + discountCode: .none, + hasBraveFee: true + ) + completion(feeResponse, "") + } let store = SwapTokenStore( keyringService: keyringService, blockchainRegistry: blockchainRegistry, @@ -287,9 +309,13 @@ class SwapStoreTests: XCTestCase { let buyAmountExpectation = expectation(description: "buyAmountExpectation") store.$buyAmount .dropFirst() - .first() - .sink { buyAmount in + .collect(3) + .sink { buyAmounts in defer { buyAmountExpectation.fulfill() } + guard let buyAmount = buyAmounts.last else { + XCTFail("Expected multiple buyAmount assignments.") + return + } XCTAssertFalse(buyAmount.isEmpty) XCTAssertEqual(buyAmount, "2.0000") } @@ -300,6 +326,9 @@ class SwapStoreTests: XCTestCase { store.setUpTest(sellAmount: "0.01") waitForExpectations(timeout: 1) { error in XCTAssertNil(error) + // Verify fees + XCTAssertEqual(store.braveFeeForDisplay, "0.875%") + XCTAssertEqual(store.protocolFeeForDisplay, "0.15%") } } @@ -309,8 +338,21 @@ class SwapStoreTests: XCTestCase { swapService._priceQuote = { _, completion in let swapResponse: BraveWallet.SwapResponse = .init() swapResponse.sellAmount = "3000000000000000000" + swapResponse.fees.zeroExFee = nil // protocol fee should only be shown when non-nil completion(swapResponse, nil, "") } + swapService._braveFee = { params, completion in + let feeResponse = BraveWallet.BraveSwapFeeResponse( + feeParam: "0.00875", + protocolFeePct: "0.15", + braveFeePct: "0.875", + discountOnBraveFeePct: "0", + effectiveFeePct: "0.875", + discountCode: .none, + hasBraveFee: true + ) + completion(feeResponse, "") + } let store = SwapTokenStore( keyringService: keyringService, blockchainRegistry: blockchainRegistry, @@ -327,19 +369,35 @@ class SwapStoreTests: XCTestCase { let sellAmountExpectation = expectation(description: "sellAmountExpectation") store.$sellAmount .dropFirst() - .first() - .sink { sellAmount in + .collect(3) + .sink { sellAmounts in defer { sellAmountExpectation.fulfill() } + guard let sellAmount = sellAmounts.last else { + XCTFail("Expected multiple sellAmount assignments.") + return + } XCTAssertFalse(sellAmount.isEmpty) XCTAssertEqual(sellAmount, "3.0000") } .store(in: &cancellables) + + let braveFeeExpectation = expectation(description: "braveFeeExpectation") + store.$braveFee + .dropFirst() + .collect(3) + .sink { _ in + braveFeeExpectation.fulfill() + } + .store(in: &cancellables) XCTAssertTrue(store.sellAmount.isEmpty) // calls fetchPriceQuote store.setUpTest(sellAmount: nil, buyAmount: "0.01") waitForExpectations(timeout: 1) { error in XCTAssertNil(error) + // Verify fees + XCTAssertEqual(store.braveFeeForDisplay, "0.875%") + XCTAssertNil(store.protocolFeeForDisplay) } } @@ -363,6 +421,18 @@ class SwapStoreTests: XCTestCase { marketInfos: []) completion(.init(routes: [route]), nil, "") } + swapService._braveFee = { params, completion in + let feeResponse = BraveWallet.BraveSwapFeeResponse( + feeParam: "85", + protocolFeePct: "0", + braveFeePct: "0.85", + discountOnBraveFeePct: "0", + effectiveFeePct: "0.85", + discountCode: .none, + hasBraveFee: true + ) + completion(feeResponse, "") + } let store = SwapTokenStore( keyringService: keyringService, blockchainRegistry: blockchainRegistry, @@ -375,16 +445,30 @@ class SwapStoreTests: XCTestCase { userAssetManager: mockAssetManager, prefilledToken: nil ) - + let buyAmountExpectation = expectation(description: "buyAmountExpectation") store.$buyAmount .dropFirst() - .sink { buyAmount in + .collect(3) + .sink { buyAmounts in defer { buyAmountExpectation.fulfill() } + guard let buyAmount = buyAmounts.last else { + XCTFail("Expected multiple buyAmount assignments.") + return + } XCTAssertFalse(buyAmount.isEmpty) XCTAssertEqual(buyAmount, "2.5000") } .store(in: &cancellables) + + let braveFeeExpectation = expectation(description: "braveFeeExpectation") + store.$braveFee + .dropFirst() + .collect(3) + .sink { _ in + braveFeeExpectation.fulfill() + } + .store(in: &cancellables) XCTAssertTrue(store.buyAmount.isEmpty) // non-empty assignment to `sellAmount` calls fetchPriceQuote @@ -396,6 +480,9 @@ class SwapStoreTests: XCTestCase { ) waitForExpectations(timeout: 1) { error in XCTAssertNil(error) + // Verify fees + XCTAssertEqual(store.braveFeeForDisplay, "0.85%") + XCTAssertNil(store.protocolFeeForDisplay) } } @@ -430,8 +517,13 @@ class SwapStoreTests: XCTestCase { let stateExpectation = expectation(description: "stateExpectation") store.$state .dropFirst() - .sink { state in + .collect(5) // sellAmount, buyAmount didSet to `.idle` + .sink { states in defer { stateExpectation.fulfill() } + guard let state = states.last else { + XCTFail("Expected multiple state updates.") + return + } XCTAssertEqual(state, .error(Strings.Wallet.insufficientLiquidity)) } .store(in: &cancellables)