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

Fix #7924: Swap protocol fees #8233

Merged
merged 2 commits into from
Oct 16, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 23 additions & 26 deletions Sources/BraveWallet/Crypto/BuySendSwap/SwapCryptoView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
139 changes: 115 additions & 24 deletions Sources/BraveWallet/Crypto/Stores/SwapTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,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,
Expand All @@ -72,7 +77,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,
Expand All @@ -98,8 +103,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
Expand All @@ -121,7 +169,6 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore {
})
}
}
private var updatingPriceQuote = false
private var timer: Timer?
private let batSymbol = "BAT"
private let daiSymbol = "DAI"
Expand Down Expand Up @@ -370,7 +417,11 @@ public class SwapTokenStore: ObservableObject, WalletObserverStore {
}

/// 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:
Expand Down Expand Up @@ -404,6 +455,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)
}
Expand Down Expand Up @@ -571,13 +638,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(
Expand All @@ -587,11 +650,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,
Expand All @@ -613,11 +676,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)) ?? ""
Expand All @@ -635,6 +700,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)
}

Expand Down Expand Up @@ -754,22 +834,30 @@ 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
}
switch accountInfo.coin {
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
}
Expand All @@ -781,11 +869,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,
Expand All @@ -801,6 +889,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()
}
}

Expand Down
11 changes: 11 additions & 0 deletions Sources/BraveWallet/Extensions/BraveWalletExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions Sources/BraveWallet/WalletStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading