Skip to content

Commit

Permalink
feat: add approval needed to lib (shapeshift#95)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
DaoDev44 authored Oct 5, 2021
1 parent d1cf1be commit 0b1bae4
Show file tree
Hide file tree
Showing 11 changed files with 264 additions and 33 deletions.
8 changes: 8 additions & 0 deletions packages/swapper/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { HDWallet } from '@shapeshiftoss/hdwallet-core'
import {
Asset,
ApprovalNeededInput,
ApprovalNeededOutput,
BuildQuoteTxInput,
GetQuoteInput,
Quote,
Expand Down Expand Up @@ -65,4 +67,10 @@ export interface Swapper {
* @param wallet
*/
executeQuote(args: ExecQuoteInput): Promise<ExecQuoteOutput>

/**
* Get a boolean if a quote needs approval
*/

approvalNeeded(args: ApprovalNeededInput): Promise<ApprovalNeededOutput>
}
10 changes: 8 additions & 2 deletions packages/swapper/src/swappers/thorchain/ThorchainSwapper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
Asset,
ApprovalNeededOutput,
SwapperType,
Quote,
ExecQuoteOutput,
Expand Down Expand Up @@ -28,13 +29,14 @@ export class ThorchainSwapper implements Swapper {

getMinMax(input: GetQuoteInput): Promise<MinMaxOutput> {
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')
Expand All @@ -45,6 +47,10 @@ export class ThorchainSwapper implements Swapper {
}

getDefaultPair(): Partial<Asset>[] {
throw new Error('Method not implemented.')
throw new Error('ThorchainSwapper: getDefaultPair unimplemented')
}

async approvalNeeded(): Promise<ApprovalNeededOutput> {
throw new Error('ThorchainSwapper: approvalNeeded unimplemented')
}
}
7 changes: 7 additions & 0 deletions packages/swapper/src/swappers/zrx/ZrxSwapper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import Web3 from 'web3'
import {
Asset,
ApprovalNeededInput,
ApprovalNeededOutput,
BuildQuoteTxInput,
ChainTypes,
GetQuoteInput,
Expand All @@ -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
Expand Down Expand Up @@ -76,4 +79,8 @@ export class ZrxSwapper implements Swapper {
async executeQuote(args: ExecQuoteInput): Promise<ExecQuoteOutput> {
return executeQuote(this.deps, args)
}

async approvalNeeded(args: ApprovalNeededInput): Promise<ApprovalNeededOutput> {
return approvalNeeded(this.deps, args)
}
}
Original file line number Diff line number Diff line change
@@ -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<unknown>).mockImplementation(() => ({
methods: {
allowance: jest.fn(() => ({
call: jest.fn(() => allowanceOnChain)
}))
}
}))
;(zrxService.get as jest.Mock<unknown>).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<unknown>).mockImplementation(() => ({
methods: {
allowance: jest.fn(() => ({
call: jest.fn(() => allowanceOnChain)
}))
}
}))
;(zrxService.get as jest.Mock<unknown>).mockReturnValue(Promise.resolve({ data }))

expect(await approvalNeeded(args, input)).toEqual({
approvalNeeded: true,
gas: APPROVAL_GAS_LIMIT,
gasPrice: data.gasPrice
})
})
})
82 changes: 82 additions & 0 deletions packages/swapper/src/swappers/zrx/approvalNeeded/approvalNeeded.ts
Original file line number Diff line number Diff line change
@@ -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<ApprovalNeededOutput> {
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<QuoteResponse> = await zrxService.get<QuoteResponse>(
'/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
}
}
28 changes: 2 additions & 26 deletions packages/swapper/src/swappers/zrx/buildQuoteTx/buildQuoteTx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<LiquiditySource>
}

export async function buildQuoteTx(
{ adapterManager, web3 }: ZrxSwapperDeps,
{ input, wallet }: BuildQuoteTxInput
Expand Down
1 change: 1 addition & 0 deletions packages/swapper/src/swappers/zrx/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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')
Expand Down
30 changes: 26 additions & 4 deletions packages/swapper/src/swappers/zrx/utils/helpers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)
Expand Down
Loading

0 comments on commit 0b1bae4

Please sign in to comment.