From 0b1bae4ad71de3a1306df1e5c8dd8964e26ce1cc Mon Sep 17 00:00:00 2001 From: DaoDev44 <88341957+DaoDev44@users.noreply.github.com> Date: Tue, 5 Oct 2021 14:47:01 -0600 Subject: [PATCH] feat: add approval needed to lib (#95) * add approvalNeeded to ZrxSwapper * add rest of approvalNeeded and tests; extract web3 logic to a helper function * add throw error when passed wrong chain to approvalNeeded --- packages/swapper/src/api.ts | 8 ++ .../swappers/thorchain/ThorchainSwapper.ts | 10 +- .../swapper/src/swappers/zrx/ZrxSwapper.ts | 7 ++ .../zrx/approvalNeeded/approvalNeeded.test.ts | 117 ++++++++++++++++++ .../zrx/approvalNeeded/approvalNeeded.ts | 82 ++++++++++++ .../swappers/zrx/buildQuoteTx/buildQuoteTx.ts | 28 +---- .../{erc20-abi.ts => erc20Allowance-abi.ts} | 0 .../src/swappers/zrx/utils/constants.ts | 1 + .../zrx/utils/helpers/helpers.test.ts | 3 +- .../src/swappers/zrx/utils/helpers/helpers.ts | 30 ++++- packages/types/src/types.ts | 11 ++ 11 files changed, 264 insertions(+), 33 deletions(-) create mode 100644 packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.test.ts create mode 100644 packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.ts rename packages/swapper/src/swappers/zrx/utils/abi/{erc20-abi.ts => erc20Allowance-abi.ts} (100%) diff --git a/packages/swapper/src/api.ts b/packages/swapper/src/api.ts index 097811f49..d66d84cfd 100644 --- a/packages/swapper/src/api.ts +++ b/packages/swapper/src/api.ts @@ -1,6 +1,8 @@ import { HDWallet } from '@shapeshiftoss/hdwallet-core' import { Asset, + ApprovalNeededInput, + ApprovalNeededOutput, BuildQuoteTxInput, GetQuoteInput, Quote, @@ -65,4 +67,10 @@ export interface Swapper { * @param wallet */ executeQuote(args: ExecQuoteInput): Promise + + /** + * Get a boolean if a quote needs approval + */ + + approvalNeeded(args: ApprovalNeededInput): Promise } diff --git a/packages/swapper/src/swappers/thorchain/ThorchainSwapper.ts b/packages/swapper/src/swappers/thorchain/ThorchainSwapper.ts index 46ee674bc..506658ea3 100644 --- a/packages/swapper/src/swappers/thorchain/ThorchainSwapper.ts +++ b/packages/swapper/src/swappers/thorchain/ThorchainSwapper.ts @@ -1,5 +1,6 @@ import { Asset, + ApprovalNeededOutput, SwapperType, Quote, ExecQuoteOutput, @@ -28,13 +29,14 @@ export class ThorchainSwapper implements Swapper { getMinMax(input: GetQuoteInput): Promise { console.info(input) - throw new Error('Method not implemented.') + throw new Error('ThorchainSwapper: getMinMax unimplemented') } getAvailableAssets(assets: Asset[]): Asset[] { console.info(assets) throw new Error('ThorchainSwapper: getAvailableAssets unimplemented') } + canTradePair(sellAsset: Asset, buyAsset: Asset): boolean { console.info(sellAsset, buyAsset) throw new Error('ThorchainSwapper: canTradePair unimplemented') @@ -45,6 +47,10 @@ export class ThorchainSwapper implements Swapper { } getDefaultPair(): Partial[] { - throw new Error('Method not implemented.') + throw new Error('ThorchainSwapper: getDefaultPair unimplemented') + } + + async approvalNeeded(): Promise { + throw new Error('ThorchainSwapper: approvalNeeded unimplemented') } } diff --git a/packages/swapper/src/swappers/zrx/ZrxSwapper.ts b/packages/swapper/src/swappers/zrx/ZrxSwapper.ts index ba5a2e8c6..1b9d1738d 100644 --- a/packages/swapper/src/swappers/zrx/ZrxSwapper.ts +++ b/packages/swapper/src/swappers/zrx/ZrxSwapper.ts @@ -1,6 +1,8 @@ import Web3 from 'web3' import { Asset, + ApprovalNeededInput, + ApprovalNeededOutput, BuildQuoteTxInput, ChainTypes, GetQuoteInput, @@ -17,6 +19,7 @@ import { getZrxQuote } from './getQuote/getQuote' import { getUsdRate } from './utils/helpers/helpers' import { getMinMax } from './getMinMax/getMinMax' import { executeQuote } from './executeQuote/executeQuote' +import { approvalNeeded } from './approvalNeeded/approvalNeeded' export type ZrxSwapperDeps = { adapterManager: ChainAdapterManager @@ -76,4 +79,8 @@ export class ZrxSwapper implements Swapper { async executeQuote(args: ExecQuoteInput): Promise { return executeQuote(this.deps, args) } + + async approvalNeeded(args: ApprovalNeededInput): Promise { + return approvalNeeded(this.deps, args) + } } diff --git a/packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.test.ts b/packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.test.ts new file mode 100644 index 000000000..26335f832 --- /dev/null +++ b/packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.test.ts @@ -0,0 +1,117 @@ +import Web3 from 'web3' +import { HDWallet } from '@shapeshiftoss/hdwallet-core' +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 = { + [ChainTypes.Ethereum]: 'http://localhost:31300/api/v1' + } + const ethNodeUrl = 'http://localhost:1000' + const adapterManager = new ChainAdapterManager(unchainedUrls) + const web3Provider = new Web3.providers.HttpProvider(ethNodeUrl) + const web3 = new Web3(web3Provider) + + return { web3, adapterManager } +} + +describe('approvalNeeded', () => { + 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 + } + + expect(await approvalNeeded(args, input)).toEqual({ approvalNeeded: false }) + }) + + it('throws an error if sellAsset chain is not ETH', async () => { + const input = { + quote: { ...quoteInput, sellAsset: { ...sellAsset, chain: ChainTypes.Bitcoin } }, + wallet + } + + await expect(approvalNeeded(args, input)).rejects.toThrow( + 'ZrxSwapper:approvalNeeded only Ethereum chain type is supported' + ) + }) + + 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 new file mode 100644 index 000000000..19a938b10 --- /dev/null +++ b/packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.ts @@ -0,0 +1,82 @@ +import { BigNumber } from 'bignumber.js' +import { AxiosResponse } from 'axios' +import { ChainAdapter } from '@shapeshiftoss/chain-adapters' +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 { SwapError } from '../../../api' +import { ZrxSwapperDeps } from '../ZrxSwapper' +import { zrxService } from '../utils/zrxService' +import { getERC20Allowance } from '../utils/helpers/helpers' +import { erc20AllowanceAbi } from '../utils/abi/erc20Allowance-abi' + +export async function approvalNeeded( + { adapterManager, web3 }: ZrxSwapperDeps, + { quote, wallet }: ApprovalNeededInput +): Promise { + const { sellAsset } = quote + + if (sellAsset.symbol === 'ETH') { + return { approvalNeeded: false } + } + + if (sellAsset.chain !== ChainTypes.Ethereum) { + throw new SwapError('ZrxSwapper:approvalNeeded only Ethereum chain type is supported') + } + + const adapter: ChainAdapter = adapterManager.byChain(sellAsset.chain) + const receiveAddress = await adapter.getAddress({ wallet, path: DEFAULT_ETH_PATH }) + + /** + * /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 877782ab1..cacb8383b 100644 --- a/packages/swapper/src/swappers/zrx/buildQuoteTx/buildQuoteTx.ts +++ b/packages/swapper/src/swappers/zrx/buildQuoteTx/buildQuoteTx.ts @@ -3,10 +3,10 @@ import { AxiosResponse } from 'axios' import * as rax from 'retry-axios' import { ChainAdapter } from '@shapeshiftoss/chain-adapters' import { SwapError } from '../../..' -import { Quote, BuildQuoteTxInput } from '@shapeshiftoss/types' +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 { @@ -18,30 +18,6 @@ import { MAX_SLIPPAGE } from '../utils/constants' -type LiquiditySource = { - name: string - proportion: string -} - -type QuoteResponse = { - price: string - guaranteedPrice: string - to: string - data?: string - value?: string - gas?: string - estimatedGas?: string - gasPrice?: string - protocolFee?: string - minimumProtocolFee?: string - buyTokenAddress?: string - sellTokenAddress?: string - buyAmount?: string - sellAmount?: string - allowanceTarget?: string - sources?: Array -} - export async function buildQuoteTx( { adapterManager, web3 }: ZrxSwapperDeps, { input, wallet }: BuildQuoteTxInput 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..42bdcf589 100644 --- a/packages/swapper/src/swappers/zrx/utils/helpers/helpers.test.ts +++ b/packages/swapper/src/swappers/zrx/utils/helpers/helpers.test.ts @@ -1,12 +1,13 @@ 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' jest.mock('web3') const axios = jest.createMockFromModule('axios') + //@ts-ignore axios.create = jest.fn(() => axios) jest.mock('../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) diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 3d58b3f64..fbe8f1456 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -190,6 +190,17 @@ export type ExecQuoteOutput = { txid: string } +export type ApprovalNeededInput = { + quote: Quote + wallet: HDWallet +} + +export type ApprovalNeededOutput = { + approvalNeeded: boolean + gas?: string + gasPrice?: string +} + // chain-adapters export type Transaction = {