1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-10-23 11:46:13 +02:00
metamask-extension/ui/app/pages/swaps/swaps.util.js
Mark Stacey 4cab30eae1
Remove duplicate percent sign from MetaMask fee (#9647)
The MetaMask fee is shown with two percent signs on the view quote page, because the percent sign is embedded in the fee amount as well as in the localized message.

The fee amount used now comes from the API, and does not have a percent sign. The percent sign is now only in the localized message. This allows for different locales to display the percentage differently. The old hard-coded value with a percent sign embedded has been removed, as it is no longer used anywhere.
2020-10-19 20:58:48 -02:30

420 lines
13 KiB
JavaScript

import log from 'loglevel'
import BigNumber from 'bignumber.js'
import abi from 'human-standard-token-abi'
import { isValidAddress } from 'ethereumjs-util'
import { calcTokenValue, calcTokenAmount } from '../../helpers/utils/token-util'
import { constructTxParams, toPrecisionWithoutTrailingZeros } from '../../helpers/utils/util'
import { decimalToHex, getValueFromWeiHex } from '../../helpers/utils/conversions.util'
import { subtractCurrencies } from '../../helpers/utils/conversion-util'
import { formatCurrency } from '../../helpers/utils/confirm-tx.util'
import fetchWithCache from '../../helpers/utils/fetch-with-cache'
import { calcGasTotal } from '../send/send.utils'
const TOKEN_TRANSFER_LOG_TOPIC_HASH = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
const CACHE_REFRESH_ONE_HOUR = 3600000
const getBaseApi = function (type) {
switch (type) {
case 'trade':
return `https://api.metaswap.codefi.network/trades?`
case 'tokens':
return `https://api.metaswap.codefi.network/tokens`
case 'topAssets':
return `https://api.metaswap.codefi.network/topAssets`
case 'featureFlag':
return `https://api.metaswap.codefi.network/featureFlag`
case 'aggregatorMetadata':
return `https://api.metaswap.codefi.network/aggregatorMetadata`
case 'feeAmount':
return `https://api.metaswap.codefi.network/fee`
default:
throw new Error('getBaseApi requires an api call type')
}
}
const validHex = (string) => Boolean(string?.match(/^0x[a-f0-9]+$/u))
const truthyString = (string) => Boolean(string?.length)
const truthyDigitString = (string) => truthyString(string) && Boolean(string.match(/^\d+$/u))
const QUOTE_VALIDATORS = [
{
property: 'trade',
type: 'object',
validator: (trade) => trade && validHex(trade.data) && isValidAddress(trade.to) && isValidAddress(trade.from) && truthyString(trade.value),
},
{
property: 'approvalNeeded',
type: 'object',
validator: (approvalTx) => (
approvalTx === null ||
(
approvalTx &&
validHex(approvalTx.data) &&
isValidAddress(approvalTx.to) &&
isValidAddress(approvalTx.from)
)
),
},
{
property: 'sourceAmount',
type: 'string',
validator: truthyDigitString,
},
{
property: 'destinationAmount',
type: 'string',
validator: truthyDigitString,
},
{
property: 'sourceToken',
type: 'string',
validator: isValidAddress,
},
{
property: 'destinationToken',
type: 'string',
validator: isValidAddress,
},
{
property: 'aggregator',
type: 'string',
validator: truthyString,
},
{
property: 'aggType',
type: 'string',
validator: truthyString,
},
{
property: 'error',
type: 'object',
validator: (error) => error === null || typeof error === 'object',
},
{
property: 'averageGas',
type: 'number',
},
{
property: 'maxGas',
type: 'number',
},
]
const TOKEN_VALIDATORS = [
{
property: 'address',
type: 'string',
validator: isValidAddress,
},
{
property: 'symbol',
type: 'string',
validator: (string) => truthyString(string) && string.length <= 12,
},
{
property: 'decimals',
type: 'string|number',
validator: (string) => Number(string) >= 0 && Number(string) <= 36,
},
]
const TOP_ASSET_VALIDATORS = TOKEN_VALIDATORS.slice(0, 2)
const AGGREGATOR_METADATA_VALIDATORS = [
{
property: 'color',
type: 'string',
validator: (string) => Boolean(string.match(/^#[A-Fa-f0-9]+$/u)),
},
{
property: 'title',
type: 'string',
validator: truthyString,
},
{
property: 'icon',
type: 'string',
validator: (string) => Boolean(string.match(/^data:image/u)),
},
]
function validateData (validators, object, urlUsed) {
return validators.every(({ property, type, validator }) => {
const types = type.split('|')
const valid = types.some((_type) => typeof object[property] === _type) && (!validator || validator(object[property]))
if (!valid) {
log.error(`response to GET ${urlUsed} invalid for property ${property}; value was:`, object[property], '| type was: ', typeof object[property])
}
return valid
})
}
export async function fetchTradesInfo ({
slippage,
sourceToken,
sourceDecimals,
destinationToken,
value,
fromAddress,
exchangeList,
}) {
const urlParams = {
destinationToken,
sourceToken,
sourceAmount: calcTokenValue(value, sourceDecimals).toString(10),
slippage,
timeout: 10000,
walletAddress: fromAddress,
}
if (exchangeList) {
urlParams.exchangeList = exchangeList
}
const queryString = new URLSearchParams(urlParams).toString()
const tradeURL = `${getBaseApi('trade')}${queryString}`
const tradesResponse = await fetchWithCache(tradeURL, { method: 'GET' }, { cacheRefreshTime: 0, timeout: 15000 })
const newQuotes = tradesResponse.reduce((aggIdTradeMap, quote) => {
if (quote.trade && !quote.error && validateData(QUOTE_VALIDATORS, quote, tradeURL)) {
const constructedTrade = constructTxParams({
to: quote.trade.to,
from: quote.trade.from,
data: quote.trade.data,
amount: decimalToHex(quote.trade.value),
gas: decimalToHex(quote.maxGas),
})
let { approvalNeeded } = quote
if (approvalNeeded) {
approvalNeeded = constructTxParams({
...approvalNeeded,
})
}
return {
...aggIdTradeMap,
[quote.aggregator]: {
...quote,
slippage,
trade: constructedTrade,
approvalNeeded,
},
}
}
return aggIdTradeMap
}, {})
return newQuotes
}
export async function fetchTokens () {
const tokenUrl = getBaseApi('tokens')
const tokens = await fetchWithCache(tokenUrl, { method: 'GET' }, { cacheRefreshTime: CACHE_REFRESH_ONE_HOUR })
const filteredTokens = tokens.filter((token) => validateData(TOKEN_VALIDATORS, token, tokenUrl))
return filteredTokens
}
export async function fetchAggregatorMetadata () {
const aggregatorMetadataUrl = getBaseApi('aggregatorMetadata')
const aggregators = await fetchWithCache(aggregatorMetadataUrl, { method: 'GET' }, { cacheRefreshTime: CACHE_REFRESH_ONE_HOUR })
const filteredAggregators = {}
for (const aggKey in aggregators) {
if (validateData(AGGREGATOR_METADATA_VALIDATORS, aggregators[aggKey], aggregatorMetadataUrl)) {
filteredAggregators[aggKey] = aggregators[aggKey]
}
}
return filteredAggregators
}
export async function fetchTopAssets () {
const topAssetsUrl = getBaseApi('topAssets')
const response = await fetchWithCache(topAssetsUrl, { method: 'GET' }, { cacheRefreshTime: CACHE_REFRESH_ONE_HOUR })
const topAssetsMap = response.reduce((_topAssetsMap, asset, index) => {
if (validateData(TOP_ASSET_VALIDATORS, asset, topAssetsUrl)) {
return { ..._topAssetsMap, [asset.address]: { index: String(index) } }
}
return _topAssetsMap
}, {})
return topAssetsMap
}
export async function fetchSwapsFeatureLiveness () {
const status = await fetchWithCache(getBaseApi('featureFlag'), { method: 'GET' }, { cacheRefreshTime: 600000 })
return status?.active
}
export async function fetchMetaMaskFeeAmount () {
const response = await fetchWithCache(getBaseApi('feeAmount'), { method: 'GET' }, { cacheRefreshTime: 600000 })
return response?.fee
}
export async function fetchTokenPrice (address) {
const query = `contract_addresses=${address}&vs_currencies=eth`
const prices = await fetchWithCache(`https://api.coingecko.com/api/v3/simple/token_price/ethereum?${query}`, { method: 'GET' }, { cacheRefreshTime: 60000 })
return prices && prices[address]?.eth
}
export async function fetchTokenBalance (address, userAddress) {
const tokenContract = global.eth.contract(abi).at(address)
const tokenBalancePromise = tokenContract
? tokenContract.balanceOf(userAddress)
: Promise.resolve()
const usersToken = await tokenBalancePromise
return usersToken
}
export function getRenderableGasFeesForQuote (tradeGas, approveGas, gasPrice, currentCurrency, conversionRate) {
const totalGasLimitForCalculation = (new BigNumber(tradeGas || '0x0', 16)).plus(approveGas || '0x0', 16).toString(16)
const gasTotalInWeiHex = calcGasTotal(totalGasLimitForCalculation, gasPrice)
const ethFee = getValueFromWeiHex({
value: gasTotalInWeiHex,
toDenomination: 'ETH',
numberOfDecimals: 6,
})
const rawNetworkFees = getValueFromWeiHex({
value: gasTotalInWeiHex,
toCurrency: currentCurrency,
conversionRate,
numberOfDecimals: 2,
})
const formattedNetworkFee = formatCurrency(rawNetworkFees, currentCurrency)
return {
rawNetworkFees,
rawEthFee: ethFee,
feeInFiat: formattedNetworkFee,
feeInEth: `${ethFee} ETH`,
}
}
export function quotesToRenderableData (quotes, gasPrice, conversionRate, currentCurrency, approveGas, tokenConversionRates, customGasLimit) {
return Object.values(quotes).map((quote) => {
const { destinationAmount = 0, sourceAmount = 0, sourceTokenInfo, destinationTokenInfo, slippage, aggType, aggregator, gasEstimateWithRefund, averageGas } = quote
const sourceValue = calcTokenAmount(sourceAmount, sourceTokenInfo.decimals || 18).toString(10)
const destinationValue = calcTokenAmount(destinationAmount, destinationTokenInfo.decimals || 18).toPrecision(8)
const {
feeInFiat,
rawNetworkFees,
rawEthFee,
feeInEth,
} = getRenderableGasFeesForQuote(
(
customGasLimit ||
gasEstimateWithRefund ||
decimalToHex(averageGas || 800000)
),
approveGas,
gasPrice,
currentCurrency,
conversionRate,
)
const slippageMultiplier = (new BigNumber(100 - slippage)).div(100)
const minimumAmountReceived = (new BigNumber(destinationValue)).times(slippageMultiplier).toFixed(6)
const tokenConversionRate = tokenConversionRates[destinationTokenInfo.address]
const ethValueOfTrade = destinationTokenInfo.symbol === 'ETH'
? calcTokenAmount(destinationAmount, destinationTokenInfo.decimals || 18).minus(rawEthFee, 10)
: (new BigNumber(tokenConversionRate || 0, 10))
.times(calcTokenAmount(destinationAmount, destinationTokenInfo.decimals || 18), 10)
.minus(rawEthFee, 10)
let liquiditySourceKey
let renderedSlippage = slippage
if (aggType === 'AGG') {
liquiditySourceKey = 'swapAggregator'
} else if (aggType === 'RFQ') {
liquiditySourceKey = 'swapRequestForQuotation'
renderedSlippage = 0
} else if (aggType === 'DEX') {
liquiditySourceKey = 'swapDecentralizedExchange'
} else {
liquiditySourceKey = 'swapUnknown'
}
return {
aggId: aggregator,
amountReceiving: `${destinationValue} ${destinationTokenInfo.symbol}`,
destinationTokenDecimals: destinationTokenInfo.decimals,
destinationTokenSymbol: destinationTokenInfo.symbol,
destinationTokenValue: formatSwapsValueForDisplay(destinationValue),
isBestQuote: quote.isBestQuote,
liquiditySourceKey,
feeInEth,
detailedNetworkFees: `${feeInEth} (${feeInFiat})`,
networkFees: feeInFiat,
quoteSource: aggType,
rawNetworkFees,
slippage: renderedSlippage,
sourceTokenDecimals: sourceTokenInfo.decimals,
sourceTokenSymbol: sourceTokenInfo.symbol,
sourceTokenValue: sourceValue,
ethValueOfTrade,
minimumAmountReceived,
}
})
}
export function getSwapsTokensReceivedFromTxMeta (tokenSymbol, txMeta, tokenAddress, accountAddress, tokenDecimals, approvalTxMeta) {
const txReceipt = txMeta?.txReceipt
if (tokenSymbol === 'ETH') {
if (!txReceipt || !txMeta || !txMeta.postTxBalance || !txMeta.preTxBalance) {
return null
}
let approvalTxGasCost = '0x0'
if (approvalTxMeta && approvalTxMeta.txReceipt) {
approvalTxGasCost = calcGasTotal(approvalTxMeta.txReceipt.gasUsed, approvalTxMeta.txParams.gasPrice)
}
const gasCost = calcGasTotal(txReceipt.gasUsed, txMeta.txParams.gasPrice)
const totalGasCost = (new BigNumber(gasCost, 16)).plus(approvalTxGasCost, 16).toString(16)
const preTxBalanceLessGasCost = subtractCurrencies(txMeta.preTxBalance, totalGasCost, {
aBase: 16,
bBase: 16,
toNumericBase: 'hex',
})
const ethReceived = subtractCurrencies(txMeta.postTxBalance, preTxBalanceLessGasCost, {
aBase: 16,
bBase: 16,
fromDenomination: 'WEI',
toDenomination: 'ETH',
toNumericBase: 'dec',
numberOfDecimals: 6,
})
return ethReceived
}
const txReceiptLogs = txReceipt?.logs
if (txReceiptLogs && txReceipt?.status !== '0x0') {
const tokenTransferLog = txReceiptLogs.find((txReceiptLog) => {
const isTokenTransfer = txReceiptLog.topics && txReceiptLog.topics[0] === TOKEN_TRANSFER_LOG_TOPIC_HASH
const isTransferFromGivenToken = txReceiptLog.address === tokenAddress
const isTransferFromGivenAddress = txReceiptLog.topics && txReceiptLog.topics[2] && txReceiptLog.topics[2].match(accountAddress.slice(2))
return isTokenTransfer && isTransferFromGivenToken && isTransferFromGivenAddress
})
return tokenTransferLog
? toPrecisionWithoutTrailingZeros(
calcTokenAmount(tokenTransferLog.data, tokenDecimals).toString(10), 6,
)
: ''
}
return null
}
export function formatSwapsValueForDisplay (destinationAmount) {
let amountToDisplay = toPrecisionWithoutTrailingZeros(destinationAmount, 12)
if (amountToDisplay.match(/e[+-]/u)) {
amountToDisplay = (new BigNumber(amountToDisplay)).toFixed()
}
return amountToDisplay
}