Skip to content
This repository has been archived by the owner on Apr 11, 2023. It is now read-only.

feat: add approval needed to lib #95

Merged
merged 3 commits into from
Oct 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
})
})
})
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