diff --git a/packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.test.ts b/packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.test.ts index 7f33d99db..1fc66df38 100644 --- a/packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.test.ts +++ b/packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.test.ts @@ -4,6 +4,28 @@ import { ChainTypes } from '@shapeshiftoss/types' import { ChainAdapterManager } from '@shapeshiftoss/chain-adapters' import { approvalNeeded } from './approvalNeeded' import { setupQuote } from '../utils/test-data/setupSwapQuote' +import { zrxService } from '../utils/zrxService' +import { APPROVAL_GAS_LIMIT } from '../utils/constants' + +jest.mock('web3') +jest.mock('axios', () => ({ + create: jest.fn(() => ({ + get: jest.fn() + })) +})) + +// @ts-ignore +Web3.mockImplementation(() => ({ + eth: { + Contract: jest.fn(() => ({ + methods: { + allowance: jest.fn(() => ({ + call: jest.fn() + })) + } + })) + } +})) const setup = () => { const unchainedUrls = { @@ -18,13 +40,76 @@ const setup = () => { } describe('approvalNeeded', () => { - const args = setup() - const wallet = {} + const { web3, adapterManager } = setup() + const args = { web3, adapterManager } + const walletAddress = '0xc770eefad204b5180df6a14ee197d99d808ee52d' + const wallet = ({ + ethGetAddress: jest.fn(() => Promise.resolve(walletAddress)) + } as unknown) as HDWallet + const { quoteInput, sellAsset } = setupQuote() it('returns false if sellAsset symbol is ETH', async () => { - const input = { quote: { ...quoteInput, sellAsset: { ...sellAsset, symbol: 'ETH' } }, wallet } + const input = { + quote: { ...quoteInput, sellAsset: { ...sellAsset, symbol: 'ETH' } }, + wallet + } + + expect(await approvalNeeded(args, input)).toEqual({ approvalNeeded: false }) + }) + + it('returns false if sellAsset chain is not ETH', async () => { + const input = { + quote: { ...quoteInput, sellAsset: { ...sellAsset, chain: ChainTypes.Bitcoin } }, + wallet + } expect(await approvalNeeded(args, input)).toEqual({ approvalNeeded: false }) }) + + it('returns false if allowanceOnChain is greater than quote.sellAmount', async () => { + const allowanceOnChain = '50' + const data = { gasPrice: '1000', allowanceTarget: '10' } + const input = { + quote: { ...quoteInput, sellAmount: '10' }, + wallet + } + ;(web3.eth.Contract as jest.Mock).mockImplementation(() => ({ + methods: { + allowance: jest.fn(() => ({ + call: jest.fn(() => allowanceOnChain) + })) + } + })) + ;(zrxService.get as jest.Mock).mockReturnValue(Promise.resolve({ data })) + + expect(await approvalNeeded(args, input)).toEqual({ + approvalNeeded: false, + gas: APPROVAL_GAS_LIMIT, + gasPrice: data.gasPrice + }) + }) + + it('returns true if allowanceOnChain is less than quote.sellAmount', async () => { + const allowanceOnChain = '5' + const data = { gasPrice: '1000', allowanceTarget: '10' } + const input = { + quote: { ...quoteInput, sellAmount: '10' }, + wallet + } + ;(web3.eth.Contract as jest.Mock).mockImplementation(() => ({ + methods: { + allowance: jest.fn(() => ({ + call: jest.fn(() => allowanceOnChain) + })) + } + })) + ;(zrxService.get as jest.Mock).mockReturnValue(Promise.resolve({ data })) + + expect(await approvalNeeded(args, input)).toEqual({ + approvalNeeded: true, + gas: APPROVAL_GAS_LIMIT, + gasPrice: data.gasPrice + }) + }) }) diff --git a/packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.ts b/packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.ts index 66f14bfe8..5a1953564 100644 --- a/packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.ts +++ b/packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.ts @@ -1,14 +1,27 @@ +import { BigNumber } from 'bignumber.js' import { AxiosResponse } from 'axios' import { ChainAdapter } from '@shapeshiftoss/chain-adapters' -import { ApprovalNeededInput, ApprovalNeededOutput, ChainTypes, QuoteResponse } from '@shapeshiftoss/types' -import { DEFAULT_SLIPPAGE, AFFILIATE_ADDRESS, DEFAULT_ETH_PATH } from '../utils/constants' +import { + ApprovalNeededInput, + ApprovalNeededOutput, + ChainTypes, + QuoteResponse +} from '@shapeshiftoss/types' +import { + AFFILIATE_ADDRESS, + APPROVAL_BUY_AMOUNT, + APPROVAL_GAS_LIMIT, + DEFAULT_ETH_PATH, + DEFAULT_SLIPPAGE +} from '../utils/constants' import { ZrxSwapperDeps } from '../ZrxSwapper' import { zrxService } from '../utils/zrxService' -const APPROVAL_BUY_AMOUNT = '100000000000000000' // A valid buy amount - 0.1 ETH +import { getERC20Allowance } from '../utils/helpers/helpers' +import { erc20AllowanceAbi } from '../utils/abi/erc20Allowance-abi' export async function approvalNeeded( - { adapterManager }: ZrxSwapperDeps, + { adapterManager, web3 }: ZrxSwapperDeps, { quote, wallet }: ApprovalNeededInput ): Promise { const { sellAsset } = quote @@ -17,6 +30,48 @@ export async function approvalNeeded( return { approvalNeeded: false } } + const adapter: ChainAdapter = adapterManager.byChain(sellAsset.chain) + const receiveAddress = await adapter.getAddress({ wallet, path: DEFAULT_ETH_PATH }) - return { approvalNeeded: true } + /** + * /swap/v1/quote + * params: { + * sellToken: contract address (or symbol) of token to sell + * buyToken: contractAddress (or symbol) of token to buy + * sellAmount?: integer string value of the smallest increment of the sell token + * buyAmount?: integer string value of the smallest incremtent of the buy token + * } + */ + const quoteResponse: AxiosResponse = await zrxService.get( + '/swap/v1/quote', + { + params: { + buyToken: 'ETH', + sellToken: quote.sellAsset.tokenId || quote.sellAsset.symbol || quote.sellAsset.chain, + buyAmount: APPROVAL_BUY_AMOUNT, + takerAddress: receiveAddress, + slippagePercentage: DEFAULT_SLIPPAGE, + skipValidation: true, + affiliateAddress: AFFILIATE_ADDRESS + } + } + ) + const { data } = quoteResponse + + const allowanceResult = getERC20Allowance( + { web3, erc20AllowanceAbi }, + { + tokenId: quote.sellAsset.tokenId as string, + spenderAddress: data.allowanceTarget as string, + ownerAddress: receiveAddress + } + ) + + const allowanceOnChain = new BigNumber(allowanceResult || '0') + + return { + approvalNeeded: allowanceOnChain.lt(new BigNumber(quote.sellAmount || 1)), + gas: APPROVAL_GAS_LIMIT, + gasPrice: data.gasPrice + } } diff --git a/packages/swapper/src/swappers/zrx/buildQuoteTx/buildQuoteTx.ts b/packages/swapper/src/swappers/zrx/buildQuoteTx/buildQuoteTx.ts index b94ad4b0f..cacb8383b 100644 --- a/packages/swapper/src/swappers/zrx/buildQuoteTx/buildQuoteTx.ts +++ b/packages/swapper/src/swappers/zrx/buildQuoteTx/buildQuoteTx.ts @@ -6,7 +6,7 @@ import { SwapError } from '../../..' import { Quote, QuoteResponse, BuildQuoteTxInput } from '@shapeshiftoss/types' import { ZrxSwapperDeps } from '../ZrxSwapper' import { applyAxiosRetry } from '../utils/applyAxiosRetry' -import { erc20AllowanceAbi } from '../utils/abi/erc20-abi' +import { erc20AllowanceAbi } from '../utils/abi/erc20Allowance-abi' import { normalizeAmount, getAllowanceRequired } from '../utils/helpers/helpers' import { zrxService } from '../utils/zrxService' import { diff --git a/packages/swapper/src/swappers/zrx/utils/abi/erc20-abi.ts b/packages/swapper/src/swappers/zrx/utils/abi/erc20Allowance-abi.ts similarity index 100% rename from packages/swapper/src/swappers/zrx/utils/abi/erc20-abi.ts rename to packages/swapper/src/swappers/zrx/utils/abi/erc20Allowance-abi.ts diff --git a/packages/swapper/src/swappers/zrx/utils/constants.ts b/packages/swapper/src/swappers/zrx/utils/constants.ts index 8db88491d..484d224b4 100644 --- a/packages/swapper/src/swappers/zrx/utils/constants.ts +++ b/packages/swapper/src/swappers/zrx/utils/constants.ts @@ -14,3 +14,4 @@ export const DEFAULT_SLIPPAGE = '3.0' // 3% export const MAX_SLIPPAGE = '30.0' // 30% export const DEFAULT_ETH_PATH = `m/44'/60'/0'/0/0` // TODO: remove when `adapter.getAddress` changes to take an account instead of default path export const AFFILIATE_ADDRESS = '0xc770eefad204b5180df6a14ee197d99d808ee52d' +export const APPROVAL_BUY_AMOUNT = '100000000000000000' // A valid buy amount - 0.1 ETH diff --git a/packages/swapper/src/swappers/zrx/utils/helpers/helpers.test.ts b/packages/swapper/src/swappers/zrx/utils/helpers/helpers.test.ts index 938be4bda..18cdfed4d 100644 --- a/packages/swapper/src/swappers/zrx/utils/helpers/helpers.test.ts +++ b/packages/swapper/src/swappers/zrx/utils/helpers/helpers.test.ts @@ -1,7 +1,7 @@ import Web3 from 'web3' import BigNumber from 'bignumber.js' import { setupQuote } from '../test-data/setupSwapQuote' -import { erc20AllowanceAbi } from '../../utils/abi/erc20-abi' +import { erc20AllowanceAbi } from '../abi/erc20Allowance-abi' import { normalizeAmount, getAllowanceRequired, getUsdRate } from '../helpers/helpers' import { zrxService } from '../zrxService' diff --git a/packages/swapper/src/swappers/zrx/utils/helpers/helpers.ts b/packages/swapper/src/swappers/zrx/utils/helpers/helpers.ts index 4d11f7605..490c5974e 100644 --- a/packages/swapper/src/swappers/zrx/utils/helpers/helpers.ts +++ b/packages/swapper/src/swappers/zrx/utils/helpers/helpers.ts @@ -13,6 +13,17 @@ export type GetAllowanceRequiredArgs = { erc20AllowanceAbi: AbiItem[] } +export type GetERC20AllowanceDeps = { + erc20AllowanceAbi: AbiItem[] + web3: Web3 +} + +export type GetERC20AllowanceArgs = { + tokenId: string + ownerAddress: string + spenderAddress: string +} + /** * Very large amounts like those found in ERC20s with a precision of 18 get converted * to exponential notation ('1.6e+21') in javascript. The 0x api doesn't play well with @@ -26,6 +37,14 @@ export const normalizeAmount = (amount: string | undefined): string | undefined return new BigNumber(amount).toNumber().toLocaleString('fullwide', { useGrouping: false }) } +export const getERC20Allowance = ( + { erc20AllowanceAbi, web3 }: GetERC20AllowanceDeps, + { tokenId, ownerAddress, spenderAddress }: GetERC20AllowanceArgs +) => { + const erc20Contract = new web3.eth.Contract(erc20AllowanceAbi, tokenId) + return erc20Contract.methods.allowance(ownerAddress, spenderAddress).call() +} + export const getAllowanceRequired = async ({ quote, web3, @@ -35,11 +54,14 @@ export const getAllowanceRequired = async ({ return new BigNumber(0) } - const ownerAddress = quote.receiveAddress - const spenderAddress = quote.allowanceContract + const ownerAddress = quote.receiveAddress as string + const spenderAddress = quote.allowanceContract as string + const tokenId = quote.sellAsset.tokenId as string - const erc20Contract = new web3.eth.Contract(erc20AllowanceAbi, quote.sellAsset.tokenId) - const allowanceOnChain = erc20Contract.methods.allowance(ownerAddress, spenderAddress).call() + const allowanceOnChain = getERC20Allowance( + { web3, erc20AllowanceAbi }, + { ownerAddress, spenderAddress, tokenId } + ) if (allowanceOnChain === '0') { return new BigNumber(quote.sellAmount || 0)