diff --git a/packages/checkout/widgets-lib/src/widgets/sale/functions/fundingBalanceFees.test.ts b/packages/checkout/widgets-lib/src/widgets/sale/functions/fundingBalanceFees.test.ts new file mode 100644 index 0000000000..90edaa6b1e --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/sale/functions/fundingBalanceFees.test.ts @@ -0,0 +1,198 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { BigNumber } from 'ethers'; +import { FeeType } from '@imtbl/checkout-sdk'; +import { FundingBalance, FundingBalanceType } from '../types'; +import { + FeesBySymbol, + getFundingBalanceFeeBreakDown, + getFundingBalanceTotalFees, +} from './fundingBalanceFees'; + +export const SwapFundingBalanceMock = { + type: 'SWAP', + chainId: 13473, + fundingItem: { + type: 'ERC20', + fundsRequired: { + amount: BigNumber.from('500000000000000000'), + formattedAmount: '5', + }, + userBalance: { + balance: BigNumber.from('100000000000000000'), + formattedBalance: '10', + }, + token: { + address: '0x4B96E7b7eA673A996F140d5De411a97b7eab934E', + circulating_market_cap: null, + decimals: 18, + exchange_rate: null, + holders: '46', + icon_url: null, + name: 'Core', + symbol: 'zkCORE', + total_supply: '1000000000000000000000000000', + type: 'ERC-20', + volume_24h: null, + }, + }, + fees: { + approvalGasFee: { + type: 'GAS', + amount: BigNumber.from('10000000000000'), + formattedAmount: '0.0001', + token: { + name: 'Immutable Testnet Token', + symbol: 'tIMX', + address: 'native', + decimals: 18, + }, + }, + swapGasFee: { + type: 'GAS', + amount: BigNumber.from('200000000000000'), + formattedAmount: '0.002', + token: { + name: 'Immutable Testnet Token', + symbol: 'tIMX', + address: 'native', + decimals: 18, + }, + }, + swapFees: [ + { + type: 'SWAP_FEE', + amount: BigNumber.from('500000000000000000'), + formattedAmount: '0.5', + token: { + name: 'Core', + symbol: 'zkCORE', + address: '0x4B96E7b7eA673A996F140d5De411a97b7eab934E', + decimals: 18, + }, + }, + { + type: 'SWAP_FEE', + amount: BigNumber.from('100000000000000000'), + formattedAmount: '0.1', + token: { + name: 'Core', + symbol: 'zkCORE', + address: '0x4B96E7b7eA673A996F140d5De411a97b7eab934E', + decimals: 18, + }, + }, + ], + }, +} as FundingBalance; + +describe('getFundingBalanceTotalFees', () => { + it('should return empty when no fees', () => { + expect( + getFundingBalanceTotalFees({ + type: FundingBalanceType.SUFFICIENT, + } as FundingBalance), + ).toEqual({}); + }); + + it('should get total fee amount by symbol', () => { + const expected: FeesBySymbol = { + tIMX: { + type: FeeType.GAS, + amount: expect.any(Object), + formattedAmount: '0.00021', // sum amount of all tIMX + token: { + name: 'Immutable Testnet Token', + symbol: 'tIMX', + address: 'native', + decimals: 18, + }, + }, + zkCORE: { + type: FeeType.SWAP_FEE, + amount: expect.any(Object), + formattedAmount: '0.6', // sum amount of all zkCORE + token: { + name: 'Core', + symbol: 'zkCORE', + address: '0x4B96E7b7eA673A996F140d5De411a97b7eab934E', + decimals: 18, + }, + }, + }; + + const result = getFundingBalanceTotalFees(SwapFundingBalanceMock); + expect(result).toEqual(expected); + }); +}); + +describe('getFundingBalanceFeeBreakDown', () => { + const conversionsMock: Map = new Map([ + ['tIMX', 2.3], + ['zkCORE', 0.7], + ]); + + const t = ((v: unknown) => v) as any; + + it('should return empty when no fees', () => { + const sufficientFundingBalanceMock = { + type: FundingBalanceType.SUFFICIENT, + } as unknown as FundingBalance; + + expect( + getFundingBalanceFeeBreakDown( + sufficientFundingBalanceMock, + conversionsMock, + t, + ), + ).toEqual([]); + }); + + it('should return fee breakdowns', () => { + const expected = [ + { + amount: '0.000200', + fiatAmount: '≈ drawers.feesBreakdown.fees.fiatPricePrefix-.--', + label: 'drawers.feesBreakdown.fees.swapGasFee.label', + prefix: '~ ', + token: { + address: 'native', + decimals: 18, + name: 'Immutable Testnet Token', + symbol: 'tIMX', + }, + }, + { + amount: '0.000010', + fiatAmount: '≈ drawers.feesBreakdown.fees.fiatPricePrefix-.--', + label: 'drawers.feesBreakdown.fees.approvalFee.label', + prefix: '~ ', + token: { + address: 'native', + decimals: 18, + name: 'Immutable Testnet Token', + symbol: 'tIMX', + }, + }, + { + amount: '0.600000', + fiatAmount: '≈ drawers.feesBreakdown.fees.fiatPricePrefix-.--', + label: 'drawers.feesBreakdown.fees.swapSecondaryFee.label', + prefix: '', + token: { + address: '0x4B96E7b7eA673A996F140d5De411a97b7eab934E', + decimals: 18, + name: 'Core', + symbol: 'zkCORE', + }, + }, + ]; + + const result = getFundingBalanceFeeBreakDown( + SwapFundingBalanceMock, + conversionsMock, + t, + ); + + expect(result).toEqual(expected); + }); +}); diff --git a/packages/checkout/widgets-lib/src/widgets/sale/functions/fundingBalanceFees.ts b/packages/checkout/widgets-lib/src/widgets/sale/functions/fundingBalanceFees.ts new file mode 100644 index 0000000000..80af405f7d --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/sale/functions/fundingBalanceFees.ts @@ -0,0 +1,140 @@ +import { utils } from 'ethers'; +import { Fee, FundingStepType, TokenInfo } from '@imtbl/checkout-sdk'; +import { + calculateCryptoToFiat, + abbreviateWalletAddress, + tokenValueFormat, +} from 'lib/utils'; +import { FormattedFee } from 'widgets/swap/functions/swapFees'; +import { TFunction } from 'i18next'; +import { FundingBalance } from '../types'; + +export type FeesBySymbol = Record; + +const getTotalFeesBySymbol = ( + fees: Fee[], + tokenInfo?: TokenInfo, +): FeesBySymbol => fees + .filter((fee) => fee.amount.gt(0) && fee.token) + .reduce((acc, fee) => { + if (!fee.token) return acc; + + const token: TokenInfo = { + ...fee.token, + address: fee.token.address || '', + symbol: fee.token.symbol || tokenInfo?.symbol || '', + }; + + const address = abbreviateWalletAddress( + token.address!, + '...', + ).toLowerCase(); + const key = token.symbol || address; + if (!key) return acc; + + if (acc[key]) { + const newAmount = acc[key].amount.add(fee.amount); + return { + ...acc, + [key]: { + ...acc[key], + amount: newAmount, + formattedAmount: utils.formatUnits(newAmount, token.decimals), + }, + }; + } + + if (key) { + return { + ...acc, + [key]: { + ...fee, + token, + formattedAmount: utils.formatUnits(fee.amount, token.decimals), + }, + }; + } + + return acc; + }, {} as FeesBySymbol); + +export const getFundingBalanceTotalFees = ( + balance: FundingBalance, +): FeesBySymbol => { + if (balance.type !== FundingStepType.SWAP) { + return {}; + } + + const fees = [ + balance.fees.approvalGasFee, + balance.fees.swapGasFee, + ...balance.fees.swapFees, + ]; + const totalFees = getTotalFeesBySymbol(fees, balance.fundingItem.token); + + return totalFees; +}; + +export const getFundingBalanceFeeBreakDown = ( + balance: FundingBalance, + conversions: Map, + t: TFunction, +): FormattedFee[] => { + const feesBreakdown: FormattedFee[] = []; + + if (balance.type !== FundingStepType.SWAP) { + return []; + } + + const addFee = (fee: Fee, label: string, prefix: string = '~ ') => { + if (fee.amount.gt(0)) { + const formattedFee = utils.formatUnits(fee.amount, fee?.token?.decimals); + + feesBreakdown.push({ + label, + fiatAmount: `≈ ${t( + 'drawers.feesBreakdown.fees.fiatPricePrefix', + )}${calculateCryptoToFiat( + formattedFee, + fee.token?.symbol || '', + conversions, + '-.--', + 4, + )}`, + amount: `${tokenValueFormat(formattedFee)}`, + prefix, + token: fee?.token!, + }); + } + }; + + // Format gas fee + addFee( + balance.fees.swapGasFee, + t('drawers.feesBreakdown.fees.swapGasFee.label'), + ); + + // Format gas fee approval + addFee( + balance.fees.approvalGasFee, + t('drawers.feesBreakdown.fees.approvalFee.label'), + ); + + // Format the secondary fees + const totalSwapFeesBySymbol = Object.entries( + getTotalFeesBySymbol(balance.fees.swapFees, balance.fundingItem.token), + ); + + totalSwapFeesBySymbol.forEach(([, swapFee]) => { + const basisPoints: number = swapFee?.basisPoints ?? 0; + addFee( + swapFee, + t('drawers.feesBreakdown.fees.swapSecondaryFee.label', { + amount: basisPoints ? `${basisPoints / 100}%` : '', + }), + '', + ); + }); + + return feesBreakdown; +};