1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-18 07:23:21 +01:00
metamask-extension/ui/app/pages/swaps/swaps.util.js

643 lines
16 KiB
JavaScript

import log from 'loglevel'
import BigNumber from 'bignumber.js'
import abi from 'human-standard-token-abi'
import { isValidAddress } from 'ethereumjs-util'
import { ETH_SWAPS_TOKEN_OBJECT } from '../../helpers/constants/swaps'
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 METASWAP_API_HOST = 'https://api.metaswap.codefi.network'
const getBaseApi = function (type) {
switch (type) {
case 'trade':
return `${METASWAP_API_HOST}/trades?`
case 'tokens':
return `${METASWAP_API_HOST}/tokens`
case 'topAssets':
return `${METASWAP_API_HOST}/topAssets`
case 'featureFlag':
return `${METASWAP_API_HOST}/featureFlag`
case 'aggregatorMetadata':
return `${METASWAP_API_HOST}/aggregatorMetadata`
case 'gasPrices':
return `${METASWAP_API_HOST}/gasPrices`
case 'refreshTime':
return `${METASWAP_API_HOST}/quoteRefreshRate`
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',
},
{
property: 'gasEstimate',
type: 'number|undefined',
validator: (gasEstimate) => gasEstimate === undefined || gasEstimate > 0,
},
]
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)),
},
]
const isValidDecimalNumber = (string) =>
!isNaN(string) && string.match(/^[.0-9]+$/u) && !isNaN(parseFloat(string))
const SWAP_GAS_PRICE_VALIDATOR = [
{
property: 'SafeGasPrice',
type: 'string',
validator: isValidDecimalNumber,
},
{
property: 'ProposeGasPrice',
type: 'string',
validator: isValidDecimalNumber,
},
{
property: 'FastGasPrice',
type: 'string',
validator: isValidDecimalNumber,
},
]
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) => {
return (
validateData(TOKEN_VALIDATORS, token, tokenUrl) &&
token.address !== ETH_SWAPS_TOKEN_OBJECT.address
)
})
filteredTokens.push(ETH_SWAPS_TOKEN_OBJECT)
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 fetchSwapsQuoteRefreshTime() {
const response = await fetchWithCache(
getBaseApi('refreshTime'),
{ method: 'GET' },
{ cacheRefreshTime: 600000 },
)
// We presently use milliseconds in the UI
if (typeof response?.seconds === 'number' && response.seconds > 0) {
return response.seconds * 1000
}
throw new Error(
`MetaMask - refreshTime provided invalid response: ${response}`,
)
}
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 async function fetchSwapsGasPrices() {
const gasPricesUrl = getBaseApi('gasPrices')
const response = await fetchWithCache(
gasPricesUrl,
{ method: 'GET' },
{ cacheRefreshTime: 15000 },
)
const responseIsValid = validateData(
SWAP_GAS_PRICE_VALIDATOR,
response,
gasPricesUrl,
)
if (!responseIsValid) {
throw new Error(`${gasPricesUrl} response is invalid`)
}
const {
SafeGasPrice: safeLow,
ProposeGasPrice: average,
FastGasPrice: fast,
} = response
return {
safeLow,
average,
fast,
}
}
export function getRenderableNetworkFeesForQuote(
tradeGas,
approveGas,
gasPrice,
currentCurrency,
conversionRate,
tradeValue,
sourceSymbol,
sourceAmount,
) {
const totalGasLimitForCalculation = new BigNumber(tradeGas || '0x0', 16)
.plus(approveGas || '0x0', 16)
.toString(16)
const gasTotalInWeiHex = calcGasTotal(totalGasLimitForCalculation, gasPrice)
const nonGasFee = new BigNumber(tradeValue, 16)
.minus(sourceSymbol === 'ETH' ? sourceAmount : 0, 10)
.toString(16)
const totalWeiCost = new BigNumber(gasTotalInWeiHex, 16)
.plus(nonGasFee, 16)
.toString(16)
const ethFee = getValueFromWeiHex({
value: totalWeiCost,
toDenomination: 'ETH',
numberOfDecimals: 5,
})
const rawNetworkFees = getValueFromWeiHex({
value: totalWeiCost,
toCurrency: currentCurrency,
conversionRate,
numberOfDecimals: 2,
})
const formattedNetworkFee = formatCurrency(rawNetworkFees, currentCurrency)
return {
rawNetworkFees,
rawEthFee: ethFee,
feeInFiat: formattedNetworkFee,
feeInEth: `${ethFee} ETH`,
nonGasFee,
}
}
export function quotesToRenderableData(
quotes,
gasPrice,
conversionRate,
currentCurrency,
approveGas,
tokenConversionRates,
) {
return Object.values(quotes).map((quote) => {
const {
destinationAmount = 0,
sourceAmount = 0,
sourceTokenInfo,
destinationTokenInfo,
slippage,
aggType,
aggregator,
gasEstimateWithRefund,
averageGas,
fee,
trade,
} = quote
const sourceValue = calcTokenAmount(
sourceAmount,
sourceTokenInfo.decimals,
).toString(10)
const destinationValue = calcTokenAmount(
destinationAmount,
destinationTokenInfo.decimals,
).toPrecision(8)
const {
feeInFiat,
rawNetworkFees,
rawEthFee,
feeInEth,
} = getRenderableNetworkFeesForQuote(
gasEstimateWithRefund || decimalToHex(averageGas || 800000),
approveGas,
gasPrice,
currentCurrency,
conversionRate,
trade.value,
sourceTokenInfo.symbol,
sourceAmount,
)
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,
).minus(rawEthFee, 10)
: new BigNumber(tokenConversionRate || 0, 10)
.times(
calcTokenAmount(destinationAmount, destinationTokenInfo.decimals),
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),
destinationIconUrl: destinationTokenInfo.iconUrl,
isBestQuote: quote.isBestQuote,
liquiditySourceKey,
feeInEth,
detailedNetworkFees: `${feeInEth} (${feeInFiat})`,
networkFees: feeInFiat,
quoteSource: aggType,
rawNetworkFees,
slippage: renderedSlippage,
sourceTokenDecimals: sourceTokenInfo.decimals,
sourceTokenSymbol: sourceTokenInfo.symbol,
sourceTokenValue: sourceValue,
sourceTokenIconUrl: sourceTokenInfo.iconUrl,
ethValueOfTrade,
minimumAmountReceived,
metaMaskFee: fee,
}
})
}
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
}