From 23180c736576342a6ff75dab1616d40634ad3a2f Mon Sep 17 00:00:00 2001 From: cgewecke Date: Mon, 18 Apr 2022 16:04:49 -0700 Subject: [PATCH 01/26] Initial BatchTradeExtensionAPI --- package.json | 2 +- src/Set.ts | 2 + src/api/extensions/BatchTradeExtensionAPI.ts | 120 ++++++++++++++++++ src/api/index.ts | 2 + src/types/common.ts | 24 +++- .../BatchTradeExtensionWrapper.ts | 120 ++++++++++++++++++ .../set-v2-strategies/ContractWrapper.ts | 33 ++++- .../TradeExtensionWrapper.ts | 2 +- yarn.lock | 18 +-- 9 files changed, 308 insertions(+), 15 deletions(-) create mode 100644 src/api/extensions/BatchTradeExtensionAPI.ts create mode 100644 src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts diff --git a/package.json b/package.json index eabec74..ed146ca 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@0xproject/typescript-typings": "^3.0.2", "@0xproject/utils": "^2.0.2", "@setprotocol/set-protocol-v2": "^0.1.15", - "@setprotocol/set-v2-strategies": "^0.0.7", + "@setprotocol/set-v2-strategies": "^0.0.10-batch.0", "@types/chai-as-promised": "^7.1.3", "@types/jest": "^26.0.5", "@types/web3": "^1.2.2", diff --git a/src/Set.ts b/src/Set.ts index 785c6e3..285e6e9 100644 --- a/src/Set.ts +++ b/src/Set.ts @@ -38,6 +38,7 @@ import { IssuanceExtensionAPI, TradeExtensionAPI, StreamingFeeExtensionAPI, + BatchTradeExtensionAPI, } from './api/index'; const ethersProviders = require('ethers').providers; @@ -212,6 +213,7 @@ class Set { streamingFeeExtension: new StreamingFeeExtensionAPI(ethersProvider, config.streamingFeeExtensionAddress), issuanceExtension: new IssuanceExtensionAPI(ethersProvider, config.issuanceExtensionAddress), tradeExtension: new TradeExtensionAPI(ethersProvider, config.tradeExtensionAddress), + batchTradeExtension: new BatchTradeExtensionAPI(ethersProvider, config.batchTradeExtensionAddress), }; } } diff --git a/src/api/extensions/BatchTradeExtensionAPI.ts b/src/api/extensions/BatchTradeExtensionAPI.ts new file mode 100644 index 0000000..cd74094 --- /dev/null +++ b/src/api/extensions/BatchTradeExtensionAPI.ts @@ -0,0 +1,120 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +import { ContractTransaction, BytesLike, utils as EthersUtils } from 'ethers'; +import { Provider } from '@ethersproject/providers'; +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; +import { TransactionOverrides } from '@setprotocol/set-protocol-v2/dist/typechain'; +import { BatchTradeExtension__factory } from '@setprotocol/set-v2-strategies/dist/typechain/factories/BatchTradeExtension__factory'; + +import BatchTradeExtensionWrapper from '../../wrappers/set-v2-strategies/BatchTradeExtensionWrapper'; +import Assertions from '../../assertions'; +import { TradeInfo } from '../../types'; + +/** + * @title BatchTradeExtensionAPI + * @author Set Protocol + * + * The BatchTradeExtensionAPI exposes methods to trade SetToken components in batches using the TradeModule for + * SetTokens using the DelegatedManager system. The API also provides some helper methods to generate bytecode + * data packets that encode module and extension initialization method calls. + */ +export default class BatchTradeExtensionAPI { + private batchTradeExtensionWrapper: BatchTradeExtensionWrapper; + private assert: Assertions; + + public constructor( + provider: Provider, + batchTradeExtensionAddress: Address, + assertions?: Assertions) { + this.batchTradeExtensionWrapper = new BatchTradeExtensionWrapper(provider, batchTradeExtensionAddress); + this.assert = assertions || new Assertions(); + } + + /** + * Executes a batch of trades on a supported DEX. Must be called an address authorized for the `operator` role + * on the BatchTradeExtension + * + * NOTE: Although SetToken units are passed in for the send and receive quantities, the total quantity + * sent and received is the quantity of SetToken units multiplied by the SetToken totalSupply. + * + * @param setTokenAddress Address of the deployed SetToken to trade on behalf of + * @param trades Array of TradeInfo objects to execute as a batch of trades + * @param callerAddress Address of caller (optional) + * @param txOpts Overrides for transaction (optional) + */ + public async batchTradeWithOperatorAsync( + setTokenAddress: Address, + trades: TradeInfo[], + callerAddress: Address = undefined, + txOpts: TransactionOverrides = {} + ): Promise { + this._validateTrades(trades); + this.assert.schema.isValidAddress('setTokenAddress', setTokenAddress); + + return await this.batchTradeExtensionWrapper.batchTradeWithOperatorAsync( + setTokenAddress, + trades, + callerAddress, + txOpts + ); + } + + /** + * Generates TradeExtension initialize call bytecode to be passed as an element in the `initializeBytecode` + * array for the DelegatedManagerFactory's `initializeAsync` method. + * + * @param delegatedManagerAddress Instance of deployed DelegatedManager to initialize the TradeExtension for + * + * @return Initialization bytecode + */ + public getTradeExtensionInitializationBytecode( + delegatedManagerAddress: Address + ): BytesLike { + this.assert.schema.isValidAddress('delegatedManagerAddress', delegatedManagerAddress); + + const extensionInterface = new EthersUtils.Interface(BatchTradeExtension__factory.abi); + return extensionInterface.encodeFunctionData('initializeExtension', [ delegatedManagerAddress ]); + } + + /** + * Generates `moduleAndExtensionInitialization` bytecode to be passed as an element in the `initializeBytecode` + * array for the `initializeAsync` method. + * + * @param setTokenAddress Instance of deployed setToken to initialize the TradeModule for + * + * @return Initialization bytecode + */ + public getTradeModuleAndExtensionInitializationBytecode(delegatedManagerAddress: Address): BytesLike { + this.assert.schema.isValidAddress('delegatedManagerAddress', delegatedManagerAddress); + + const extensionInterface = new EthersUtils.Interface(BatchTradeExtension__factory.abi); + return extensionInterface.encodeFunctionData('initializeModuleAndExtension', [ delegatedManagerAddress ]); + } + + private _validateTrades(trades: TradeInfo[]) { + for (const trade of trades) { + this.assert.schema.isValidString('exchangeName', trade.exchangeName); + this.assert.schema.isValidAddress('sendToken', trade.sendToken); + this.assert.schema.isValidNumber('sendQuantity', trade.sendQuantity); + this.assert.schema.isValidAddress('receiveToken', trade.receiveToken); + this.assert.schema.isValidNumber('minReceiveQuantity', trade.minReceiveQuantity); + this.assert.schema.isValidBytes('data', trade.data); + } + } +} diff --git a/src/api/index.ts b/src/api/index.ts index 4d5cd52..c85b23e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -17,6 +17,7 @@ import DelegatedManagerFactoryAPI from './DelegatedManagerFactoryAPI'; import IssuanceExtensionAPI from './extensions/IssuanceExtensionAPI'; import StreamingFeeExtensionAPI from './extensions/StreamingFeeExtensionAPI'; import TradeExtensionAPI from './extensions/TradeExtensionAPI'; +import BatchTradeExtensionAPI from './extensions/BatchTradeExtensionAPI'; import { TradeQuoter, @@ -44,6 +45,7 @@ export { IssuanceExtensionAPI, StreamingFeeExtensionAPI, TradeExtensionAPI, + BatchTradeExtensionAPI, TradeQuoter, CoinGeckoDataService, GasOracleService diff --git a/src/types/common.ts b/src/types/common.ts index 173a84d..860bcd9 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,13 +1,14 @@ import { Provider } from '@ethersproject/providers'; import { provider as Web3CoreProvider } from 'web3-core'; import { Address } from '@setprotocol/set-protocol-v2/utils/types'; -import { BigNumber } from 'ethers/lib/ethers'; +import { BigNumber, BytesLike } from 'ethers/lib/ethers'; import { ZeroExApiUrls } from './utils'; import type { IssuanceExtensionAPI, TradeExtensionAPI, - StreamingFeeExtensionAPI + StreamingFeeExtensionAPI, + BatchTradeExtensionAPI } from '../api'; export { TransactionReceipt } from 'ethereum-types'; @@ -43,6 +44,7 @@ export interface SetJSConfig { issuanceExtensionAddress: Address; tradeExtensionAddress: Address; streamingFeeExtensionAddress: Address; + batchTradeExtensionAddress: Address; } export type SetDetails = { @@ -164,5 +166,21 @@ export type VAssetDisplayInfo = { export type DelegatedManagerSystemExtensions = { issuanceExtension: IssuanceExtensionAPI, tradeExtension: TradeExtensionAPI, - streamingFeeExtension: StreamingFeeExtensionAPI + streamingFeeExtension: StreamingFeeExtensionAPI, + batchTradeExtension: BatchTradeExtensionAPI +}; + +export type TradeInfo = { + exchangeName: string; + sendToken: Address; + sendQuantity: BigNumber; + receiveToken: Address; + minReceiveQuantity: BigNumber; + data: BytesLike; +}; + +export type BatchTradeResult = { + success: boolean; + tradeInfo: TradeInfo; + revertReason?: string | undefined; }; diff --git a/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts b/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts new file mode 100644 index 0000000..ad696ad --- /dev/null +++ b/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts @@ -0,0 +1,120 @@ +/* + Copyright 2022 Set Labs Inc. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; +import { ContractTransaction, constants } from 'ethers'; +import { TransactionOverrides } from '@setprotocol/set-protocol-v2/dist/typechain'; +import { Provider } from '@ethersproject/providers'; +import { generateTxOpts } from '../../utils/transactions'; +import { TradeInfo, BatchTradeResult } from '../../types'; + +import ContractWrapper from './ContractWrapper'; + +/** + * @title BatchTradeExtensionWrapper + * @author Set Protocol + * + * The BatchTradeExtensionWrapper forwards functionality from the BatchTradeExtension contract. + * + */ +export default class BatchTradeExtensionWrapper { + private provider: Provider; + private contracts: ContractWrapper; + + private batchTradeExtensionAddress: Address; + + public constructor(provider: Provider, batchTradeExtensionAddress: Address) { + this.provider = provider; + this.contracts = new ContractWrapper(this.provider); + this.batchTradeExtensionAddress = batchTradeExtensionAddress; + } + + /** + * Executes a batch of trades on a supported DEX. Must be called an address authorized for the `operator` role + * on the BatchTradeExtension + * + * NOTE: Although SetToken units are passed in for each TradeInfo entry's send and receive quantities, the + * total quantity sent and received is the quantity of SetToken units multiplied by the SetToken totalSupply. + * + * @param setTokenAddress Address of the deployed SetToken to trade on behalf of + * @param trades Array of TradeInfo objects to execute as a batch of trades + * @param callerAddress Address of caller (optional) + * @param txOptions Overrides for transaction (optional) + */ + public async batchTradeWithOperatorAsync( + setTokenAddress: Address, + trades: TradeInfo[], + callerAddress: Address = undefined, + txOpts: TransactionOverrides = {} + ): Promise { + const txOptions = await generateTxOpts(txOpts); + const batchTradeExtensionInstance = await this.contracts.loadBatchTradeExtensionAsync( + this.batchTradeExtensionAddress, + callerAddress + ); + + return await batchTradeExtensionInstance.batchTrade( + setTokenAddress, + trades, + txOptions + ); + } + + /** + * Returns success status of each trade (and any revert reasons in the case of failure) executed as + * part of a batch. Transaction must be mined before this method is called. + * + * @param transactionHash Transaction hash of tx which executed a batch trade + * @param trades Array of TradeInfo objects which were submitted with the batch trade transaction + */ + public async getBatchTradeResultsAsync(transactionHash: string, trades: TradeInfo[]): Promise { + const batchTradeExtensionInstance = await this.contracts.loadBatchTradeExtensionAsync( + this.batchTradeExtensionAddress, + constants.AddressZero + ); + + const receipt = await this.provider.getTransactionReceipt(transactionHash); + const results: BatchTradeResult[] = []; + + for (const trade of trades) { + results.push({ + success: true, + tradeInfo: trade, + }); + } + + for (const log of receipt.logs) { + const decodedLog = batchTradeExtensionInstance.interface.parseLog({ + data: log.data, + topics: log.topics, + }); + + if (decodedLog.name === 'StringTradeFailed') { + const tradeIndex = (decodedLog.args as any).i.toNumber(); + results[tradeIndex].success = false; + results[tradeIndex].revertReason = (decodedLog.args as any).reason; + } + + // May need to do something extra here to decode low level revert bytes + if (decodedLog.name === 'BytesTradeFailed') { + const tradeIndex = (decodedLog.args as any).i.toNumber(); + results[tradeIndex].success = false; + results[tradeIndex].revertReason = (decodedLog.args as any).reason; + } + } + + return results; + } +} diff --git a/src/wrappers/set-v2-strategies/ContractWrapper.ts b/src/wrappers/set-v2-strategies/ContractWrapper.ts index c4c0f40..0747228 100644 --- a/src/wrappers/set-v2-strategies/ContractWrapper.ts +++ b/src/wrappers/set-v2-strategies/ContractWrapper.ts @@ -24,7 +24,8 @@ import { DelegatedManagerFactory, StreamingFeeSplitExtension, TradeExtension, - IssuanceExtension + IssuanceExtension, + BatchTradeExtension } from '@setprotocol/set-v2-strategies/typechain'; import { @@ -39,6 +40,9 @@ import { import { IssuanceExtension__factory } from '@setprotocol/set-v2-strategies/dist/typechain/factories/IssuanceExtension__factory'; +import { + BatchTradeExtension__factory, +} from '@setprotocol/set-v2-strategies/dist/typechain/factories/TradeExtension__factory'; /** @@ -164,4 +168,31 @@ export default class ContractWrapper { return issuanceExtensionContract; } } + + /** + * Load BatchTradeExtension contract + * + * @param batchTradeExtensionAddress Address of the TradeExtension instance + * @param callerAddress Address of caller, uses first one on node if none provided. + * @return BatchTradeExtension contract instance + */ + public async loadBatchTradeExtensionAsync( + batchTradeExtensionAddress: Address, + callerAddress?: Address, + ): Promise { + const signer = (this.provider as JsonRpcProvider).getSigner(callerAddress); + const cacheKey = `batchTradeExtension_${batchTradeExtensionAddress}_${await signer.getAddress()}`; + + if (cacheKey in this.cache) { + return this.cache[cacheKey] as BatchTradeExtension; + } else { + const batchTradeExtensionContract = BatchTradeExtension__factory.connect( + batchTradeExtensionAddress, + signer + ); + + this.cache[cacheKey] = batchTradeExtensionContract; + return batchTradeExtensionContract; + } + } } diff --git a/src/wrappers/set-v2-strategies/TradeExtensionWrapper.ts b/src/wrappers/set-v2-strategies/TradeExtensionWrapper.ts index 4a9ad40..d66f934 100644 --- a/src/wrappers/set-v2-strategies/TradeExtensionWrapper.ts +++ b/src/wrappers/set-v2-strategies/TradeExtensionWrapper.ts @@ -44,7 +44,7 @@ export default class TradeExtensionWrapper { * Executes a trade on a supported DEX. Must be called an address authorized for the `operator` role * on the TradeExtension * - * NOTE: Although the SetToken units are passed in for the send and receive quantities, the total quantity + * NOTE: Although SetToken units are passed in for the send and receive quantities, the total quantity * sent and received is the quantity of SetToken units multiplied by the SetToken totalSupply. * * @param setTokenAddress Address of the deployed SetToken to trade on behalf of diff --git a/yarn.lock b/yarn.lock index 008a735..9e5c8e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1016,10 +1016,10 @@ resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-3.4.1-solc-0.7-2.tgz#371c67ebffe50f551c3146a9eec5fe6ffe862e92" integrity sha512-tAG9LWg8+M2CMu7hIsqHPaTyG4uDzjr6mhvH96LvOpLZZj6tgzTluBt+LsCf1/QaYrlis6pITvpIaIhE+iZB+Q== -"@setprotocol/set-protocol-v2@^0.1.11-hardhat.0": - version "0.1.12" - resolved "https://registry.yarnpkg.com/@setprotocol/set-protocol-v2/-/set-protocol-v2-0.1.12.tgz#4ff67d0d2935148908ddada79bc51fda17c97afa" - integrity sha512-0zHRCTgASXR4P8ya0lafF4WSjnVsc6N7QMLiOGymVfuoSJDpDD+jGOn57FnVmgeknmt60frxJYMxxn5XQN9lyw== +"@setprotocol/set-protocol-v2@0.10.0-hhat.1": + version "0.10.0-hhat.1" + resolved "https://registry.yarnpkg.com/@setprotocol/set-protocol-v2/-/set-protocol-v2-0.10.0-hhat.1.tgz#933dc1ad9599ddf39796ce5785e0cd4ec0e7ba5d" + integrity sha512-wjTIHUzsAmL8u01XsOALJp62ceznVPK420zfpNK6Kv/mK56UKZWq3ptzHbnhjujU1vDfiNylH+enEHjUYiBogQ== dependencies: "@uniswap/v3-sdk" "^3.5.1" ethers "^5.5.2" @@ -1040,12 +1040,12 @@ module-alias "^2.2.2" replace-in-file "^6.1.0" -"@setprotocol/set-v2-strategies@^0.0.7": - version "0.0.7" - resolved "https://registry.yarnpkg.com/@setprotocol/set-v2-strategies/-/set-v2-strategies-0.0.7.tgz#dca84f40c31b7118b6ff527f372d7fe2906f53ad" - integrity sha512-YfA1obWvj2v/lsNnwrCHf2wRud+mdUZutHJggZyziqMokFi6dsej9StczSt8ftWUsWQqQnnGshVcBAjSL0T1sQ== +"@setprotocol/set-v2-strategies@^0.0.10-batch.0": + version "0.0.10-batch.0" + resolved "https://registry.yarnpkg.com/@setprotocol/set-v2-strategies/-/set-v2-strategies-0.0.10-batch.0.tgz#419a58a6c3154ff626ff9a37311b3c981dc0002f" + integrity sha512-3P/oWV6vjuttCoAybEfVF7x/6KjYkrUW27cS1SOOPwOIJzzVdHdFRd9zV587qbpAfULk6w7HB1t1aVTkQ3mfMw== dependencies: - "@setprotocol/set-protocol-v2" "^0.1.11-hardhat.0" + "@setprotocol/set-protocol-v2" "0.10.0-hhat.1" "@uniswap/v3-sdk" "^3.5.1" ethers "5.5.2" fs-extra "^5.0.0" From 4d95a92e7689238834b2471cbef523a5707f100e Mon Sep 17 00:00:00 2001 From: cgewecke Date: Wed, 20 Apr 2022 08:46:46 -0700 Subject: [PATCH 02/26] Add batchFetchTradeQuoteAsync to TradeAPI --- src/api/TradeAPI.ts | 107 +++++++++++++++++++++++++++++++++++++++++++- src/types/common.ts | 9 ++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/src/api/TradeAPI.ts b/src/api/TradeAPI.ts index d6c4c5c..90509c6 100644 --- a/src/api/TradeAPI.ts +++ b/src/api/TradeAPI.ts @@ -32,7 +32,8 @@ import { import { TradeQuote, - ZeroExApiUrls + ZeroExApiUrls, + TradeOrderPair } from '../types'; /** @@ -140,7 +141,7 @@ export default class TradeAPI { * @param fromAddress SetToken address which holds the buy / sell components * @param setToken SetTokenAPI instance * @param gasPrice (Optional) gasPrice to calculate gas costs with (Default: fetched from EthGasStation) - * @param slippagePercentage (Optional) maximum slippage, determines min receive quantity. (Default: 2%) + * @param slippagePercentage (Optional) max slippage, determines min receive quantity (ex: 5 (=5%)) (Default: 2%) * @param isFirmQuote (Optional) Whether quote request is indicative or firm * @param feePercentage (Optional) Default: 0 * @param feeRecipient (Optional) Default: 0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55 @@ -196,4 +197,106 @@ export default class TradeAPI { excludedSources, }); } + + /** + * Batch multiple calls to 0x API to generate trade quotes for SetToken component pairs. By default, trade quotes + * are fetched for 0x's public endpoints using their `https://api.0x.org`, `https:///api.0x.org` + * url scheme. In practice these open endpoints appear to be rate limited at ~3 req/sec + * + * It's also possible to make calls from non-browser context with an API key using the `https://gated.api.0x.org` + * url scheme. + * + * These gated endpoints rate-limit calls *per API key* as follows (mileage may vary): + * + * > Ethereum: up to 50 requests per second/200 requests per minute. + * > Other networks: 30 requests per second. + * + * The `delayStep` parameter option allows you to stagger parallelized requests to stay within rate limits + * and is set to 300ms by default. + * + * @param orderPairs TradeOrderPairs array (see `fetchTradeQuoteAsync` for property descriptions) + * @param fromAddress SetToken address which holds the buy / sell components + * @param setToken SetTokenAPI instance + * @param gasPrice gasPrice to calculate gas costs with + * @param isFirmQuote (Optional) Whether quote request is indicative or firm + * @param feePercentage (Optional) Default: 0 + * @param feeRecipient (Optional) Default: 0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55 + * @param excludedSources (Optional) Exchanges to exclude (Default: ['Kyber', 'Eth2Dai', 'Mesh']) + * @param simulatedChainId (Optional) ChainId of target network (useful when using a forked development client) + * @param delayStep (Optional) Delay between firing each quote request (to manage rate-limiting) + * + * @return {Promise} + */ + public async batchFetchTradeQuoteAsync( + orderPairs: TradeOrderPair[], + fromAddress: Address, + setToken: SetTokenAPI, + gasPrice?: number, + isFirmQuote?: boolean, + feePercentage?: number, + feeRecipient?: Address, + excludedSources?: string[], + simulatedChainId?: number, + delayStep?: number, + ): Promise { + const self = this; + this.assert.schema.isValidAddress('fromAddress', fromAddress); + + for (const pair of orderPairs) { + this.assert.schema.isValidAddress('fromToken', pair.fromToken); + this.assert.schema.isValidAddress('toToken', pair.toToken); + this.assert.schema.isValidJsNumber('fromTokenDecimals', pair.fromTokenDecimals); + this.assert.schema.isValidJsNumber('toTokenDecimals', pair.toTokenDecimals); + this.assert.schema.isValidString('rawAmount', pair.rawAmount); + } + + // The forked Hardhat network has a chainId of 31337 so we can't rely on autofetching this value + const chainId = (simulatedChainId !== undefined) + ? simulatedChainId + : (await this.provider.getNetwork()).chainId; + + // Default 300ms delay (to keep under 200 reqs/min for public endpoints) + const _delayStep = (delayStep !== undefined) + ? delayStep + : 300; + + const orders = []; + let delay = 0; + + for (const pair of orderPairs) { + const order = new Promise(async function (resolve, reject) { + await new Promise(r => setTimeout(() => r(true), delay)); + + try { + const response = await self.tradeQuoter.generateQuoteForTrade({ + fromToken: pair.fromToken, + toToken: pair.toToken, + fromTokenDecimals: pair.fromTokenDecimals, + toTokenDecimals: pair.toTokenDecimals, + rawAmount: pair.rawAmount, + slippagePercentage: pair.slippagePercentage, + tradeModule: self.tradeModuleWrapper, + provider: self.provider, + fromAddress, + chainId, + setToken, + gasPrice, + isFirmQuote, + feePercentage, + feeRecipient, + excludedSources, + }); + + resolve(response); + } catch (e) { + reject(e); + } + }); + + delay += _delayStep; + orders.push(order); + } + + return Promise.all(orders); + } } diff --git a/src/types/common.ts b/src/types/common.ts index 860bcd9..9ebf717 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -184,3 +184,12 @@ export type BatchTradeResult = { tradeInfo: TradeInfo; revertReason?: string | undefined; }; + +export type TradeOrderPair = { + fromToken: Address; + toToken: Address; + fromTokenDecimals: number; + toTokenDecimals: number; + rawAmount: string; + slippagePercentage: number; +}; From ebcec4fb4dfefe20d502fbec225c2cd482ae1156 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 21 Apr 2022 07:33:48 -0700 Subject: [PATCH 03/26] Batch fetch quotes, decode 0x custom errors, restructure --- package.json | 3 +- src/Set.ts | 4 +- src/api/TradeAPI.ts | 187 ------------------ src/api/UtilsAPI.ts | 176 +++++++++++++++++ .../BatchTradeExtensionWrapper.ts | 62 ++++-- .../set-v2-strategies/ContractWrapper.ts | 10 + test/api/UtilsAPI.spec.ts | 124 +++++++++++- tsconfig.json | 1 + yarn.lock | 102 +++++++++- 9 files changed, 458 insertions(+), 211 deletions(-) diff --git a/package.json b/package.json index ed146ca..5bc8d3d 100644 --- a/package.json +++ b/package.json @@ -55,11 +55,12 @@ "typescript": "^4.4.2" }, "dependencies": { + "@0x/utils": "^6.5.3", "@0xproject/types": "^1.1.4", "@0xproject/typescript-typings": "^3.0.2", "@0xproject/utils": "^2.0.2", "@setprotocol/set-protocol-v2": "^0.1.15", - "@setprotocol/set-v2-strategies": "^0.0.10-batch.0", + "@setprotocol/set-v2-strategies": "^0.0.10", "@types/chai-as-promised": "^7.1.3", "@types/jest": "^26.0.5", "@types/web3": "^1.2.2", diff --git a/src/Set.ts b/src/Set.ts index 285e6e9..487b0db 100644 --- a/src/Set.ts +++ b/src/Set.ts @@ -190,7 +190,7 @@ class Set { assertions ); this.system = new SystemAPI(ethersProvider, config.controllerAddress); - this.trade = new TradeAPI(ethersProvider, config.tradeModuleAddress, config.zeroExApiKey, config.zeroExApiUrls); + this.trade = new TradeAPI(ethersProvider, config.tradeModuleAddress); this.navIssuance = new NavIssuanceAPI(ethersProvider, config.navIssuanceModuleAddress); this.priceOracle = new PriceOracleAPI(ethersProvider, config.masterOracleAddress); this.debtIssuance = new DebtIssuanceAPI(ethersProvider, config.debtIssuanceModuleAddress); @@ -202,7 +202,7 @@ class Set { this.perpV2BasisTradingViewer = new PerpV2LeverageViewerAPI(ethersProvider, config.perpV2BasisTradingModuleViewerAddress); this.blockchain = new BlockchainAPI(ethersProvider, assertions); - this.utils = new UtilsAPI(ethersProvider, config.zeroExApiKey, config.zeroExApiUrls); + this.utils = new UtilsAPI(ethersProvider, config.tradeModuleAddress, config.zeroExApiKey, config.zeroExApiUrls); this.delegatedManagerFactory = new DelegatedManagerFactoryAPI( ethersProvider, diff --git a/src/api/TradeAPI.ts b/src/api/TradeAPI.ts index 90509c6..b30cf05 100644 --- a/src/api/TradeAPI.ts +++ b/src/api/TradeAPI.ts @@ -23,19 +23,8 @@ import { TransactionOverrides } from '@setprotocol/set-protocol-v2/dist/typechai import { BigNumber } from 'ethers/lib/ethers'; import TradeModuleWrapper from '../wrappers/set-protocol-v2/TradeModuleWrapper'; -import SetTokenAPI from './SetTokenAPI'; import Assertions from '../assertions'; -import { - TradeQuoter -} from './utils'; - -import { - TradeQuote, - ZeroExApiUrls, - TradeOrderPair -} from '../types'; - /** * @title TradeAPI * @author Set Protocol @@ -47,19 +36,13 @@ import { export default class TradeAPI { private tradeModuleWrapper: TradeModuleWrapper; private assert: Assertions; - private provider: Provider; - private tradeQuoter: TradeQuoter; public constructor( provider: Provider, tradeModuleAddress: Address, - zeroExApiKey?: string, - zeroExApiUrls?: ZeroExApiUrls ) { - this.provider = provider; this.tradeModuleWrapper = new TradeModuleWrapper(provider, tradeModuleAddress); this.assert = new Assertions(); - this.tradeQuoter = new TradeQuoter(zeroExApiKey, zeroExApiUrls); } /** @@ -129,174 +112,4 @@ export default class TradeAPI { txOpts ); } - - /** - * Call 0x API to generate a trade quote for two SetToken components. - * - * @param fromToken Address of token being sold - * @param toToken Address of token being bought - * @param fromTokenDecimals Token decimals of token being sold (ex: 18) - * @param toTokenDecimals Token decimals of token being bought (ex: 18) - * @param rawAmount String quantity of token to sell (ex: "0.5") - * @param fromAddress SetToken address which holds the buy / sell components - * @param setToken SetTokenAPI instance - * @param gasPrice (Optional) gasPrice to calculate gas costs with (Default: fetched from EthGasStation) - * @param slippagePercentage (Optional) max slippage, determines min receive quantity (ex: 5 (=5%)) (Default: 2%) - * @param isFirmQuote (Optional) Whether quote request is indicative or firm - * @param feePercentage (Optional) Default: 0 - * @param feeRecipient (Optional) Default: 0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55 - * @param excludedSources (Optional) Exchanges to exclude (Default: ['Kyber', 'Eth2Dai', 'Mesh']) - * @param simulatedChainId (Optional) ChainId of target network (useful when using a forked development client) - * - * @return {Promise} - */ - public async fetchTradeQuoteAsync( - fromToken: Address, - toToken: Address, - fromTokenDecimals: number, - toTokenDecimals: number, - rawAmount: string, - fromAddress: Address, - setToken: SetTokenAPI, - gasPrice?: number, - slippagePercentage?: number, - isFirmQuote?: boolean, - feePercentage?: number, - feeRecipient?: Address, - excludedSources?: string[], - simulatedChainId?: number, - ): Promise { - this.assert.schema.isValidAddress('fromToken', fromToken); - this.assert.schema.isValidAddress('toToken', toToken); - this.assert.schema.isValidAddress('fromAddress', fromAddress); - this.assert.schema.isValidJsNumber('fromTokenDecimals', fromTokenDecimals); - this.assert.schema.isValidJsNumber('toTokenDecimals', toTokenDecimals); - this.assert.schema.isValidString('rawAmount', rawAmount); - - // The forked Hardhat network has a chainId of 31337 so we can't rely on autofetching this value - const chainId = (simulatedChainId !== undefined) - ? simulatedChainId - : (await this.provider.getNetwork()).chainId; - - return this.tradeQuoter.generateQuoteForTrade({ - fromToken, - toToken, - fromTokenDecimals, - toTokenDecimals, - rawAmount, - fromAddress, - chainId, - tradeModule: this.tradeModuleWrapper, - provider: this.provider, - setToken, - gasPrice, - slippagePercentage, - isFirmQuote, - feePercentage, - feeRecipient, - excludedSources, - }); - } - - /** - * Batch multiple calls to 0x API to generate trade quotes for SetToken component pairs. By default, trade quotes - * are fetched for 0x's public endpoints using their `https://api.0x.org`, `https:///api.0x.org` - * url scheme. In practice these open endpoints appear to be rate limited at ~3 req/sec - * - * It's also possible to make calls from non-browser context with an API key using the `https://gated.api.0x.org` - * url scheme. - * - * These gated endpoints rate-limit calls *per API key* as follows (mileage may vary): - * - * > Ethereum: up to 50 requests per second/200 requests per minute. - * > Other networks: 30 requests per second. - * - * The `delayStep` parameter option allows you to stagger parallelized requests to stay within rate limits - * and is set to 300ms by default. - * - * @param orderPairs TradeOrderPairs array (see `fetchTradeQuoteAsync` for property descriptions) - * @param fromAddress SetToken address which holds the buy / sell components - * @param setToken SetTokenAPI instance - * @param gasPrice gasPrice to calculate gas costs with - * @param isFirmQuote (Optional) Whether quote request is indicative or firm - * @param feePercentage (Optional) Default: 0 - * @param feeRecipient (Optional) Default: 0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55 - * @param excludedSources (Optional) Exchanges to exclude (Default: ['Kyber', 'Eth2Dai', 'Mesh']) - * @param simulatedChainId (Optional) ChainId of target network (useful when using a forked development client) - * @param delayStep (Optional) Delay between firing each quote request (to manage rate-limiting) - * - * @return {Promise} - */ - public async batchFetchTradeQuoteAsync( - orderPairs: TradeOrderPair[], - fromAddress: Address, - setToken: SetTokenAPI, - gasPrice?: number, - isFirmQuote?: boolean, - feePercentage?: number, - feeRecipient?: Address, - excludedSources?: string[], - simulatedChainId?: number, - delayStep?: number, - ): Promise { - const self = this; - this.assert.schema.isValidAddress('fromAddress', fromAddress); - - for (const pair of orderPairs) { - this.assert.schema.isValidAddress('fromToken', pair.fromToken); - this.assert.schema.isValidAddress('toToken', pair.toToken); - this.assert.schema.isValidJsNumber('fromTokenDecimals', pair.fromTokenDecimals); - this.assert.schema.isValidJsNumber('toTokenDecimals', pair.toTokenDecimals); - this.assert.schema.isValidString('rawAmount', pair.rawAmount); - } - - // The forked Hardhat network has a chainId of 31337 so we can't rely on autofetching this value - const chainId = (simulatedChainId !== undefined) - ? simulatedChainId - : (await this.provider.getNetwork()).chainId; - - // Default 300ms delay (to keep under 200 reqs/min for public endpoints) - const _delayStep = (delayStep !== undefined) - ? delayStep - : 300; - - const orders = []; - let delay = 0; - - for (const pair of orderPairs) { - const order = new Promise(async function (resolve, reject) { - await new Promise(r => setTimeout(() => r(true), delay)); - - try { - const response = await self.tradeQuoter.generateQuoteForTrade({ - fromToken: pair.fromToken, - toToken: pair.toToken, - fromTokenDecimals: pair.fromTokenDecimals, - toTokenDecimals: pair.toTokenDecimals, - rawAmount: pair.rawAmount, - slippagePercentage: pair.slippagePercentage, - tradeModule: self.tradeModuleWrapper, - provider: self.provider, - fromAddress, - chainId, - setToken, - gasPrice, - isFirmQuote, - feePercentage, - feeRecipient, - excludedSources, - }); - - resolve(response); - } catch (e) { - reject(e); - } - }); - - delay += _delayStep; - orders.push(order); - } - - return Promise.all(orders); - } } diff --git a/src/api/UtilsAPI.ts b/src/api/UtilsAPI.ts index 26adbb6..89ceb04 100644 --- a/src/api/UtilsAPI.ts +++ b/src/api/UtilsAPI.ts @@ -22,6 +22,7 @@ import { Address } from '@setprotocol/set-protocol-v2/utils/types'; import SetTokenAPI from './SetTokenAPI'; import Assertions from '../assertions'; +import TradeModuleWrapper from '../wrappers/set-protocol-v2/TradeModuleWrapper'; import { TradeQuoter, @@ -31,7 +32,9 @@ import { import { SwapQuote, + TradeQuote, SwapOrderPairs, + TradeOrderPair, CoinGeckoTokenData, CoinGeckoTokenMap, GasOracleSpeed, @@ -51,16 +54,19 @@ export default class UtilsAPI { private assert: Assertions; private provider: Provider; private tradeQuoter: TradeQuoter; + private tradeModuleWrapper: TradeModuleWrapper; private coinGecko: CoinGeckoDataService; private chainId: number; public constructor( provider: Provider, + tradeModuleAddress: Address, zeroExApiKey?: string, zeroExApiUrls?: ZeroExApiUrls ) { this.provider = provider; this.assert = new Assertions(); + this.tradeModuleWrapper = new TradeModuleWrapper(provider, tradeModuleAddress); this.tradeQuoter = new TradeQuoter(zeroExApiKey, zeroExApiUrls); } @@ -245,6 +251,176 @@ export default class UtilsAPI { return Promise.all(orders); } + /** + * Call 0x API to generate a trade quote for two SetToken components. + * + * @param fromToken Address of token being sold + * @param toToken Address of token being bought + * @param fromTokenDecimals Token decimals of token being sold (ex: 18) + * @param toTokenDecimals Token decimals of token being bought (ex: 18) + * @param rawAmount String quantity of token to sell (ex: "0.5") + * @param fromAddress SetToken address which holds the buy / sell components + * @param setToken SetTokenAPI instance + * @param gasPrice (Optional) gasPrice to calculate gas costs with (Default: fetched from EthGasStation) + * @param slippagePercentage (Optional) max slippage, determines min receive quantity (ex: 5 (=5%)) (Default: 2%) + * @param isFirmQuote (Optional) Whether quote request is indicative or firm + * @param feePercentage (Optional) Default: 0 + * @param feeRecipient (Optional) Default: 0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55 + * @param excludedSources (Optional) Exchanges to exclude (Default: ['Kyber', 'Eth2Dai', 'Mesh']) + * @param simulatedChainId (Optional) ChainId of target network (useful when using a forked development client) + * + * @return {Promise} + */ + public async fetchTradeQuoteAsync( + fromToken: Address, + toToken: Address, + fromTokenDecimals: number, + toTokenDecimals: number, + rawAmount: string, + fromAddress: Address, + setToken: SetTokenAPI, + gasPrice?: number, + slippagePercentage?: number, + isFirmQuote?: boolean, + feePercentage?: number, + feeRecipient?: Address, + excludedSources?: string[], + simulatedChainId?: number, + ): Promise { + this.assert.schema.isValidAddress('fromToken', fromToken); + this.assert.schema.isValidAddress('toToken', toToken); + this.assert.schema.isValidAddress('fromAddress', fromAddress); + this.assert.schema.isValidJsNumber('fromTokenDecimals', fromTokenDecimals); + this.assert.schema.isValidJsNumber('toTokenDecimals', toTokenDecimals); + this.assert.schema.isValidString('rawAmount', rawAmount); + + // The forked Hardhat network has a chainId of 31337 so we can't rely on autofetching this value + const chainId = (simulatedChainId !== undefined) + ? simulatedChainId + : (await this.provider.getNetwork()).chainId; + + return this.tradeQuoter.generateQuoteForTrade({ + fromToken, + toToken, + fromTokenDecimals, + toTokenDecimals, + rawAmount, + fromAddress, + chainId, + tradeModule: this.tradeModuleWrapper, + provider: this.provider, + setToken, + gasPrice, + slippagePercentage, + isFirmQuote, + feePercentage, + feeRecipient, + excludedSources, + }); + } + + /** + * Batch multiple calls to 0x API to generate trade quotes for SetToken component pairs. By default, trade quotes + * are fetched for 0x's public endpoints using their `https://api.0x.org`, `https:///api.0x.org` + * url scheme. In practice these open endpoints appear to be rate limited at ~3 req/sec + * + * It's also possible to make calls from non-browser context with an API key using the `https://gated.api.0x.org` + * url scheme. + * + * These gated endpoints rate-limit calls *per API key* as follows (mileage may vary): + * + * > Ethereum: up to 50 requests per second/200 requests per minute. + * > Other networks: 30 requests per second. + * + * The `delayStep` parameter option allows you to stagger parallelized requests to stay within rate limits + * and is set to 300ms by default. + * + * @param orderPairs TradeOrderPairs array (see `fetchTradeQuoteAsync` for property descriptions) + * @param fromAddress SetToken address which holds the buy / sell components + * @param setToken SetTokenAPI instance + * @param gasPrice gasPrice to calculate gas costs with + * @param isFirmQuote (Optional) Whether quote request is indicative or firm + * @param feePercentage (Optional) Default: 0 + * @param feeRecipient (Optional) Default: 0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55 + * @param excludedSources (Optional) Exchanges to exclude (Default: ['Kyber', 'Eth2Dai', 'Mesh']) + * @param simulatedChainId (Optional) ChainId of target network (useful when using a forked development client) + * @param delayStep (Optional) Delay between firing each quote request (to manage rate-limiting) + * + * @return {Promise} + */ + public async batchFetchTradeQuoteAsync( + orderPairs: TradeOrderPair[], + fromAddress: Address, + setToken: SetTokenAPI, + gasPrice?: number, + isFirmQuote?: boolean, + feePercentage?: number, + feeRecipient?: Address, + excludedSources?: string[], + simulatedChainId?: number, + delayStep?: number, + ): Promise { + const self = this; + this.assert.schema.isValidAddress('fromAddress', fromAddress); + + for (const pair of orderPairs) { + this.assert.schema.isValidAddress('fromToken', pair.fromToken); + this.assert.schema.isValidAddress('toToken', pair.toToken); + this.assert.schema.isValidJsNumber('fromTokenDecimals', pair.fromTokenDecimals); + this.assert.schema.isValidJsNumber('toTokenDecimals', pair.toTokenDecimals); + this.assert.schema.isValidString('rawAmount', pair.rawAmount); + } + + // The forked Hardhat network has a chainId of 31337 so we can't rely on autofetching this value + const chainId = (simulatedChainId !== undefined) + ? simulatedChainId + : (await this.provider.getNetwork()).chainId; + + // Default 300ms delay (to keep under 200 reqs/min for public endpoints) + const _delayStep = (delayStep !== undefined) + ? delayStep + : 300; + + const orders = []; + let delay = 0; + + for (const pair of orderPairs) { + const order = new Promise(async function (resolve, reject) { + await new Promise(r => setTimeout(() => r(true), delay)); + + try { + const response = await self.tradeQuoter.generateQuoteForTrade({ + fromToken: pair.fromToken, + toToken: pair.toToken, + fromTokenDecimals: pair.fromTokenDecimals, + toTokenDecimals: pair.toTokenDecimals, + rawAmount: pair.rawAmount, + slippagePercentage: pair.slippagePercentage, + tradeModule: self.tradeModuleWrapper, + provider: self.provider, + fromAddress, + chainId, + setToken, + gasPrice, + isFirmQuote, + feePercentage, + feeRecipient, + excludedSources, + }); + + resolve(response); + } catch (e) { + reject(e); + } + }); + + delay += _delayStep; + orders.push(order); + } + + return Promise.all(orders); + } + /** * Fetches a list of tokens and their metadata from CoinGecko. Each entry includes * the token's address, proper name, decimals, exchange symbol and a logo URI if available. diff --git a/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts b/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts index ad696ad..7573a32 100644 --- a/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts +++ b/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts @@ -14,11 +14,12 @@ 'use strict'; import { Address } from '@setprotocol/set-protocol-v2/utils/types'; -import { ContractTransaction, constants } from 'ethers'; +import { ContractTransaction, constants, BigNumber } from 'ethers'; import { TransactionOverrides } from '@setprotocol/set-protocol-v2/dist/typechain'; import { Provider } from '@ethersproject/providers'; import { generateTxOpts } from '../../utils/transactions'; import { TradeInfo, BatchTradeResult } from '../../types'; +import { RevertError } from '@0x/utils'; import ContractWrapper from './ContractWrapper'; @@ -73,13 +74,45 @@ export default class BatchTradeExtensionWrapper { } /** - * Returns success status of each trade (and any revert reasons in the case of failure) executed as - * part of a batch. Transaction must be mined before this method is called. + * Estimate gas cost for executing a batch trade on a supported DEX * - * @param transactionHash Transaction hash of tx which executed a batch trade - * @param trades Array of TradeInfo objects which were submitted with the batch trade transaction + * @param setTokenAddress Address of the deployed SetToken to trade on behalf of + * @param trades Array of TradeInfo objects to execute as a batch of trades + * @param callerAddress Address of caller */ - public async getBatchTradeResultsAsync(transactionHash: string, trades: TradeInfo[]): Promise { + public async estimateGasForBatchTradeWithOperator( + setTokenAddress: Address, + trades: TradeInfo[], + callerAddress: Address + ): Promise { + const batchTradeExtensionInstance = await this.contracts.loadBatchTradeExtensionWithoutSigner( + this.batchTradeExtensionAddress + ); + + const tx = await batchTradeExtensionInstance.populateTransaction.batchTrade( + setTokenAddress, + trades, + {from: callerAddress } + ); + + return this.provider.estimateGas(tx); + } + + /** + * Given the transaction hash of `batchTradeWithOperator` tx, fetches success statuses of all trades + * executed including the revert reason if a trade failed. If a revert reason is formatted as a + * custom error, invokes customErrorParser to transform it into a human readable string. + * (Uses a ZeroEx custom error parser by default) + * + * + * @param transactionHash Transaction hash of a batchTradeWithOperator tx + * @param trades Array of trades executed by batchTrade + */ + public async getBatchTradeResults( + transactionHash: string, + trades: TradeInfo[], + customErrorParser: (hexEncodedErr: string) => string = this.decodeZeroExCustomError + ): Promise { const batchTradeExtensionInstance = await this.contracts.loadBatchTradeExtensionAsync( this.batchTradeExtensionAddress, constants.AddressZero @@ -96,25 +129,32 @@ export default class BatchTradeExtensionWrapper { } for (const log of receipt.logs) { + try { const decodedLog = batchTradeExtensionInstance.interface.parseLog({ data: log.data, topics: log.topics, }); if (decodedLog.name === 'StringTradeFailed') { - const tradeIndex = (decodedLog.args as any).i.toNumber(); + const tradeIndex = (decodedLog.args as any)._index.toNumber(); results[tradeIndex].success = false; - results[tradeIndex].revertReason = (decodedLog.args as any).reason; + results[tradeIndex].revertReason = (decodedLog.args as any)._reason; } - // May need to do something extra here to decode low level revert bytes if (decodedLog.name === 'BytesTradeFailed') { - const tradeIndex = (decodedLog.args as any).i.toNumber(); + const tradeIndex = (decodedLog.args as any)._index.toNumber(); results[tradeIndex].success = false; - results[tradeIndex].revertReason = (decodedLog.args as any).reason; + results[tradeIndex].revertReason = customErrorParser((decodedLog.args as any)._reason); } + } catch (e) { + // ignore all non-batch trade events + } } return results; } + + private decodeZeroExCustomError(hexEncodedErr: string): string { + return RevertError.decode(hexEncodedErr).message; + } } diff --git a/src/wrappers/set-v2-strategies/ContractWrapper.ts b/src/wrappers/set-v2-strategies/ContractWrapper.ts index 0747228..c422219 100644 --- a/src/wrappers/set-v2-strategies/ContractWrapper.ts +++ b/src/wrappers/set-v2-strategies/ContractWrapper.ts @@ -195,4 +195,14 @@ export default class ContractWrapper { return batchTradeExtensionContract; } } + + /** + * Load BatchTradeExtension contract without signer (for running populateTransaction) + * + * @param batchTradeExtensionAddress Address of the BatchTradeExtension + * @return BatchTradeExtension contract instance + */ + public loadBatchTradeExtensionWithoutSigner(batchTradeExtensionAddress: Address): BatchTradeExtension { + return BatchTradeExtension__factory.connect(batchTradeExtensionAddress); + } } diff --git a/test/api/UtilsAPI.spec.ts b/test/api/UtilsAPI.spec.ts index 0f993e1..671b09d 100644 --- a/test/api/UtilsAPI.spec.ts +++ b/test/api/UtilsAPI.spec.ts @@ -22,6 +22,8 @@ import { Address } from '@setprotocol/set-protocol-v2/utils/types'; import UtilsAPI from '@src/api/UtilsAPI'; import type SetTokenAPI from '@src/api/SetTokenAPI'; +import TradeModuleWrapper from '@src/wrappers/set-protocol-v2/TradeModuleWrapper'; + import { TradeQuoter, CoinGeckoDataService, @@ -29,6 +31,7 @@ import { import { expect } from '@test/utils/chai'; import { SwapQuote, + TradeQuote, SwapOrderPairs, CoinGeckoTokenData, CoinGeckoTokenMap, @@ -56,12 +59,17 @@ axios.get.mockImplementation(val => { }); describe('UtilsAPI', () => { + let tradeModuleAddress: Address; + let tradeModuleWrapper: TradeModuleWrapper; let tradeQuoter: TradeQuoter; let utilsAPI: UtilsAPI; beforeEach(async () => { - utilsAPI = new UtilsAPI(provider); + [ tradeModuleAddress ] = await provider.listAccounts(); + + utilsAPI = new UtilsAPI(provider, tradeModuleAddress); tradeQuoter = (TradeQuoter as any).mock.instances[0]; + tradeModuleWrapper = (TradeModuleWrapper as any).mock.instances[0]; }); afterEach(async () => { @@ -69,6 +77,120 @@ describe('UtilsAPI', () => { (axios as any).mockClear(); }); + describe('#fetchTradeQuoteAsync', () => { + let subjectFromToken: Address; + let subjectToToken: Address; + let subjectFromTokenDecimals: number; + let subjectToTokenDecimals: number; + let subjectRawAmount: string; + let subjectFromAddress: Address; + let subjectSetToken: SetTokenAPI; + let subjectGasPrice: number; + let subjectFeePercentage: number; + + beforeEach(async () => { + subjectFromToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; + subjectToToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + subjectFromTokenDecimals = 8; + subjectToTokenDecimals = 6; + subjectRawAmount = '5'; + subjectFromAddress = '0xCCCC262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + subjectSetToken = { val: 'settoken' } as SetTokenAPI; + subjectGasPrice = 20; + subjectFeePercentage = 1; + }); + + async function subject(): Promise { + return await utilsAPI.fetchTradeQuoteAsync( + subjectFromToken, + subjectToToken, + subjectFromTokenDecimals, + subjectToTokenDecimals, + subjectRawAmount, + subjectFromAddress, + subjectSetToken, + subjectGasPrice, + undefined, + undefined, + subjectFeePercentage + ); + } + + it('should call the TradeQuoter with correct params', async () => { + const expectedQuoteOptions = { + fromToken: subjectFromToken, + toToken: subjectToToken, + fromTokenDecimals: subjectFromTokenDecimals, + toTokenDecimals: subjectToTokenDecimals, + rawAmount: subjectRawAmount, + fromAddress: subjectFromAddress, + chainId: (await provider.getNetwork()).chainId, + tradeModule: tradeModuleWrapper, + provider: provider, + setToken: subjectSetToken, + gasPrice: subjectGasPrice, + slippagePercentage: undefined, + isFirmQuote: undefined, + feePercentage: subjectFeePercentage, + feeRecipient: undefined, + excludedSources: undefined, + }; + await subject(); + + expect(tradeQuoter.generateQuoteForTrade).to.have.beenCalledWith(expectedQuoteOptions); + }); + + describe('when the fromToken address is invalid', () => { + beforeEach(async () => { + subjectFromToken = '0xInvalidAddress'; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when the toToken address is invalid', () => { + beforeEach(async () => { + subjectToToken = '0xInvalidAddress'; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when the fromTokenDecimals is invalid', () => { + beforeEach(async () => { + subjectFromTokenDecimals = '100' as number; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when the toTokenDecimals is invalid', () => { + beforeEach(async () => { + subjectToTokenDecimals = '100' as number; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when the rawAmount quantity is invalid', () => { + beforeEach(async () => { + subjectRawAmount = 5 as string; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + }); + describe('#fetchSwapQuoteAsync', () => { let subjectFromToken: Address; let subjectToToken: Address; diff --git a/tsconfig.json b/tsconfig.json index 1d8ea72..7ccddf9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "resolveJsonModule": true, "declaration": true, "declarationDir": "./dist/types", + "skipLibCheck": true, "paths": { "@src/*": [ "src/*" ], "@test/*": [ "test/*" ] diff --git a/yarn.lock b/yarn.lock index 9e5c8e8..5e8869a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,47 @@ # yarn lockfile v1 +"@0x/types@^3.3.6": + version "3.3.6" + resolved "https://registry.yarnpkg.com/@0x/types/-/types-3.3.6.tgz#2746137791d5c8ca6034311a9327fc78b46c5f63" + integrity sha512-ljtc9X4BnlM+MkcLet6hypsF1og0N4lMxt/2nNuPvbI6qude1kdu7Eyw2yb8fpwQfClTtR4rYUT6DeL0zh7qmQ== + dependencies: + "@types/node" "12.12.54" + bignumber.js "~9.0.2" + ethereum-types "^3.7.0" + +"@0x/typescript-typings@^5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@0x/typescript-typings/-/typescript-typings-5.3.1.tgz#853bcad04fbaee4af63532317d7f9ef486dfbb1a" + integrity sha512-baxz6gTNDI+q/TBOm8xXeqCiCu/Rw6a/cpuWzjFNPPTMgO7o4nsk6fIGFGJLuSGUmDMOx+YVzUB0emV6dMtMxA== + dependencies: + "@types/bn.js" "^4.11.0" + "@types/node" "12.12.54" + "@types/react" "*" + bignumber.js "~9.0.2" + ethereum-types "^3.7.0" + popper.js "1.14.3" + +"@0x/utils@^6.5.3": + version "6.5.3" + resolved "https://registry.yarnpkg.com/@0x/utils/-/utils-6.5.3.tgz#b944ffb197a062e3996a4f2e6e43f7babe21e113" + integrity sha512-C8Af9MeNvWTtSg5eEOObSZ+7gjOGSHkhqDWv8iPfrMMt7yFkAjHxpXW+xufk6ZG2VTg+hel82GDyhKaGtoQZDA== + dependencies: + "@0x/types" "^3.3.6" + "@0x/typescript-typings" "^5.3.1" + "@types/mocha" "^5.2.7" + "@types/node" "12.12.54" + abortcontroller-polyfill "^1.1.9" + bignumber.js "~9.0.2" + chalk "^2.3.0" + detect-node "2.0.3" + ethereum-types "^3.7.0" + ethereumjs-util "^7.1.0" + ethers "~4.0.4" + isomorphic-fetch "2.2.1" + js-sha3 "^0.7.0" + lodash "^4.17.11" + "@0xproject/types@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@0xproject/types/-/types-1.1.4.tgz#3ffd65e670d6a21dab19ee0ffd5fad0056291b8e" @@ -1040,10 +1081,10 @@ module-alias "^2.2.2" replace-in-file "^6.1.0" -"@setprotocol/set-v2-strategies@^0.0.10-batch.0": - version "0.0.10-batch.0" - resolved "https://registry.yarnpkg.com/@setprotocol/set-v2-strategies/-/set-v2-strategies-0.0.10-batch.0.tgz#419a58a6c3154ff626ff9a37311b3c981dc0002f" - integrity sha512-3P/oWV6vjuttCoAybEfVF7x/6KjYkrUW27cS1SOOPwOIJzzVdHdFRd9zV587qbpAfULk6w7HB1t1aVTkQ3mfMw== +"@setprotocol/set-v2-strategies@^0.0.10": + version "0.0.10" + resolved "https://registry.yarnpkg.com/@setprotocol/set-v2-strategies/-/set-v2-strategies-0.0.10.tgz#eab1bc4001c7a0d5dac61b82ff991d413daf5fde" + integrity sha512-Jca6tLOadDSF0hRw65ML6Sf69Z3lwUJFYta4jn08iaKnGZMgRlDiRo8KvpWolmyznnCLTdFOPtz/woJKqkc09w== dependencies: "@setprotocol/set-protocol-v2" "0.10.0-hhat.1" "@uniswap/v3-sdk" "^3.5.1" @@ -1168,18 +1209,23 @@ jest-diff "^25.2.1" pretty-format "^25.2.1" +"@types/mocha@^5.2.7": + version "5.2.7" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.7.tgz#315d570ccb56c53452ff8638738df60726d5b6ea" + integrity sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ== + "@types/node@*", "@types/node@^14.0.23": version "14.0.27" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.27.tgz#a151873af5a5e851b51b3b065c9e63390a9e0eb1" +"@types/node@12.12.54", "@types/node@^12.12.6": + version "12.12.54" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.54.tgz#a4b58d8df3a4677b6c08bfbc94b7ad7a7a5f82d1" + "@types/node@^10.3.2": version "10.17.28" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.28.tgz#0e36d718a29355ee51cec83b42d921299200f6d9" -"@types/node@^12.12.6": - version "12.12.54" - resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.54.tgz#a4b58d8df3a4677b6c08bfbc94b7ad7a7a5f82d1" - "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -1836,6 +1882,11 @@ bignumber.js@~4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-4.1.0.tgz#db6f14067c140bd46624815a7916c92d9b6c24b1" +bignumber.js@~9.0.2: + version "9.0.2" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.2.tgz#71c6c6bed38de64e24a65ebe16cfcf23ae693673" + integrity sha512-GAcQvbpsM0pUb0zw1EI0KhQEZ+lRwR5fYaAp3vPOYuP7aDvGy6cVN6XHLauvF8SOga2y0dcLcjt3iQDTSEliyw== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -2926,6 +2977,14 @@ ethereum-types@^3.2.0: "@types/node" "*" bignumber.js "~9.0.0" +ethereum-types@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/ethereum-types/-/ethereum-types-3.7.0.tgz#2fec14cebef6e68f3b66a6efd4eaa1003f2c972b" + integrity sha512-7gU4cUkpmKbAMgEdF3vWFCcLS1aKdsGxIFbd8WIHgBOHLwlcjfcxtkwrFGXuCc90cg6V4MDA9iOI7W0hQ7eTvQ== + dependencies: + "@types/node" "12.12.54" + bignumber.js "~9.0.2" + ethereumjs-common@^1.3.2, ethereumjs-common@^1.5.0: version "1.5.2" resolved "https://registry.yarnpkg.com/ethereumjs-common/-/ethereumjs-common-1.5.2.tgz#2065dbe9214e850f2e955a80e650cb6999066979" @@ -3035,6 +3094,21 @@ ethers@5.5.2, ethers@^5.5.2: "@ethersproject/web" "5.5.1" "@ethersproject/wordlists" "5.5.0" +ethers@~4.0.4: + version "4.0.49" + resolved "https://registry.yarnpkg.com/ethers/-/ethers-4.0.49.tgz#0eb0e9161a0c8b4761be547396bbe2fb121a8894" + integrity sha512-kPltTvWiyu+OktYy1IStSO16i2e7cS9D9OxZ81q2UUaiNPVrm/RTcbxamCXF9VUSKzJIdJV68EAIhTEVBalRWg== + dependencies: + aes-js "3.0.0" + bn.js "^4.11.9" + elliptic "6.5.4" + hash.js "1.1.3" + js-sha3 "0.5.7" + scrypt-js "2.0.4" + setimmediate "1.0.4" + uuid "2.0.1" + xmlhttprequest "1.8.0" + ethjs-unit@0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/ethjs-unit/-/ethjs-unit-0.1.6.tgz#c665921e476e87bce2a9d588a6fe0405b2c41699" @@ -4117,7 +4191,7 @@ isobject@^3.0.0, isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" -isomorphic-fetch@^2.2.1: +isomorphic-fetch@2.2.1, isomorphic-fetch@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" dependencies: @@ -4817,6 +4891,11 @@ lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" +lodash@^4.17.11: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.5: version "4.17.19" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" @@ -6025,6 +6104,11 @@ scrypt-js@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-2.0.3.tgz#bb0040be03043da9a012a2cea9fc9f852cfc87d4" +scrypt-js@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-2.0.4.tgz#32f8c5149f0797672e551c07e230f834b6af5f16" + integrity sha512-4KsaGcPnuhtCZQCxFxN3GVYIhKFPTdLd8PLC552XwbMndtD0cjRFAhDuuydXQ0h08ZfPgzqe6EKHozpuH74iDw== + scrypt-js@3.0.1, scrypt-js@^3.0.0, scrypt-js@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-3.0.1.tgz#d314a57c2aef69d1ad98a138a21fe9eafa9ee312" From f1115615ec594bbf065964b3ee50a8049002a188 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 21 Apr 2022 15:40:49 -0700 Subject: [PATCH 04/26] Add tests --- src/api/extensions/BatchTradeExtensionAPI.ts | 68 +++- src/types/common.ts | 2 +- .../BatchTradeExtensionWrapper.ts | 90 +---- test/api/TradeAPI.spec.ts | 122 ------- test/api/UtilsAPI.spec.ts | 340 +++++++++++++----- .../extensions/BatchTradeExtensionAPI.spec.ts | 263 ++++++++++++++ test/fixtures/tradeQuote.ts | 18 + 7 files changed, 592 insertions(+), 311 deletions(-) create mode 100644 test/api/extensions/BatchTradeExtensionAPI.spec.ts diff --git a/src/api/extensions/BatchTradeExtensionAPI.ts b/src/api/extensions/BatchTradeExtensionAPI.ts index cd74094..5794164 100644 --- a/src/api/extensions/BatchTradeExtensionAPI.ts +++ b/src/api/extensions/BatchTradeExtensionAPI.ts @@ -18,13 +18,14 @@ import { ContractTransaction, BytesLike, utils as EthersUtils } from 'ethers'; import { Provider } from '@ethersproject/providers'; +import { RevertError } from '@0x/utils'; import { Address } from '@setprotocol/set-protocol-v2/utils/types'; import { TransactionOverrides } from '@setprotocol/set-protocol-v2/dist/typechain'; import { BatchTradeExtension__factory } from '@setprotocol/set-v2-strategies/dist/typechain/factories/BatchTradeExtension__factory'; import BatchTradeExtensionWrapper from '../../wrappers/set-v2-strategies/BatchTradeExtensionWrapper'; import Assertions from '../../assertions'; -import { TradeInfo } from '../../types'; +import { TradeInfo, BatchTradeResult } from '../../types'; /** * @title BatchTradeExtensionAPI @@ -35,6 +36,7 @@ import { TradeInfo } from '../../types'; * data packets that encode module and extension initialization method calls. */ export default class BatchTradeExtensionAPI { + private provider: Provider; private batchTradeExtensionWrapper: BatchTradeExtensionWrapper; private assert: Assertions; @@ -42,6 +44,7 @@ export default class BatchTradeExtensionAPI { provider: Provider, batchTradeExtensionAddress: Address, assertions?: Assertions) { + this.provider = provider; this.batchTradeExtensionWrapper = new BatchTradeExtensionWrapper(provider, batchTradeExtensionAddress); this.assert = assertions || new Assertions(); } @@ -76,14 +79,67 @@ export default class BatchTradeExtensionAPI { } /** - * Generates TradeExtension initialize call bytecode to be passed as an element in the `initializeBytecode` + * Given the transaction hash of `batchTradeWithOperator` tx, fetches success statuses of all trades + * executed including the revert reason if a trade failed. If a revert reason is formatted as a + * custom error, invokes customErrorParser to transform it into a human readable string. + * (Uses a ZeroEx custom error parser by default) + * + * + * @param transactionHash Transaction hash of a batchTradeWithOperator tx + * @param trades Array of trades executed by batchTrade + */ + public async getBatchTradeResultsAsync( + transactionHash: string, + trades: TradeInfo[], + customErrorParser: (hexEncodedErr: string) => string = this.decodeZeroExCustomError + ): Promise { + this.assert.schema.isValidBytes('transactionHash', transactionHash); + this._validateTrades(trades); + + const results: BatchTradeResult[] = []; + const extensionInterface = new EthersUtils.Interface(BatchTradeExtension__factory.abi); + const receipt = await this.provider.getTransactionReceipt(transactionHash); + + for (const trade of trades) { + results.push({ + success: true, + tradeInfo: trade, + }); + } + + for (const log of receipt.logs) { + try { + const decodedLog = extensionInterface.parseLog({ data: log.data, topics: log.topics }); + + if (decodedLog.name === 'StringTradeFailed') { + const tradeIndex = (decodedLog.args as any)._index.toNumber(); + results[tradeIndex].success = false; + results[tradeIndex].revertReason = (decodedLog.args as any)._reason; + } + + if (decodedLog.name === 'BytesTradeFailed') { + const tradeIndex = (decodedLog.args as any)._index.toNumber(); + results[tradeIndex].success = false; + results[tradeIndex].revertReason = customErrorParser((decodedLog.args as any)._reason); + } + } catch (e) { + console.log('e --> ' + e); + // ignore all non-batch trade events + } + } + + return results; + } + + /** + * Generates BatchTradeExtension initialize call bytecode to be passed as an element in the `initializeBytecode` * array for the DelegatedManagerFactory's `initializeAsync` method. * - * @param delegatedManagerAddress Instance of deployed DelegatedManager to initialize the TradeExtension for + * @param delegatedManagerAddress Instance of deployed DelegatedManager to initialize the BatchTradeExtension for * * @return Initialization bytecode */ - public getTradeExtensionInitializationBytecode( + public getBatchTradeExtensionInitializationBytecode( delegatedManagerAddress: Address ): BytesLike { this.assert.schema.isValidAddress('delegatedManagerAddress', delegatedManagerAddress); @@ -117,4 +173,8 @@ export default class BatchTradeExtensionAPI { this.assert.schema.isValidBytes('data', trade.data); } } + + private decodeZeroExCustomError(hexEncodedErr: string): string { + return RevertError.decode(hexEncodedErr).message; + } } diff --git a/src/types/common.ts b/src/types/common.ts index 9ebf717..fb5b1df 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -191,5 +191,5 @@ export type TradeOrderPair = { fromTokenDecimals: number; toTokenDecimals: number; rawAmount: string; - slippagePercentage: number; + slippagePercentage?: number; }; diff --git a/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts b/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts index 7573a32..dcfdb6e 100644 --- a/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts +++ b/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts @@ -14,12 +14,11 @@ 'use strict'; import { Address } from '@setprotocol/set-protocol-v2/utils/types'; -import { ContractTransaction, constants, BigNumber } from 'ethers'; +import { ContractTransaction } from 'ethers'; import { TransactionOverrides } from '@setprotocol/set-protocol-v2/dist/typechain'; import { Provider } from '@ethersproject/providers'; import { generateTxOpts } from '../../utils/transactions'; -import { TradeInfo, BatchTradeResult } from '../../types'; -import { RevertError } from '@0x/utils'; +import { TradeInfo } from '../../types'; import ContractWrapper from './ContractWrapper'; @@ -72,89 +71,4 @@ export default class BatchTradeExtensionWrapper { txOptions ); } - - /** - * Estimate gas cost for executing a batch trade on a supported DEX - * - * @param setTokenAddress Address of the deployed SetToken to trade on behalf of - * @param trades Array of TradeInfo objects to execute as a batch of trades - * @param callerAddress Address of caller - */ - public async estimateGasForBatchTradeWithOperator( - setTokenAddress: Address, - trades: TradeInfo[], - callerAddress: Address - ): Promise { - const batchTradeExtensionInstance = await this.contracts.loadBatchTradeExtensionWithoutSigner( - this.batchTradeExtensionAddress - ); - - const tx = await batchTradeExtensionInstance.populateTransaction.batchTrade( - setTokenAddress, - trades, - {from: callerAddress } - ); - - return this.provider.estimateGas(tx); - } - - /** - * Given the transaction hash of `batchTradeWithOperator` tx, fetches success statuses of all trades - * executed including the revert reason if a trade failed. If a revert reason is formatted as a - * custom error, invokes customErrorParser to transform it into a human readable string. - * (Uses a ZeroEx custom error parser by default) - * - * - * @param transactionHash Transaction hash of a batchTradeWithOperator tx - * @param trades Array of trades executed by batchTrade - */ - public async getBatchTradeResults( - transactionHash: string, - trades: TradeInfo[], - customErrorParser: (hexEncodedErr: string) => string = this.decodeZeroExCustomError - ): Promise { - const batchTradeExtensionInstance = await this.contracts.loadBatchTradeExtensionAsync( - this.batchTradeExtensionAddress, - constants.AddressZero - ); - - const receipt = await this.provider.getTransactionReceipt(transactionHash); - const results: BatchTradeResult[] = []; - - for (const trade of trades) { - results.push({ - success: true, - tradeInfo: trade, - }); - } - - for (const log of receipt.logs) { - try { - const decodedLog = batchTradeExtensionInstance.interface.parseLog({ - data: log.data, - topics: log.topics, - }); - - if (decodedLog.name === 'StringTradeFailed') { - const tradeIndex = (decodedLog.args as any)._index.toNumber(); - results[tradeIndex].success = false; - results[tradeIndex].revertReason = (decodedLog.args as any)._reason; - } - - if (decodedLog.name === 'BytesTradeFailed') { - const tradeIndex = (decodedLog.args as any)._index.toNumber(); - results[tradeIndex].success = false; - results[tradeIndex].revertReason = customErrorParser((decodedLog.args as any)._reason); - } - } catch (e) { - // ignore all non-batch trade events - } - } - - return results; - } - - private decodeZeroExCustomError(hexEncodedErr: string): string { - return RevertError.decode(hexEncodedErr).message; - } } diff --git a/test/api/TradeAPI.spec.ts b/test/api/TradeAPI.spec.ts index d120b4e..86ec9fa 100644 --- a/test/api/TradeAPI.spec.ts +++ b/test/api/TradeAPI.spec.ts @@ -24,21 +24,16 @@ import { ether } from '@setprotocol/set-protocol-v2/dist/utils/common'; import TradeAPI from '@src/api/TradeAPI'; import TradeModuleWrapper from '@src/wrappers/set-protocol-v2/TradeModuleWrapper'; -import type SetTokenAPI from '@src/api/SetTokenAPI'; import { TradeQuoter, } from '@src/api/utils'; import { expect } from '@test/utils/chai'; -import { - TradeQuote, -} from '@src/types'; import { tradeQuoteFixtures as fixture } from '../fixtures/tradeQuote'; const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545'); jest.mock('@src/wrappers/set-protocol-v2/TradeModuleWrapper'); -jest.mock('@src/api/utils/tradeQuoter'); jest.mock('axios'); jest.mock('graph-results-pager'); @@ -60,7 +55,6 @@ describe('TradeAPI', () => { let owner: Address; let tradeModuleWrapper: TradeModuleWrapper; - let tradeQuoter: TradeQuoter; let tradeAPI: TradeAPI; beforeEach(async () => { @@ -72,12 +66,10 @@ describe('TradeAPI', () => { tradeAPI = new TradeAPI(provider, tradeModuleAddress); tradeModuleWrapper = (TradeModuleWrapper as any).mock.instances[0]; - tradeQuoter = (TradeQuoter as any).mock.instances[0]; }); afterEach(async () => { (TradeModuleWrapper as any).mockClear(); - (TradeQuoter as any).mockClear(); (axios as any).mockClear(); }); @@ -204,118 +196,4 @@ describe('TradeAPI', () => { }); }); }); - - describe('#fetchTradeQuoteAsync', () => { - let subjectFromToken: Address; - let subjectToToken: Address; - let subjectFromTokenDecimals: number; - let subjectToTokenDecimals: number; - let subjectRawAmount: string; - let subjectFromAddress: Address; - let subjectSetToken: SetTokenAPI; - let subjectGasPrice: number; - let subjectFeePercentage: number; - - beforeEach(async () => { - subjectFromToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; - subjectToToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; - subjectFromTokenDecimals = 8; - subjectToTokenDecimals = 6; - subjectRawAmount = '5'; - subjectFromAddress = '0xCCCC262A92581EC09C2d522b48bCcd9E3C8ACf9C'; - subjectSetToken = { val: 'settoken' } as SetTokenAPI; - subjectGasPrice = 20; - subjectFeePercentage = 1; - }); - - async function subject(): Promise { - return await tradeAPI.fetchTradeQuoteAsync( - subjectFromToken, - subjectToToken, - subjectFromTokenDecimals, - subjectToTokenDecimals, - subjectRawAmount, - subjectFromAddress, - subjectSetToken, - subjectGasPrice, - undefined, - undefined, - subjectFeePercentage - ); - } - - it('should call the TradeQuoter with correct params', async () => { - const expectedQuoteOptions = { - fromToken: subjectFromToken, - toToken: subjectToToken, - fromTokenDecimals: subjectFromTokenDecimals, - toTokenDecimals: subjectToTokenDecimals, - rawAmount: subjectRawAmount, - fromAddress: subjectFromAddress, - chainId: (await provider.getNetwork()).chainId, - tradeModule: tradeModuleWrapper, - provider: provider, - setToken: subjectSetToken, - gasPrice: subjectGasPrice, - slippagePercentage: undefined, - isFirmQuote: undefined, - feePercentage: subjectFeePercentage, - feeRecipient: undefined, - excludedSources: undefined, - }; - await subject(); - - expect(tradeQuoter.generateQuoteForTrade).to.have.beenCalledWith(expectedQuoteOptions); - }); - - describe('when the fromToken address is invalid', () => { - beforeEach(async () => { - subjectFromToken = '0xInvalidAddress'; - }); - - it('should throw with invalid params', async () => { - await expect(subject()).to.be.rejectedWith('Validation error'); - }); - }); - - describe('when the toToken address is invalid', () => { - beforeEach(async () => { - subjectToToken = '0xInvalidAddress'; - }); - - it('should throw with invalid params', async () => { - await expect(subject()).to.be.rejectedWith('Validation error'); - }); - }); - - describe('when the fromTokenDecimals is invalid', () => { - beforeEach(async () => { - subjectFromTokenDecimals = '100' as number; - }); - - it('should throw with invalid params', async () => { - await expect(subject()).to.be.rejectedWith('Validation error'); - }); - }); - - describe('when the toTokenDecimals is invalid', () => { - beforeEach(async () => { - subjectToTokenDecimals = '100' as number; - }); - - it('should throw with invalid params', async () => { - await expect(subject()).to.be.rejectedWith('Validation error'); - }); - }); - - describe('when the rawAmount quantity is invalid', () => { - beforeEach(async () => { - subjectRawAmount = 5 as string; - }); - - it('should throw with invalid params', async () => { - await expect(subject()).to.be.rejectedWith('Validation error'); - }); - }); - }); }); diff --git a/test/api/UtilsAPI.spec.ts b/test/api/UtilsAPI.spec.ts index 671b09d..b8abd03 100644 --- a/test/api/UtilsAPI.spec.ts +++ b/test/api/UtilsAPI.spec.ts @@ -33,6 +33,7 @@ import { SwapQuote, TradeQuote, SwapOrderPairs, + TradeOrderPair, CoinGeckoTokenData, CoinGeckoTokenMap, CoinGeckoCoinPrices @@ -42,6 +43,7 @@ import { tradeQuoteFixtures as fixture } from '../fixtures/tradeQuote'; const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545'); +jest.mock('@src/wrappers/set-protocol-v2/TradeModuleWrapper'); jest.mock('@src/api/utils/tradeQuoter'); jest.mock('axios'); jest.mock('graph-results-pager'); @@ -73,16 +75,16 @@ describe('UtilsAPI', () => { }); afterEach(async () => { + (TradeModuleWrapper as any).mockClear(); (TradeQuoter as any).mockClear(); (axios as any).mockClear(); }); - describe('#fetchTradeQuoteAsync', () => { + describe('#fetchSwapQuoteAsync', () => { let subjectFromToken: Address; let subjectToToken: Address; - let subjectFromTokenDecimals: number; - let subjectToTokenDecimals: number; let subjectRawAmount: string; + let subjectUseBuyAmount: boolean; let subjectFromAddress: Address; let subjectSetToken: SetTokenAPI; let subjectGasPrice: number; @@ -91,22 +93,20 @@ describe('UtilsAPI', () => { beforeEach(async () => { subjectFromToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; subjectToToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; - subjectFromTokenDecimals = 8; - subjectToTokenDecimals = 6; subjectRawAmount = '5'; + subjectUseBuyAmount = false; subjectFromAddress = '0xCCCC262A92581EC09C2d522b48bCcd9E3C8ACf9C'; subjectSetToken = { val: 'settoken' } as SetTokenAPI; subjectGasPrice = 20; subjectFeePercentage = 1; }); - async function subject(): Promise { - return await utilsAPI.fetchTradeQuoteAsync( + async function subject(): Promise { + return await utilsAPI.fetchSwapQuoteAsync( subjectFromToken, subjectToToken, - subjectFromTokenDecimals, - subjectToTokenDecimals, subjectRawAmount, + subjectUseBuyAmount, subjectFromAddress, subjectSetToken, subjectGasPrice, @@ -120,13 +120,10 @@ describe('UtilsAPI', () => { const expectedQuoteOptions = { fromToken: subjectFromToken, toToken: subjectToToken, - fromTokenDecimals: subjectFromTokenDecimals, - toTokenDecimals: subjectToTokenDecimals, rawAmount: subjectRawAmount, + useBuyAmount: subjectUseBuyAmount, fromAddress: subjectFromAddress, chainId: (await provider.getNetwork()).chainId, - tradeModule: tradeModuleWrapper, - provider: provider, setToken: subjectSetToken, gasPrice: subjectGasPrice, slippagePercentage: undefined, @@ -137,7 +134,7 @@ describe('UtilsAPI', () => { }; await subject(); - expect(tradeQuoter.generateQuoteForTrade).to.have.beenCalledWith(expectedQuoteOptions); + expect(tradeQuoter.generateQuoteForSwap).to.have.beenCalledWith(expectedQuoteOptions); }); describe('when the fromToken address is invalid', () => { @@ -160,19 +157,109 @@ describe('UtilsAPI', () => { }); }); - describe('when the fromTokenDecimals is invalid', () => { + describe('when the rawAmount quantity is invalid', () => { beforeEach(async () => { - subjectFromTokenDecimals = '100' as number; + subjectRawAmount = 5 as string; }); it('should throw with invalid params', async () => { await expect(subject()).to.be.rejectedWith('Validation error'); }); }); + }); - describe('when the toTokenDecimals is invalid', () => { + describe('#batchFetchSwapQuoteAsync', () => { + let fromToken: Address; + let toToken: Address; + let rawAmount: string; + let ignoredRawAmount: string; + let subjectOrderPairs: SwapOrderPairs[]; + let subjectUseBuyAmount: boolean; + let subjectFromAddress: Address; + let subjectSetToken: SetTokenAPI; + let subjectGasPrice: number; + let subjectFeePercentage: number; + + beforeEach(async () => { + fromToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; + toToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + rawAmount = '5'; + ignoredRawAmount = '10'; + + subjectOrderPairs = [ + { + fromToken, + toToken, + rawAmount, + }, + { + fromToken: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + toToken: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + rawAmount: ignoredRawAmount, + ignore: true, + }, + ]; + subjectUseBuyAmount = false; + subjectFromAddress = '0xCCCC262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + subjectSetToken = { val: 'settoken' } as SetTokenAPI; + subjectGasPrice = 20; + subjectFeePercentage = 1; + }); + + async function subject(): Promise { + return await utilsAPI.batchFetchSwapQuoteAsync( + subjectOrderPairs, + subjectUseBuyAmount, + subjectFromAddress, + subjectSetToken, + subjectGasPrice, + undefined, + undefined, + subjectFeePercentage + ); + } + + it('should call the TradeQuoter with correct params', async () => { + const expectedQuoteOptions = { + fromToken, + toToken, + rawAmount, + useBuyAmount: subjectUseBuyAmount, + fromAddress: subjectFromAddress, + chainId: (await provider.getNetwork()).chainId, + setToken: subjectSetToken, + gasPrice: subjectGasPrice, + slippagePercentage: undefined, + isFirmQuote: undefined, + feePercentage: subjectFeePercentage, + feeRecipient: undefined, + excludedSources: undefined, + }; + await subject(); + + expect(tradeQuoter.generateQuoteForSwap).to.have.beenCalledWith(expectedQuoteOptions); + }); + + it('should format ignored orders correctly', async () => { + const expectedQuote = { + calldata: '0x0000000000000000000000000000000000000000000000000000000000000000', + fromTokenAmount: ignoredRawAmount, + toTokenAmount: ignoredRawAmount, + }; + const quotes = await subject(); + + expect(quotes[1]).to.deep.equal(expectedQuote); + }); + + describe('when a fromToken address is invalid', () => { beforeEach(async () => { - subjectToTokenDecimals = '100' as number; + subjectOrderPairs = [ + { + fromToken: '0xInvalidAddress', + toToken: '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C', + rawAmount: '5', + }, + ]; }); it('should throw with invalid params', async () => { @@ -180,9 +267,31 @@ describe('UtilsAPI', () => { }); }); - describe('when the rawAmount quantity is invalid', () => { + describe('when a toToken address is invalid', () => { beforeEach(async () => { - subjectRawAmount = 5 as string; + subjectOrderPairs = [ + { + fromToken: '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C', + toToken: '0xInvalidAddress', + rawAmount: '5', + }, + ]; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when a rawAmount quantity is invalid', () => { + beforeEach(async () => { + subjectOrderPairs = [ + { + fromToken: '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569', + toToken: '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C', + rawAmount: 5 as string, + }, + ]; }); it('should throw with invalid params', async () => { @@ -191,11 +300,12 @@ describe('UtilsAPI', () => { }); }); - describe('#fetchSwapQuoteAsync', () => { + describe('#fetchTradeQuoteAsync', () => { let subjectFromToken: Address; let subjectToToken: Address; + let subjectFromTokenDecimals: number; + let subjectToTokenDecimals: number; let subjectRawAmount: string; - let subjectUseBuyAmount: boolean; let subjectFromAddress: Address; let subjectSetToken: SetTokenAPI; let subjectGasPrice: number; @@ -204,20 +314,22 @@ describe('UtilsAPI', () => { beforeEach(async () => { subjectFromToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; subjectToToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + subjectFromTokenDecimals = 8; + subjectToTokenDecimals = 6; subjectRawAmount = '5'; - subjectUseBuyAmount = false; subjectFromAddress = '0xCCCC262A92581EC09C2d522b48bCcd9E3C8ACf9C'; subjectSetToken = { val: 'settoken' } as SetTokenAPI; subjectGasPrice = 20; subjectFeePercentage = 1; }); - async function subject(): Promise { - return await utilsAPI.fetchSwapQuoteAsync( + async function subject(): Promise { + return await utilsAPI.fetchTradeQuoteAsync( subjectFromToken, subjectToToken, + subjectFromTokenDecimals, + subjectToTokenDecimals, subjectRawAmount, - subjectUseBuyAmount, subjectFromAddress, subjectSetToken, subjectGasPrice, @@ -231,10 +343,13 @@ describe('UtilsAPI', () => { const expectedQuoteOptions = { fromToken: subjectFromToken, toToken: subjectToToken, + fromTokenDecimals: subjectFromTokenDecimals, + toTokenDecimals: subjectToTokenDecimals, rawAmount: subjectRawAmount, - useBuyAmount: subjectUseBuyAmount, fromAddress: subjectFromAddress, chainId: (await provider.getNetwork()).chainId, + tradeModule: tradeModuleWrapper, + provider: provider, setToken: subjectSetToken, gasPrice: subjectGasPrice, slippagePercentage: undefined, @@ -245,7 +360,7 @@ describe('UtilsAPI', () => { }; await subject(); - expect(tradeQuoter.generateQuoteForSwap).to.have.beenCalledWith(expectedQuoteOptions); + expect(tradeQuoter.generateQuoteForTrade).to.have.beenCalledWith(expectedQuoteOptions); }); describe('when the fromToken address is invalid', () => { @@ -268,6 +383,26 @@ describe('UtilsAPI', () => { }); }); + describe('when the fromTokenDecimals is invalid', () => { + beforeEach(async () => { + subjectFromTokenDecimals = '100' as number; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when the toTokenDecimals is invalid', () => { + beforeEach(async () => { + subjectToTokenDecimals = '100' as number; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + describe('when the rawAmount quantity is invalid', () => { beforeEach(async () => { subjectRawAmount = 5 as string; @@ -279,98 +414,113 @@ describe('UtilsAPI', () => { }); }); - describe('#batchFetchSwapQuoteAsync', () => { - let fromToken: Address; - let toToken: Address; - let rawAmount: string; - let ignoredRawAmount: string; - let subjectOrderPairs: SwapOrderPairs[]; - let subjectUseBuyAmount: boolean; + describe('#batchFetchTradeQuoteAsync', () => { + let subjectTradeOrderPairs: TradeOrderPair[]; let subjectFromAddress: Address; let subjectSetToken: SetTokenAPI; let subjectGasPrice: number; - let subjectFeePercentage: number; beforeEach(async () => { - fromToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; - toToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; - rawAmount = '5'; - ignoredRawAmount = '10'; - - subjectOrderPairs = [ + const fromToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; + const toToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + const fromTokenDecimals = 8; + const toTokenDecimals = 6; + const rawAmount = '.5'; + const slippagePercentage = 2; + + subjectTradeOrderPairs = [ { fromToken, toToken, + fromTokenDecimals, + toTokenDecimals, rawAmount, + slippagePercentage, }, + // No slippage { - fromToken: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - toToken: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', - rawAmount: ignoredRawAmount, - ignore: true, + fromToken: '0xCCCC15AA9B462ed4fC84B5dFc43Fd2a10a54B569', + toToken: '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C', + fromTokenDecimals, + toTokenDecimals, + rawAmount, }, ]; - subjectUseBuyAmount = false; - subjectFromAddress = '0xCCCC262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + subjectFromAddress = '0xEEEE262A92581EC09C2d522b48bCcd9E3C8ACf9C'; subjectSetToken = { val: 'settoken' } as SetTokenAPI; subjectGasPrice = 20; - subjectFeePercentage = 1; }); - async function subject(): Promise { - return await utilsAPI.batchFetchSwapQuoteAsync( - subjectOrderPairs, - subjectUseBuyAmount, + async function subject(): Promise { + return await utilsAPI.batchFetchTradeQuoteAsync( + subjectTradeOrderPairs, subjectFromAddress, subjectSetToken, - subjectGasPrice, - undefined, - undefined, - subjectFeePercentage + subjectGasPrice ); } it('should call the TradeQuoter with correct params', async () => { - const expectedQuoteOptions = { - fromToken, - toToken, - rawAmount, - useBuyAmount: subjectUseBuyAmount, + const firstExpectedQuoteOptions = { + fromToken: subjectTradeOrderPairs[0].fromToken, + toToken: subjectTradeOrderPairs[0].toToken, + fromTokenDecimals: subjectTradeOrderPairs[0].fromTokenDecimals, + toTokenDecimals: subjectTradeOrderPairs[0].toTokenDecimals, + rawAmount: subjectTradeOrderPairs[0].rawAmount, + slippagePercentage: subjectTradeOrderPairs[0].slippagePercentage, fromAddress: subjectFromAddress, chainId: (await provider.getNetwork()).chainId, + tradeModule: tradeModuleWrapper, + provider: provider, setToken: subjectSetToken, gasPrice: subjectGasPrice, + isFirmQuote: undefined, + feePercentage: undefined, + feeRecipient: undefined, + excludedSources: undefined, + }; + + const secondExpectedQuoteOptions = { + fromToken: subjectTradeOrderPairs[1].fromToken, + toToken: subjectTradeOrderPairs[1].toToken, + fromTokenDecimals: subjectTradeOrderPairs[1].fromTokenDecimals, + toTokenDecimals: subjectTradeOrderPairs[1].toTokenDecimals, + rawAmount: subjectTradeOrderPairs[1].rawAmount, slippagePercentage: undefined, + fromAddress: subjectFromAddress, + chainId: (await provider.getNetwork()).chainId, + tradeModule: tradeModuleWrapper, + provider: provider, + setToken: subjectSetToken, + gasPrice: subjectGasPrice, isFirmQuote: undefined, - feePercentage: subjectFeePercentage, + feePercentage: undefined, feeRecipient: undefined, excludedSources: undefined, }; + await subject(); - expect(tradeQuoter.generateQuoteForSwap).to.have.beenCalledWith(expectedQuoteOptions); + // https://stackoverflow.com/questions/40018216/how-to-check-multiple-arguments-on-multiple-calls-for-jest-spies + expect((tradeQuoter.generateQuoteForTrade as any).mock.calls).to.deep.eq([ + [ firstExpectedQuoteOptions ], + [ secondExpectedQuoteOptions ], + ]); }); - it('should format ignored orders correctly', async () => { - const expectedQuote = { - calldata: '0x0000000000000000000000000000000000000000000000000000000000000000', - fromTokenAmount: ignoredRawAmount, - toTokenAmount: ignoredRawAmount, - }; - const quotes = await subject(); + describe('when the fromToken address is invalid', () => { + beforeEach(async () => { + subjectTradeOrderPairs[1].fromToken = '0xInvalidAddress'; + }); - expect(quotes[1]).to.deep.equal(expectedQuote); + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); }); - describe('when a fromToken address is invalid', () => { + describe('when the toToken address is invalid', () => { beforeEach(async () => { - subjectOrderPairs = [ - { - fromToken: '0xInvalidAddress', - toToken: '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C', - rawAmount: '5', - }, - ]; + subjectTradeOrderPairs[1].toToken = '0xInvalidAddress'; }); it('should throw with invalid params', async () => { @@ -378,15 +528,9 @@ describe('UtilsAPI', () => { }); }); - describe('when a toToken address is invalid', () => { + describe('when the fromTokenDecimals is invalid', () => { beforeEach(async () => { - subjectOrderPairs = [ - { - fromToken: '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C', - toToken: '0xInvalidAddress', - rawAmount: '5', - }, - ]; + subjectTradeOrderPairs[1].fromTokenDecimals = '100' as number; }); it('should throw with invalid params', async () => { @@ -394,15 +538,19 @@ describe('UtilsAPI', () => { }); }); - describe('when a rawAmount quantity is invalid', () => { + describe('when the toTokenDecimals is invalid', () => { beforeEach(async () => { - subjectOrderPairs = [ - { - fromToken: '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569', - toToken: '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C', - rawAmount: 5 as string, - }, - ]; + subjectTradeOrderPairs[1].toTokenDecimals = '100' as number; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when the rawAmount quantity is invalid', () => { + beforeEach(async () => { + subjectTradeOrderPairs[1].rawAmount = 5 as string; }); it('should throw with invalid params', async () => { diff --git a/test/api/extensions/BatchTradeExtensionAPI.spec.ts b/test/api/extensions/BatchTradeExtensionAPI.spec.ts new file mode 100644 index 0000000..27e51cf --- /dev/null +++ b/test/api/extensions/BatchTradeExtensionAPI.spec.ts @@ -0,0 +1,263 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { ethers, BytesLike } from 'ethers'; +import { BigNumber, ContractTransaction } from 'ethers'; +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; +import { ether } from '@setprotocol/set-protocol-v2/dist/utils/common'; + +import BatchTradeExtensionAPI from '@src/api/extensions/BatchTradeExtensionAPI'; +import BatchTradeExtensionWrapper from '@src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper'; +import { TradeInfo, BatchTradeResult } from '../../../src/types/common'; + +import { tradeQuoteFixtures as fixture } from '../../fixtures/tradeQuote'; +import { expect } from '../../utils/chai'; + +const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545'); + +jest.mock('@src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper'); + +// @ts-ignore +provider.getTransactionReceipt = jest.fn((arg: any) => Promise.resolve(fixture.batchTradeReceipt)); + + +describe('BatchTradeExtensionAPI', () => { + let owner: Address; + let setToken: Address; + let batchTradeExtension: Address; + let delegatedManager: Address; + + let batchTradeExtensionAPI: BatchTradeExtensionAPI; + let batchTradeExtensionWrapper: BatchTradeExtensionWrapper; + + beforeEach(async () => { + [ + owner, + setToken, + delegatedManager, + batchTradeExtension, + ] = await provider.listAccounts(); + + batchTradeExtensionAPI = new BatchTradeExtensionAPI(provider, batchTradeExtension); + batchTradeExtensionWrapper = (BatchTradeExtensionWrapper as any).mock.instances[0]; + }); + + afterEach(() => { + (BatchTradeExtensionWrapper as any).mockClear(); + }); + + describe('#batchTradeWithOperatorAsync', () => { + let subjectSetToken: Address; + let subjectTrades: TradeInfo[]; + let subjectCallerAddress: Address; + let subjectTransactionOptions: any; + + beforeEach(async () => { + const exchangeName = 'ZeroExApiAdapterV5'; + const sendToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; + const sendQuantity = ether(10); + const receiveToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + const minReceiveQuantity = ether(.9); + const data = '0x123456789abcdedf'; + + subjectTrades = [ + { + exchangeName, + sendToken, + receiveToken, + sendQuantity, + minReceiveQuantity, + data, + }, + { + exchangeName, + sendToken: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + receiveToken: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + sendQuantity, + minReceiveQuantity, + data, + }, + ]; + + subjectSetToken = setToken; + subjectCallerAddress = owner; + subjectTransactionOptions = {}; + }); + + async function subject(): Promise { + return batchTradeExtensionAPI.batchTradeWithOperatorAsync( + subjectSetToken, + subjectTrades, + subjectCallerAddress, + subjectTransactionOptions + ); + } + + it('should call `tradeWithOperator` on the BatchTradeExtensionWrapper', async () => { + await subject(); + + expect(batchTradeExtensionWrapper.batchTradeWithOperatorAsync).to.have.beenCalledWith( + subjectSetToken, + subjectTrades, + subjectCallerAddress, + subjectTransactionOptions + ); + }); + + describe('when a setToken is not a valid address', () => { + beforeEach(() => subjectSetToken = '0xinvalid'); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when a exchangeName is not a valid string', () => { + beforeEach(() => subjectTrades[0].exchangeName = 5 as string); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when a sendToken is not a valid address', () => { + beforeEach(() => subjectTrades[0].sendToken = '0xinvalid'); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when sendQuantity is not a valid number', () => { + beforeEach(() => subjectTrades[0].sendQuantity = NaN as BigNumber); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when a receiveToken is not a valid address', () => { + beforeEach(() => subjectTrades[0].receiveToken = '0xinvalid'); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when minReceiveQuantity is not a valid number', () => { + beforeEach(() => subjectTrades[0].minReceiveQuantity = NaN as BigNumber); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + }); + + describe('getBatchTradeResultsAsync', () => { + let subjectTransactionHash: string; + let subjectTrades: TradeInfo[]; + + beforeEach(() => { + const exchangeName = 'ZeroExApiAdapterV5'; + const sendToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; + const sendQuantity = ether(10); + const receiveToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + const minReceiveQuantity = ether(.9); + const data = '0x123456789abcdedf'; + + subjectTrades = [ + { + exchangeName, + sendToken, + receiveToken, + sendQuantity, + minReceiveQuantity, + data, + }, + ]; + + subjectTransactionHash = '0x676f0263b724d24158d4999167ab9195edebe814e2cc05d0fa38dbdcc16d6a73'; + }); + + async function subject(): Promise { + return batchTradeExtensionAPI.getBatchTradeResultsAsync( + subjectTransactionHash, + subjectTrades + ); + } + + it('should return the expected transaction result', async () => { + const results = await subject(); + + expect(results[0].success).eq(false); + expect(results[0].tradeInfo).deep.eq(subjectTrades[0]); + expect(results[0].revertReason).eq(`NotImplementedError({ selector: '0x6af479b2' })`); + }); + }); + + describe('#getBatchTradeExtensionInitializationBytecode', () => { + let subjectDelegatedManager: Address; + + beforeEach(async () => { + subjectDelegatedManager = delegatedManager; + }); + + async function subject(): Promise { + return batchTradeExtensionAPI.getBatchTradeExtensionInitializationBytecode( + subjectDelegatedManager + ); + } + + it('should generate the expected bytecode', async () => { + const expectedBytecode = '0xde2236bd000000000000000000000000e36ea790bc9d7ab70c55260c66d52b1eca985f84'; + expect(await subject()).eq(expectedBytecode); + }); + + describe('when delegatedManager is not a valid address', () => { + beforeEach(() => subjectDelegatedManager = '0xinvalid'); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + }); + + describe('#getTradeModuleAndExtensionInitializationBytecode', () => { + let subjectDelegatedManager: Address; + + beforeEach(async () => { + subjectDelegatedManager = delegatedManager; + }); + + async function subject(): Promise { + return batchTradeExtensionAPI.getTradeModuleAndExtensionInitializationBytecode(subjectDelegatedManager); + } + + it('should generate the expected bytecode', async () => { + const expectedBytecode = '0x9b468312000000000000000000000000e36ea790bc9d7ab70c55260c66d52b1eca985f84'; + + expect(await subject()).eq(expectedBytecode); + }); + + describe('when setToken is not a valid address', () => { + beforeEach(() => subjectDelegatedManager = '0xinvalid'); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + }); +}); diff --git a/test/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts index 5e298e1..8c4e143 100644 --- a/test/fixtures/tradeQuote.ts +++ b/test/fixtures/tradeQuote.ts @@ -236,4 +236,22 @@ export const tradeQuoteFixtures = { fromTokenAmount: '1000000', toTokenAmount: '2973', }, + + batchTradeReceipt: { + logs: [ + { + transactionIndex: 0, + blockNumber: 12198056, + transactionHash: '0x676f0263b724d24158d4999167ab9195edebe814e2cc05d0fa38dbdcc16d6a73', + address: '0xa7e91d5f0fB18B1a14C178A3525628aA4aF0b67B', + topics: [ + '0x3268c4b9432fbdcc6db02e199710ef5c040d54354d44fedf46116bf4a165667d', + '0x0000000000000000000000005d61b97025b8e42935128339a71147b9393209b5', + ], + data: '0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024734e6e1c6af479b20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + logIndex: 0, + blockHash: '0x8aa6ec73fe9af0387c4fa1ec897d8fe23a7a86b4b07a1fdb4da78a8445906155', + }, + ], + }, }; From 13de4159e4c7eb8903f69a0981291b88cdfce44f Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 21 Apr 2022 16:15:45 -0700 Subject: [PATCH 05/26] Fix tsc error --- test/api/TradeAPI.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/api/TradeAPI.spec.ts b/test/api/TradeAPI.spec.ts index 86ec9fa..a7c3f5a 100644 --- a/test/api/TradeAPI.spec.ts +++ b/test/api/TradeAPI.spec.ts @@ -24,9 +24,6 @@ import { ether } from '@setprotocol/set-protocol-v2/dist/utils/common'; import TradeAPI from '@src/api/TradeAPI'; import TradeModuleWrapper from '@src/wrappers/set-protocol-v2/TradeModuleWrapper'; -import { - TradeQuoter, -} from '@src/api/utils'; import { expect } from '@test/utils/chai'; import { tradeQuoteFixtures as fixture } from '../fixtures/tradeQuote'; From 9734f7a4ec6cd6a197e7abaf962ef1d24784c8e9 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 21 Apr 2022 16:30:11 -0700 Subject: [PATCH 06/26] Remove unused code / console.log --- src/api/extensions/BatchTradeExtensionAPI.ts | 1 - src/wrappers/set-v2-strategies/ContractWrapper.ts | 10 ---------- 2 files changed, 11 deletions(-) diff --git a/src/api/extensions/BatchTradeExtensionAPI.ts b/src/api/extensions/BatchTradeExtensionAPI.ts index 5794164..89d8923 100644 --- a/src/api/extensions/BatchTradeExtensionAPI.ts +++ b/src/api/extensions/BatchTradeExtensionAPI.ts @@ -123,7 +123,6 @@ export default class BatchTradeExtensionAPI { results[tradeIndex].revertReason = customErrorParser((decodedLog.args as any)._reason); } } catch (e) { - console.log('e --> ' + e); // ignore all non-batch trade events } } diff --git a/src/wrappers/set-v2-strategies/ContractWrapper.ts b/src/wrappers/set-v2-strategies/ContractWrapper.ts index c422219..0747228 100644 --- a/src/wrappers/set-v2-strategies/ContractWrapper.ts +++ b/src/wrappers/set-v2-strategies/ContractWrapper.ts @@ -195,14 +195,4 @@ export default class ContractWrapper { return batchTradeExtensionContract; } } - - /** - * Load BatchTradeExtension contract without signer (for running populateTransaction) - * - * @param batchTradeExtensionAddress Address of the BatchTradeExtension - * @return BatchTradeExtension contract instance - */ - public loadBatchTradeExtensionWithoutSigner(batchTradeExtensionAddress: Address): BatchTradeExtension { - return BatchTradeExtension__factory.connect(batchTradeExtensionAddress); - } } From a6e0e4f7e68c080b6e8ea431fa0257f6bb793102 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Fri, 22 Apr 2022 10:38:02 -0700 Subject: [PATCH 07/26] Update version to 0.6.0-batch.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5bc8d3d..4080b3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.5.4", + "version": "0.6.0-batch.0", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", From 863d29448353781f263b6a1fc1a8875d49679715 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Tue, 26 Apr 2022 14:37:29 -0700 Subject: [PATCH 08/26] Add DelegatedManager#addExtensions & BatchTradeExtension#initializeExtension --- src/api/DelegatedManagerAPI.ts | 71 +++++++++++++++ src/api/extensions/BatchTradeExtensionAPI.ts | 22 ++++- .../BatchTradeExtensionWrapper.ts | 25 ++++- .../set-v2-strategies/ContractWrapper.ts | 31 +++++++ .../DelegatedManagerWrapper.ts | 64 +++++++++++++ test/api/DelegatedManagerAPI.spec.ts | 91 +++++++++++++++++++ .../extensions/BatchTradeExtensionAPI.spec.ts | 40 +++++++- 7 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 src/api/DelegatedManagerAPI.ts create mode 100644 src/wrappers/set-v2-strategies/DelegatedManagerWrapper.ts create mode 100644 test/api/DelegatedManagerAPI.spec.ts diff --git a/src/api/DelegatedManagerAPI.ts b/src/api/DelegatedManagerAPI.ts new file mode 100644 index 0000000..89a2141 --- /dev/null +++ b/src/api/DelegatedManagerAPI.ts @@ -0,0 +1,71 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +import { ContractTransaction } from 'ethers'; +import { Provider } from '@ethersproject/providers'; +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; +import { TransactionOverrides } from '@setprotocol/set-protocol-v2/dist/typechain'; + +import DelegatedManagerWrapper from '../wrappers/set-v2-strategies/DelegatedManagerWrapper'; +import Assertions from '../assertions'; + +/** + * @title DelegatedManagerAPI + * @author Set Protocol + * + * The DelegatedManagerAPI exposes methods to call functions only available directly on the + * DelegatedManager contract. For the most part these are owner admin operations to reconfigure + * permissions and add modules / extensions. + * + * (This API will be extended as required by set-ui (tokensets). For other use-cases interacting + * with the contract via the Etherscan write API is the simplest option) + */ +export default class DelegatedManagerAPI { + private DelegatedManagerWrapper: DelegatedManagerWrapper; + private assert: Assertions; + + public constructor( + provider: Provider, + delegatedManagerAddress: Address, + assertions?: Assertions) { + this.DelegatedManagerWrapper = new DelegatedManagerWrapper(provider, delegatedManagerAddress); + this.assert = assertions || new Assertions(); + } + + /** + * ONLY OWNER: Add new extension(s) that the DelegatedManager can call. Puts extensions into PENDING + * state, each must be initialized in order to be used. + * + * @param _extensions New extension(s) to add + * @param callerAddress Address of caller (optional) + * @param txOpts Overrides for transaction (optional) + */ + public async addExtensionsAsync( + extensions: Address[], + callerAddress: Address = undefined, + txOpts: TransactionOverrides = {} + ): Promise { + this.assert.schema.isValidAddressList('extensions', extensions); + + return await this.DelegatedManagerWrapper.addExtensions( + extensions, + callerAddress, + txOpts + ); + } +} diff --git a/src/api/extensions/BatchTradeExtensionAPI.ts b/src/api/extensions/BatchTradeExtensionAPI.ts index 89d8923..47b6e39 100644 --- a/src/api/extensions/BatchTradeExtensionAPI.ts +++ b/src/api/extensions/BatchTradeExtensionAPI.ts @@ -49,6 +49,26 @@ export default class BatchTradeExtensionAPI { this.assert = assertions || new Assertions(); } + /** + * ONLY OWNER: Initializes BatchTradeExtension to the DelegatedManager. + * + * @param delegatedManager Instance of the DelegatedManager to initialize extension with + */ + public async initializeExtensionAsync( + delegatedManagerAddress: Address, + callerAddress: Address = undefined, + txOpts: TransactionOverrides = {} + ): Promise { + this.assert.schema.isValidAddress('extensionAddress', delegatedManagerAddress); + + return await this.batchTradeExtensionWrapper.initializeExtension( + delegatedManagerAddress, + callerAddress, + txOpts + ); + + } + /** * Executes a batch of trades on a supported DEX. Must be called an address authorized for the `operator` role * on the BatchTradeExtension @@ -70,7 +90,7 @@ export default class BatchTradeExtensionAPI { this._validateTrades(trades); this.assert.schema.isValidAddress('setTokenAddress', setTokenAddress); - return await this.batchTradeExtensionWrapper.batchTradeWithOperatorAsync( + return await this.batchTradeExtensionWrapper.batchTradeWithOperator( setTokenAddress, trades, callerAddress, diff --git a/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts b/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts index dcfdb6e..b0f3561 100644 --- a/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts +++ b/src/wrappers/set-v2-strategies/BatchTradeExtensionWrapper.ts @@ -41,6 +41,29 @@ export default class BatchTradeExtensionWrapper { this.batchTradeExtensionAddress = batchTradeExtensionAddress; } + /** + * ONLY OWNER: Initializes BatchTradeExtension to the DelegatedManager. + * + * @param delegatedManager Instance of the DelegatedManager to initialize extension for + */ + public async initializeExtension( + delegatedManagerAddress: Address, + callerAddress: Address = undefined, + txOpts: TransactionOverrides = {} + ): Promise { + const txOptions = await generateTxOpts(txOpts); + const batchTradeExtensionInstance = await this.contracts.loadBatchTradeExtensionAsync( + this.batchTradeExtensionAddress, + callerAddress + ); + + return await batchTradeExtensionInstance.initializeExtension( + delegatedManagerAddress, + txOptions + ); + + } + /** * Executes a batch of trades on a supported DEX. Must be called an address authorized for the `operator` role * on the BatchTradeExtension @@ -53,7 +76,7 @@ export default class BatchTradeExtensionWrapper { * @param callerAddress Address of caller (optional) * @param txOptions Overrides for transaction (optional) */ - public async batchTradeWithOperatorAsync( + public async batchTradeWithOperator( setTokenAddress: Address, trades: TradeInfo[], callerAddress: Address = undefined, diff --git a/src/wrappers/set-v2-strategies/ContractWrapper.ts b/src/wrappers/set-v2-strategies/ContractWrapper.ts index 0747228..4242d6c 100644 --- a/src/wrappers/set-v2-strategies/ContractWrapper.ts +++ b/src/wrappers/set-v2-strategies/ContractWrapper.ts @@ -21,6 +21,7 @@ import { Contract } from 'ethers'; import { Address } from '@setprotocol/set-protocol-v2/utils/types'; import { + DelegatedManager, DelegatedManagerFactory, StreamingFeeSplitExtension, TradeExtension, @@ -28,6 +29,9 @@ import { BatchTradeExtension } from '@setprotocol/set-v2-strategies/typechain'; +import { + DelegatedManager__factory +} from '@setprotocol/set-v2-strategies/dist/typechain/factories/DelegatedManager__factory'; import { DelegatedManagerFactory__factory } from '@setprotocol/set-v2-strategies/dist/typechain/factories/DelegatedManagerFactory__factory'; @@ -61,6 +65,33 @@ export default class ContractWrapper { this.cache = {}; } + /** + * Load DelegatedManager contract + * + * @param DelegatedManagerAddress Address of the DelegatedManager instance + * @param callerAddress Address of caller, uses first one on node if none provided. + * @return DelegatedManager contract instance + */ + public async loadDelegatedManagerAsync( + delegatedManagerAddress: Address, + callerAddress?: Address, + ): Promise { + const signer = (this.provider as JsonRpcProvider).getSigner(callerAddress); + const cacheKey = `DelegatedManagerFactory_${delegatedManagerAddress}_${await signer.getAddress()}`; + + if (cacheKey in this.cache) { + return this.cache[cacheKey] as DelegatedManager; + } else { + const delegatedManagerContract = DelegatedManager__factory.connect( + delegatedManagerAddress, + signer + ); + + this.cache[cacheKey] = delegatedManagerContract; + return delegatedManagerContract; + } + } + /** * Load DelegatedManagerFactory contract * diff --git a/src/wrappers/set-v2-strategies/DelegatedManagerWrapper.ts b/src/wrappers/set-v2-strategies/DelegatedManagerWrapper.ts new file mode 100644 index 0000000..bc776db --- /dev/null +++ b/src/wrappers/set-v2-strategies/DelegatedManagerWrapper.ts @@ -0,0 +1,64 @@ +/* + Copyright 2022 Set Labs Inc. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; +import { ContractTransaction } from 'ethers'; +import { TransactionOverrides } from '@setprotocol/set-protocol-v2/dist/typechain'; +import { Provider } from '@ethersproject/providers'; +import { generateTxOpts } from '../../utils/transactions'; + +import ContractWrapper from './ContractWrapper'; + +/** + * @title DelegatedManagerWrapper + * @author Set Protocol + * + * The DelegatedManagerWrapper forwards functionality from the DelegatedManager contract. + * + */ +export default class DelegatedManagerWrapper { + private provider: Provider; + private contracts: ContractWrapper; + + private delegatedManagerAddress: Address; + + public constructor(provider: Provider, delegatedManagerAddress: Address) { + this.provider = provider; + this.contracts = new ContractWrapper(this.provider); + this.delegatedManagerAddress = delegatedManagerAddress; + } + + /** + * ONLY OWNER: Add new extension(s) that the DelegatedManager can call. Puts extensions into PENDING + * state, each must be initialized in order to be used. + * + * @param _extensions New extension(s) to add + * @param callerAddress Address of caller (optional) + * @param txOpts Overrides for transaction (optional) + */ + public async addExtensions( + extensions: Address[], + callerAddress: Address = undefined, + txOpts: TransactionOverrides = {} + ): Promise { + const txOptions = await generateTxOpts(txOpts); + const delegatedManagerInstance = await this.contracts.loadDelegatedManagerAsync( + this.delegatedManagerAddress, + callerAddress + ); + + return await delegatedManagerInstance.addExtensions(extensions, txOptions); + } +} \ No newline at end of file diff --git a/test/api/DelegatedManagerAPI.spec.ts b/test/api/DelegatedManagerAPI.spec.ts new file mode 100644 index 0000000..46b9b79 --- /dev/null +++ b/test/api/DelegatedManagerAPI.spec.ts @@ -0,0 +1,91 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { ethers } from 'ethers'; +import { ContractTransaction } from 'ethers'; +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; + +import DelegateManagerFactoryAPI from '@src/api/DelegatedManagerAPI'; +import DelegatedManagerWrapper from '@src/wrappers/set-v2-strategies/DelegatedManagerWrapper'; +import { expect } from '../utils/chai'; + +const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545'); + +jest.mock('@src/wrappers/set-v2-strategies/DelegatedManagerWrapper'); + +describe('DelegatedManagerAPI', () => { + let owner: Address; + let delegatedManager: Address; + let extensionA: Address; + let extensionB: Address; + + let delegatedManagerAPI: DelegateManagerFactoryAPI; + let delegatedManagerWrapper: DelegatedManagerWrapper; + + beforeEach(async () => { + [ + owner, + delegatedManager, + extensionA, + extensionB, + ] = await provider.listAccounts(); + + delegatedManagerAPI = new DelegateManagerFactoryAPI(provider, delegatedManager); + delegatedManagerWrapper = (DelegatedManagerWrapper as any).mock.instances[0]; + }); + + afterEach(() => { + (DelegatedManagerWrapper as any).mockClear(); + }); + + describe('#addExtension', () => { + let subjectExtensions: Address[]; + let subjectCallerAddress: Address; + let subjectTransactionOptions: any; + + beforeEach(async () => { + subjectExtensions = [extensionA, extensionB]; + subjectCallerAddress = owner; + subjectTransactionOptions = {}; + }); + + async function subject(): Promise { + return delegatedManagerAPI.addExtensionsAsync( + subjectExtensions, + subjectCallerAddress, + subjectTransactionOptions + ); + } + + it('should call `addExtension` on the DelegatedManagerWrapper', async () => { + await subject(); + + expect(delegatedManagerWrapper.addExtensions).to.have.beenCalledWith( + subjectExtensions, + subjectCallerAddress, + subjectTransactionOptions + ); + }); + + describe('when an extension is not a valid address', () => { + beforeEach(() => subjectExtensions = ['0xinvalid', extensionB]); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + }); +}); diff --git a/test/api/extensions/BatchTradeExtensionAPI.spec.ts b/test/api/extensions/BatchTradeExtensionAPI.spec.ts index 27e51cf..138547b 100644 --- a/test/api/extensions/BatchTradeExtensionAPI.spec.ts +++ b/test/api/extensions/BatchTradeExtensionAPI.spec.ts @@ -59,6 +59,44 @@ describe('BatchTradeExtensionAPI', () => { (BatchTradeExtensionWrapper as any).mockClear(); }); + describe('#initializeExtension', () => { + let subjectDelegatedManager: Address; + let subjectCallerAddress: Address; + let subjectTransactionOptions: any; + + beforeEach(async () => { + subjectDelegatedManager = delegatedManager; + subjectCallerAddress = owner; + subjectTransactionOptions = {}; + }); + + async function subject(): Promise { + return batchTradeExtensionAPI.initializeExtensionAsync( + subjectDelegatedManager, + subjectCallerAddress, + subjectTransactionOptions + ); + } + + it('should call `initializeExtension` on the BatchTradeExtensionWrapper', async () => { + await subject(); + + expect(batchTradeExtensionWrapper.initializeExtension).to.have.beenCalledWith( + subjectDelegatedManager, + subjectCallerAddress, + subjectTransactionOptions + ); + }); + + describe('when an extension is not a valid address', () => { + beforeEach(() => subjectDelegatedManager = '0xinvalid'); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + }); + describe('#batchTradeWithOperatorAsync', () => { let subjectSetToken: Address; let subjectTrades: TradeInfo[]; @@ -109,7 +147,7 @@ describe('BatchTradeExtensionAPI', () => { it('should call `tradeWithOperator` on the BatchTradeExtensionWrapper', async () => { await subject(); - expect(batchTradeExtensionWrapper.batchTradeWithOperatorAsync).to.have.beenCalledWith( + expect(batchTradeExtensionWrapper.batchTradeWithOperator).to.have.beenCalledWith( subjectSetToken, subjectTrades, subjectCallerAddress, From 7d0537a8cf0528ee7894fc0f6496d649daf1f02e Mon Sep 17 00:00:00 2001 From: cgewecke Date: Tue, 26 Apr 2022 14:44:29 -0700 Subject: [PATCH 09/26] Add DelegatedManagerAPI to Set.ts --- src/Set.ts | 12 ++++++++++++ src/api/index.ts | 2 ++ src/types/common.ts | 1 + 3 files changed, 15 insertions(+) diff --git a/src/Set.ts b/src/Set.ts index 487b0db..5e023be 100644 --- a/src/Set.ts +++ b/src/Set.ts @@ -39,6 +39,7 @@ import { TradeExtensionAPI, StreamingFeeExtensionAPI, BatchTradeExtensionAPI, + DelegatedManagerAPI, } from './api/index'; const ethersProviders = require('ethers').providers; @@ -146,6 +147,12 @@ class Set { */ public perpV2BasisTradingViewer: PerpV2LeverageViewerAPI; + /** + * An instance of DelegatedManager class. Contains methods for owner-administering Delegated + * Manager contracts + */ + public delegatedManager: DelegatedManagerAPI; + /** * An instance of DelegatedManagerFactory class. Contains methods for deploying and initializing * DelegatedManagerSystem deployed SetTokens and Manager contracts @@ -204,6 +211,11 @@ class Set { this.blockchain = new BlockchainAPI(ethersProvider, assertions); this.utils = new UtilsAPI(ethersProvider, config.tradeModuleAddress, config.zeroExApiKey, config.zeroExApiUrls); + this.delegatedManager = new DelegatedManagerAPI( + ethersProvider, + config.delegatedManagerAddress + ); + this.delegatedManagerFactory = new DelegatedManagerFactoryAPI( ethersProvider, config.delegatedManagerFactoryAddress diff --git a/src/api/index.ts b/src/api/index.ts index c85b23e..98a0502 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -18,6 +18,7 @@ import IssuanceExtensionAPI from './extensions/IssuanceExtensionAPI'; import StreamingFeeExtensionAPI from './extensions/StreamingFeeExtensionAPI'; import TradeExtensionAPI from './extensions/TradeExtensionAPI'; import BatchTradeExtensionAPI from './extensions/BatchTradeExtensionAPI'; +import DelegatedManagerAPI from './DelegatedManagerAPI'; import { TradeQuoter, @@ -46,6 +47,7 @@ export { StreamingFeeExtensionAPI, TradeExtensionAPI, BatchTradeExtensionAPI, + DelegatedManagerAPI, TradeQuoter, CoinGeckoDataService, GasOracleService diff --git a/src/types/common.ts b/src/types/common.ts index fb5b1df..ea09630 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -40,6 +40,7 @@ export interface SetJSConfig { perpV2LeverageModuleViewerAddress: Address; perpV2BasisTradingModuleAddress: Address; perpV2BasisTradingModuleViewerAddress: Address; + delegatedManagerAddress: Address; delegatedManagerFactoryAddress: Address; issuanceExtensionAddress: Address; tradeExtensionAddress: Address; From 7f9b3a41a3001fbe7c1d9e25fb221352b1cdfa78 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Tue, 26 Apr 2022 15:21:12 -0700 Subject: [PATCH 10/26] Fix DelegatedManagerAPI interface (make multi-instance) --- package.json | 2 +- src/Set.ts | 5 +---- src/api/DelegatedManagerAPI.ts | 13 ++++++++----- src/types/common.ts | 1 - .../set-v2-strategies/DelegatedManagerWrapper.ts | 15 +++++++-------- test/api/DelegatedManagerAPI.spec.ts | 6 +++++- 6 files changed, 22 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 4080b3a..b818b4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.6.0-batch.0", + "version": "0.6.0-batch.2", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", diff --git a/src/Set.ts b/src/Set.ts index 5e023be..7089784 100644 --- a/src/Set.ts +++ b/src/Set.ts @@ -211,10 +211,7 @@ class Set { this.blockchain = new BlockchainAPI(ethersProvider, assertions); this.utils = new UtilsAPI(ethersProvider, config.tradeModuleAddress, config.zeroExApiKey, config.zeroExApiUrls); - this.delegatedManager = new DelegatedManagerAPI( - ethersProvider, - config.delegatedManagerAddress - ); + this.delegatedManager = new DelegatedManagerAPI(ethersProvider); this.delegatedManagerFactory = new DelegatedManagerFactoryAPI( ethersProvider, diff --git a/src/api/DelegatedManagerAPI.ts b/src/api/DelegatedManagerAPI.ts index 89a2141..eef6b8c 100644 --- a/src/api/DelegatedManagerAPI.ts +++ b/src/api/DelegatedManagerAPI.ts @@ -41,9 +41,8 @@ export default class DelegatedManagerAPI { public constructor( provider: Provider, - delegatedManagerAddress: Address, assertions?: Assertions) { - this.DelegatedManagerWrapper = new DelegatedManagerWrapper(provider, delegatedManagerAddress); + this.DelegatedManagerWrapper = new DelegatedManagerWrapper(provider); this.assert = assertions || new Assertions(); } @@ -51,18 +50,22 @@ export default class DelegatedManagerAPI { * ONLY OWNER: Add new extension(s) that the DelegatedManager can call. Puts extensions into PENDING * state, each must be initialized in order to be used. * - * @param _extensions New extension(s) to add - * @param callerAddress Address of caller (optional) - * @param txOpts Overrides for transaction (optional) + * @param _delegatedManagerAddress DelegatedManager to addExtension for + * @param _extensions New extension(s) to add + * @param callerAddress Address of caller (optional) + * @param txOpts Overrides for transaction (optional) */ public async addExtensionsAsync( + delegatedManagerAddress: Address, extensions: Address[], callerAddress: Address = undefined, txOpts: TransactionOverrides = {} ): Promise { + this.assert.schema.isValidAddress('delegatedManagerAddress', delegatedManagerAddress); this.assert.schema.isValidAddressList('extensions', extensions); return await this.DelegatedManagerWrapper.addExtensions( + delegatedManagerAddress, extensions, callerAddress, txOpts diff --git a/src/types/common.ts b/src/types/common.ts index ea09630..fb5b1df 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -40,7 +40,6 @@ export interface SetJSConfig { perpV2LeverageModuleViewerAddress: Address; perpV2BasisTradingModuleAddress: Address; perpV2BasisTradingModuleViewerAddress: Address; - delegatedManagerAddress: Address; delegatedManagerFactoryAddress: Address; issuanceExtensionAddress: Address; tradeExtensionAddress: Address; diff --git a/src/wrappers/set-v2-strategies/DelegatedManagerWrapper.ts b/src/wrappers/set-v2-strategies/DelegatedManagerWrapper.ts index bc776db..a34d5f7 100644 --- a/src/wrappers/set-v2-strategies/DelegatedManagerWrapper.ts +++ b/src/wrappers/set-v2-strategies/DelegatedManagerWrapper.ts @@ -32,30 +32,29 @@ export default class DelegatedManagerWrapper { private provider: Provider; private contracts: ContractWrapper; - private delegatedManagerAddress: Address; - - public constructor(provider: Provider, delegatedManagerAddress: Address) { + public constructor(provider: Provider) { this.provider = provider; this.contracts = new ContractWrapper(this.provider); - this.delegatedManagerAddress = delegatedManagerAddress; } /** * ONLY OWNER: Add new extension(s) that the DelegatedManager can call. Puts extensions into PENDING * state, each must be initialized in order to be used. * - * @param _extensions New extension(s) to add - * @param callerAddress Address of caller (optional) - * @param txOpts Overrides for transaction (optional) + * @param delegatedManagerAddress DelegatedManager to add extension for + * @param extensions New extension(s) to add + * @param callerAddress Address of caller (optional) + * @param txOpts Overrides for transaction (optional) */ public async addExtensions( + delegatedManagerAddress: Address, extensions: Address[], callerAddress: Address = undefined, txOpts: TransactionOverrides = {} ): Promise { const txOptions = await generateTxOpts(txOpts); const delegatedManagerInstance = await this.contracts.loadDelegatedManagerAsync( - this.delegatedManagerAddress, + delegatedManagerAddress, callerAddress ); diff --git a/test/api/DelegatedManagerAPI.spec.ts b/test/api/DelegatedManagerAPI.spec.ts index 46b9b79..d1980ca 100644 --- a/test/api/DelegatedManagerAPI.spec.ts +++ b/test/api/DelegatedManagerAPI.spec.ts @@ -43,7 +43,7 @@ describe('DelegatedManagerAPI', () => { extensionB, ] = await provider.listAccounts(); - delegatedManagerAPI = new DelegateManagerFactoryAPI(provider, delegatedManager); + delegatedManagerAPI = new DelegateManagerFactoryAPI(provider); delegatedManagerWrapper = (DelegatedManagerWrapper as any).mock.instances[0]; }); @@ -52,11 +52,13 @@ describe('DelegatedManagerAPI', () => { }); describe('#addExtension', () => { + let subjectDelegatedManager: Address; let subjectExtensions: Address[]; let subjectCallerAddress: Address; let subjectTransactionOptions: any; beforeEach(async () => { + subjectDelegatedManager = delegatedManager; subjectExtensions = [extensionA, extensionB]; subjectCallerAddress = owner; subjectTransactionOptions = {}; @@ -64,6 +66,7 @@ describe('DelegatedManagerAPI', () => { async function subject(): Promise { return delegatedManagerAPI.addExtensionsAsync( + subjectDelegatedManager, subjectExtensions, subjectCallerAddress, subjectTransactionOptions @@ -74,6 +77,7 @@ describe('DelegatedManagerAPI', () => { await subject(); expect(delegatedManagerWrapper.addExtensions).to.have.beenCalledWith( + subjectDelegatedManager, subjectExtensions, subjectCallerAddress, subjectTransactionOptions From b3c0ce014a148026f6e8b8cd0a1ade299b29c197 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Tue, 26 Apr 2022 17:49:03 -0700 Subject: [PATCH 11/26] Improve UtilAPI docs about trade/swap quotes --- src/api/UtilsAPI.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/api/UtilsAPI.ts b/src/api/UtilsAPI.ts index 89ceb04..ff338bb 100644 --- a/src/api/UtilsAPI.ts +++ b/src/api/UtilsAPI.ts @@ -71,7 +71,9 @@ export default class UtilsAPI { } /** - * Call 0x API to generate a trade quote for two SetToken components. + * Call 0x API to generate a swap quote for two tokens. This method is intended to be used alongside + * ExchangeIssuanceZeroEx contract which allows you to convert a liquid "payment" currency into the + * components that will make up the issued SetToken, (or the reverse when redeeming). * * @param fromToken Address of token being sold * @param toToken Address of token being bought @@ -133,7 +135,9 @@ export default class UtilsAPI { } /** - * Batch multiple calls to 0x API to generate trade quotes SetToken component pairs. By default, swap quotes + * Batch multiple calls to 0x API to generate swap quotes for token pairs. This method is intended to be used + * alongside the ExchangeIssuanceZeroEx contract which allows you to convert a liquid "payment" currency into the + * components that will make up the issued SetToken, (or the reverse when redeeming). By default, swap quotes * are fetched for 0x's public endpoints using their `https://api.0x.org`, `https:///api.0x.org` * url scheme. These open endpoints are rate limited at ~3 req/sec * @@ -252,7 +256,8 @@ export default class UtilsAPI { } /** - * Call 0x API to generate a trade quote for two SetToken components. + * Call 0x API to generate a trade quote for two SetToken components. This method is intended to be used alongside + * the DelegatedManager TradeExtension which allows you to rebalance holdings within the SetToken. * * @param fromToken Address of token being sold * @param toToken Address of token being bought @@ -320,9 +325,10 @@ export default class UtilsAPI { } /** - * Batch multiple calls to 0x API to generate trade quotes for SetToken component pairs. By default, trade quotes - * are fetched for 0x's public endpoints using their `https://api.0x.org`, `https:///api.0x.org` - * url scheme. In practice these open endpoints appear to be rate limited at ~3 req/sec + * Batch multiple calls to 0x API to generate trade quotes for SetToken component pairs. This method is intended + * to be used alongside the DelegatedManager BatchTradeExtension which allows you to rebalance holdings within the + * SetToken. By default, trade quotes are fetched for 0x's public endpoints using their `https://api.0x.org`, + * `https:///api.0x.org` url scheme. In practice these open endpoints appear to be rate limited at ~3 req/sec * * It's also possible to make calls from non-browser context with an API key using the `https://gated.api.0x.org` * url scheme. From e3580d321c07cb8d4181f2bb0aa8e357228b2434 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Wed, 27 Apr 2022 14:14:30 -0700 Subject: [PATCH 12/26] Fix incorrect BatchTradeExtension_factory import --- package.json | 2 +- src/wrappers/set-v2-strategies/ContractWrapper.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b818b4d..b6a9fec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.6.0-batch.2", + "version": "0.6.0-batch.3", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", diff --git a/src/wrappers/set-v2-strategies/ContractWrapper.ts b/src/wrappers/set-v2-strategies/ContractWrapper.ts index 4242d6c..f0e1515 100644 --- a/src/wrappers/set-v2-strategies/ContractWrapper.ts +++ b/src/wrappers/set-v2-strategies/ContractWrapper.ts @@ -46,7 +46,7 @@ import { } from '@setprotocol/set-v2-strategies/dist/typechain/factories/IssuanceExtension__factory'; import { BatchTradeExtension__factory, -} from '@setprotocol/set-v2-strategies/dist/typechain/factories/TradeExtension__factory'; +} from '@setprotocol/set-v2-strategies/dist/typechain/factories/BatchTradeExtension__factory'; /** From c67ffa0c4ee90da787c507192e2ce278f2ec523f Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 28 Apr 2022 16:41:26 -0700 Subject: [PATCH 13/26] Fix from/to tokenAmount calculation in TradeQuoterAPI --- src/api/utils/tradeQuoter.ts | 27 +++++++++++---------------- test/fixtures/tradeQuote.ts | 4 ++-- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/api/utils/tradeQuoter.ts b/src/api/utils/tradeQuoter.ts index ca1aee4..e224682 100644 --- a/src/api/utils/tradeQuoter.ts +++ b/src/api/utils/tradeQuoter.ts @@ -16,8 +16,7 @@ 'use strict'; -import BigDecimal from 'js-big-decimal'; -import { BigNumber, FixedNumber, utils as ethersUtils } from 'ethers'; +import { BigNumber, FixedNumber, utils as ethersUtils, constants as ethersConstants } from 'ethers'; import type TradeModuleWrapper from '@src/wrappers/set-protocol-v2/TradeModuleWrapper'; import { @@ -313,26 +312,14 @@ export class TradeQuoter { ); const fromTokenAmount = quote.sellAmount; - - // Convert to BigDecimal to get ceiling in fromUnits calculation - // This is necessary to derive the trade amount ZeroEx expects when scaling is - // done in the TradeModule contract. (ethers.FixedNumber does not work for this case) - const fromTokenAmountBD = new BigDecimal(fromTokenAmount.toString()); - const scaleBD = new BigDecimal(SCALE.toString()); - const setTotalSupplyBD = new BigDecimal(setTotalSupply.toString()); - - const fromUnitsBD = fromTokenAmountBD.multiply(scaleBD).divide(setTotalSupplyBD, 10).ceil(); - const fromUnits = BigNumber.from(fromUnitsBD.getValue()); + const fromUnits = this.preciseMulCeil(fromTokenAmount, setTotalSupply); const toTokenAmount = quote.buyAmount; - - // BigNumber does not do fixed point math & FixedNumber underflows w/ numbers less than 1 - // Multiply the slippage by a factor and divide the end result by same... const percentMultiplier = 1000; const slippageAndFee = slippagePercentage + feePercentage; const slippageToleranceBN = Math.floor(percentMultiplier * this.outputSlippageTolerance(slippageAndFee)); const toTokenAmountMinusSlippage = toTokenAmount.mul(slippageToleranceBN).div(percentMultiplier); - const toUnits = toTokenAmountMinusSlippage.mul(SCALE).div(setTotalSupply); + const toUnits = this.preciseMulCeil(toTokenAmountMinusSlippage, setTotalSupply); return { fromTokenAmount, @@ -407,6 +394,14 @@ export class TradeQuoter { } } + private preciseMulCeil(a: BigNumber, b: BigNumber): BigNumber { + if (a.eq(0) || b.eq(0)) { + return ethersConstants.Zero; + } + + return a.mul(b).sub(1).div(SCALE).add(1); + } + private tokenDisplayAmount(amount: BigNumber, decimals: number): string { return this.normalizeTokenAmount(amount, decimals).toString(); } diff --git a/test/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts index 8c4e143..edbdb03 100644 --- a/test/fixtures/tradeQuote.ts +++ b/test/fixtures/tradeQuote.ts @@ -169,8 +169,8 @@ export const tradeQuoteFixtures = { gas: '315000', gasPrice: '61', slippagePercentage: '2.00%', - fromTokenAmount: '1126868991563', - toTokenAmount: '90314741816', + fromTokenAmount: '221853651020280958044955', + toTokenAmount: '17780820452824974082126', display: { inputAmountRaw: '.5', inputAmount: '500000000000000000', From c98e4092264adf01cddedfb1cae2934f2008437d Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 28 Apr 2022 16:42:41 -0700 Subject: [PATCH 14/26] Upgrade version to batch.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b6a9fec..eec8914 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.6.0-batch.3", + "version": "0.6.0-batch.4", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", From 17bbe4f2b2c60ba81a2dea7a63632327eb00a48d Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 28 Apr 2022 17:31:02 -0700 Subject: [PATCH 15/26] Update set-v2-strategies to 0.0.11 & update TradeInfo type --- package.json | 2 +- src/types/common.ts | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index eec8914..1bbd021 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@0xproject/typescript-typings": "^3.0.2", "@0xproject/utils": "^2.0.2", "@setprotocol/set-protocol-v2": "^0.1.15", - "@setprotocol/set-v2-strategies": "^0.0.10", + "@setprotocol/set-v2-strategies": "^0.0.11", "@types/chai-as-promised": "^7.1.3", "@types/jest": "^26.0.5", "@types/web3": "^1.2.2", diff --git a/src/types/common.ts b/src/types/common.ts index fb5b1df..8372e91 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -175,7 +175,7 @@ export type TradeInfo = { sendToken: Address; sendQuantity: BigNumber; receiveToken: Address; - minReceiveQuantity: BigNumber; + receiveQuantity: BigNumber; data: BytesLike; }; diff --git a/yarn.lock b/yarn.lock index 5e8869a..163fc3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1081,10 +1081,10 @@ module-alias "^2.2.2" replace-in-file "^6.1.0" -"@setprotocol/set-v2-strategies@^0.0.10": - version "0.0.10" - resolved "https://registry.yarnpkg.com/@setprotocol/set-v2-strategies/-/set-v2-strategies-0.0.10.tgz#eab1bc4001c7a0d5dac61b82ff991d413daf5fde" - integrity sha512-Jca6tLOadDSF0hRw65ML6Sf69Z3lwUJFYta4jn08iaKnGZMgRlDiRo8KvpWolmyznnCLTdFOPtz/woJKqkc09w== +"@setprotocol/set-v2-strategies@^0.0.11": + version "0.0.11" + resolved "https://registry.yarnpkg.com/@setprotocol/set-v2-strategies/-/set-v2-strategies-0.0.11.tgz#d8243f44f1ec0761051163cbfe091aef27278820" + integrity sha512-0CeLtoI88nuBgo9rGmUtt2lE+kZ2taPtzFPFVSFz5fftCESyfaKxDY9H1IVivtP5ACSbdknnR6TUFsDZJpE5UQ== dependencies: "@setprotocol/set-protocol-v2" "0.10.0-hhat.1" "@uniswap/v3-sdk" "^3.5.1" From c731ae970726f0968b354456555dc595cba057c8 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 28 Apr 2022 17:36:30 -0700 Subject: [PATCH 16/26] Update version to batch.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1bbd021..b3ba323 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.6.0-batch.4", + "version": "0.6.0-batch.5", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", From c2c0a9b42ec894351e257428bbd8b02ab5b9be0f Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 28 Apr 2022 17:39:04 -0700 Subject: [PATCH 17/26] Fix tsc for unit tests --- test/api/extensions/BatchTradeExtensionAPI.spec.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/api/extensions/BatchTradeExtensionAPI.spec.ts b/test/api/extensions/BatchTradeExtensionAPI.spec.ts index 138547b..b744c44 100644 --- a/test/api/extensions/BatchTradeExtensionAPI.spec.ts +++ b/test/api/extensions/BatchTradeExtensionAPI.spec.ts @@ -108,7 +108,7 @@ describe('BatchTradeExtensionAPI', () => { const sendToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; const sendQuantity = ether(10); const receiveToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; - const minReceiveQuantity = ether(.9); + const receiveQuantity = ether(.9); const data = '0x123456789abcdedf'; subjectTrades = [ @@ -117,7 +117,7 @@ describe('BatchTradeExtensionAPI', () => { sendToken, receiveToken, sendQuantity, - minReceiveQuantity, + receiveQuantity, data, }, { @@ -125,7 +125,7 @@ describe('BatchTradeExtensionAPI', () => { sendToken: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', receiveToken: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', sendQuantity, - minReceiveQuantity, + receiveQuantity, data, }, ]; @@ -195,8 +195,8 @@ describe('BatchTradeExtensionAPI', () => { }); }); - describe('when minReceiveQuantity is not a valid number', () => { - beforeEach(() => subjectTrades[0].minReceiveQuantity = NaN as BigNumber); + describe('when receiveQuantity is not a valid number', () => { + beforeEach(() => subjectTrades[0].receiveQuantity = NaN as BigNumber); it('should throw with invalid params', async () => { await expect(subject()).to.be.rejectedWith('Validation error'); @@ -213,7 +213,7 @@ describe('BatchTradeExtensionAPI', () => { const sendToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; const sendQuantity = ether(10); const receiveToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; - const minReceiveQuantity = ether(.9); + const receiveQuantity = ether(.9); const data = '0x123456789abcdedf'; subjectTrades = [ @@ -222,7 +222,7 @@ describe('BatchTradeExtensionAPI', () => { sendToken, receiveToken, sendQuantity, - minReceiveQuantity, + receiveQuantity, data, }, ]; From 99cd46c5270dd8a82d3405c04ef720023d9ccc62 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 28 Apr 2022 17:40:57 -0700 Subject: [PATCH 18/26] Fix tsc errors in BatchTradeExtensionAPI --- src/api/extensions/BatchTradeExtensionAPI.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/extensions/BatchTradeExtensionAPI.ts b/src/api/extensions/BatchTradeExtensionAPI.ts index 47b6e39..e95e8a9 100644 --- a/src/api/extensions/BatchTradeExtensionAPI.ts +++ b/src/api/extensions/BatchTradeExtensionAPI.ts @@ -188,7 +188,7 @@ export default class BatchTradeExtensionAPI { this.assert.schema.isValidAddress('sendToken', trade.sendToken); this.assert.schema.isValidNumber('sendQuantity', trade.sendQuantity); this.assert.schema.isValidAddress('receiveToken', trade.receiveToken); - this.assert.schema.isValidNumber('minReceiveQuantity', trade.minReceiveQuantity); + this.assert.schema.isValidNumber('minReceiveQuantity', trade.receiveQuantity); this.assert.schema.isValidBytes('data', trade.data); } } From 7cf911677249fd904d73fd80151d6087e4732331 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Fri, 29 Apr 2022 07:12:47 -0700 Subject: [PATCH 19/26] Update batchTrade receipt fixture --- .../extensions/BatchTradeExtensionAPI.spec.ts | 2 +- test/fixtures/tradeQuote.ts | 31 ++++++++++--------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/test/api/extensions/BatchTradeExtensionAPI.spec.ts b/test/api/extensions/BatchTradeExtensionAPI.spec.ts index b744c44..37e7881 100644 --- a/test/api/extensions/BatchTradeExtensionAPI.spec.ts +++ b/test/api/extensions/BatchTradeExtensionAPI.spec.ts @@ -242,7 +242,7 @@ describe('BatchTradeExtensionAPI', () => { expect(results[0].success).eq(false); expect(results[0].tradeInfo).deep.eq(subjectTrades[0]); - expect(results[0].revertReason).eq(`NotImplementedError({ selector: '0x6af479b2' })`); + expect(results[0].revertReason).eq(`UniswapFeature/UnderBought`); }); }); diff --git a/test/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts index edbdb03..8031fc8 100644 --- a/test/fixtures/tradeQuote.ts +++ b/test/fixtures/tradeQuote.ts @@ -238,20 +238,21 @@ export const tradeQuoteFixtures = { }, batchTradeReceipt: { - logs: [ - { - transactionIndex: 0, - blockNumber: 12198056, - transactionHash: '0x676f0263b724d24158d4999167ab9195edebe814e2cc05d0fa38dbdcc16d6a73', - address: '0xa7e91d5f0fB18B1a14C178A3525628aA4aF0b67B', - topics: [ - '0x3268c4b9432fbdcc6db02e199710ef5c040d54354d44fedf46116bf4a165667d', - '0x0000000000000000000000005d61b97025b8e42935128339a71147b9393209b5', - ], - data: '0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024734e6e1c6af479b20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', - logIndex: 0, - blockHash: '0x8aa6ec73fe9af0387c4fa1ec897d8fe23a7a86b4b07a1fdb4da78a8445906155', - }, - ], + 'logs': [ + { + 'transactionIndex': 0, + 'blockNumber': 12198056, + 'transactionHash': '0x5a998064638183c45ad81c9911b2be7e1f2a800c5691cbfa59f59feb0cb1eb96', + 'address': '0xa7e91d5f0fB18B1a14C178A3525628aA4aF0b67B', + 'topics': [ + '0x4626b45e96909618657adccb85b0f2c7413b7182966707ae16001eec1ea2f12a', + '0x0000000000000000000000005d61b97025b8e42935128339a71147b9393209b5', + '0x0000000000000000000000000000000000000000000000000000000000000000', + ], + 'data': '0x00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000001a556e6973776170466561747572652f556e646572426f7567687400000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000000000000000000000000001e5b8fa8fe2ac00000000000000000000000000002260fac5e5542a773aa44fbcfedf7c193bc2c5990000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000125a65726f457841706941646170746572563500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000128d9627aa40000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000086b64d7de7ddb4000000000000000000000000000000000000000000000000000000000000005fe66d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000002260fac5e5542a773aa44fbcfedf7c193bc2c599869584cd0000000000000000000000001000000000000000000000000000000000000011000000000000000000000000000000000000000000000059d8dcf9b6626bede8000000000000000000000000000000000000000000000000', + 'logIndex': 0, + 'blockHash': '0xdbe329ba420914fdefcc3c5a954da9cd8ee10e1040998b34649d169054daacf6', + }, + ], }, }; From 5b836e69f0d20193e2c692efffce884d8156c4f2 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Mon, 2 May 2022 19:41:53 -0700 Subject: [PATCH 20/26] Revert "Fix from/to tokenAmount calculation in TradeQuoterAPI" This reverts commit c67ffa0c4ee90da787c507192e2ce278f2ec523f. --- src/api/utils/tradeQuoter.ts | 27 ++++++++++++++++----------- test/fixtures/tradeQuote.ts | 4 ++-- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/api/utils/tradeQuoter.ts b/src/api/utils/tradeQuoter.ts index e224682..ca1aee4 100644 --- a/src/api/utils/tradeQuoter.ts +++ b/src/api/utils/tradeQuoter.ts @@ -16,7 +16,8 @@ 'use strict'; -import { BigNumber, FixedNumber, utils as ethersUtils, constants as ethersConstants } from 'ethers'; +import BigDecimal from 'js-big-decimal'; +import { BigNumber, FixedNumber, utils as ethersUtils } from 'ethers'; import type TradeModuleWrapper from '@src/wrappers/set-protocol-v2/TradeModuleWrapper'; import { @@ -312,14 +313,26 @@ export class TradeQuoter { ); const fromTokenAmount = quote.sellAmount; - const fromUnits = this.preciseMulCeil(fromTokenAmount, setTotalSupply); + + // Convert to BigDecimal to get ceiling in fromUnits calculation + // This is necessary to derive the trade amount ZeroEx expects when scaling is + // done in the TradeModule contract. (ethers.FixedNumber does not work for this case) + const fromTokenAmountBD = new BigDecimal(fromTokenAmount.toString()); + const scaleBD = new BigDecimal(SCALE.toString()); + const setTotalSupplyBD = new BigDecimal(setTotalSupply.toString()); + + const fromUnitsBD = fromTokenAmountBD.multiply(scaleBD).divide(setTotalSupplyBD, 10).ceil(); + const fromUnits = BigNumber.from(fromUnitsBD.getValue()); const toTokenAmount = quote.buyAmount; + + // BigNumber does not do fixed point math & FixedNumber underflows w/ numbers less than 1 + // Multiply the slippage by a factor and divide the end result by same... const percentMultiplier = 1000; const slippageAndFee = slippagePercentage + feePercentage; const slippageToleranceBN = Math.floor(percentMultiplier * this.outputSlippageTolerance(slippageAndFee)); const toTokenAmountMinusSlippage = toTokenAmount.mul(slippageToleranceBN).div(percentMultiplier); - const toUnits = this.preciseMulCeil(toTokenAmountMinusSlippage, setTotalSupply); + const toUnits = toTokenAmountMinusSlippage.mul(SCALE).div(setTotalSupply); return { fromTokenAmount, @@ -394,14 +407,6 @@ export class TradeQuoter { } } - private preciseMulCeil(a: BigNumber, b: BigNumber): BigNumber { - if (a.eq(0) || b.eq(0)) { - return ethersConstants.Zero; - } - - return a.mul(b).sub(1).div(SCALE).add(1); - } - private tokenDisplayAmount(amount: BigNumber, decimals: number): string { return this.normalizeTokenAmount(amount, decimals).toString(); } diff --git a/test/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts index 8031fc8..41433ef 100644 --- a/test/fixtures/tradeQuote.ts +++ b/test/fixtures/tradeQuote.ts @@ -169,8 +169,8 @@ export const tradeQuoteFixtures = { gas: '315000', gasPrice: '61', slippagePercentage: '2.00%', - fromTokenAmount: '221853651020280958044955', - toTokenAmount: '17780820452824974082126', + fromTokenAmount: '1126868991563', + toTokenAmount: '90314741816', display: { inputAmountRaw: '.5', inputAmount: '500000000000000000', From 1a83a79fc9d3d27c03094a1b475292b775eecf5b Mon Sep 17 00:00:00 2001 From: cgewecke Date: Mon, 2 May 2022 19:50:08 -0700 Subject: [PATCH 21/26] Update version to batch.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b3ba323..5c27b61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.6.0-batch.5", + "version": "0.6.0-batch.6", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", From 9b187956f435c8796edb81bc55dc87173eba8956 Mon Sep 17 00:00:00 2001 From: Dylan Tran Date: Tue, 10 May 2022 13:26:34 -0700 Subject: [PATCH 22/26] Account for Dust Positions in Batch Trade [SIM-286] (#118) * update code comments in set token api + protocol viewer to reflect correct param usage * add null address constant * update validate quotes method to be more generalized * check batch trade does not produce dust positions on utils api layer * update trade quoter to use BigNumber instead of decimal * update validation helper to allow only checking from token quantities option * refactor conversion of tokens from and to pre token positions into helper method * remove remaining comments for checking dust positions * correctly use current token position if max implied + zero ex match * only validate batch trade does not produce dust positions for tokens spread across multiple trades * add more clarity to comments * add comment re: max selling across multiple trades --- src/api/SetTokenAPI.ts | 2 +- src/api/UtilsAPI.ts | 7 + src/api/utils/tradeQuoter.ts | 215 +++++++++++++++--- src/utils/constants.ts | 3 + .../set-protocol-v2/ProtocolViewerWrapper.ts | 2 +- 5 files changed, 192 insertions(+), 37 deletions(-) diff --git a/src/api/SetTokenAPI.ts b/src/api/SetTokenAPI.ts index 2a550f3..646b89d 100644 --- a/src/api/SetTokenAPI.ts +++ b/src/api/SetTokenAPI.ts @@ -124,7 +124,7 @@ export default class SetTokenAPI { * the initialization statuses of each of the modules for the SetToken * * @param setTokenAddress Address of SetToken to fetch details for - * @param moduleAddresses Addresses of ERC20 contracts to check balance for + * @param moduleAddresses Addresses of modules to check initialization statuses for * @param callerAddress Address to use as the caller (optional) */ public async fetchSetDetailsAsync( diff --git a/src/api/UtilsAPI.ts b/src/api/UtilsAPI.ts index ff338bb..d409474 100644 --- a/src/api/UtilsAPI.ts +++ b/src/api/UtilsAPI.ts @@ -390,6 +390,13 @@ export default class UtilsAPI { const orders = []; let delay = 0; + // Check that summation of order pairs do not create dust positions upon trade execution + self.tradeQuoter.validateBatchTradeDoesNotProduceDustPosition( + orderPairs, + setToken, + fromAddress + ); + for (const pair of orderPairs) { const order = new Promise(async function (resolve, reject) { await new Promise(r => setTimeout(() => r(true), delay)); diff --git a/src/api/utils/tradeQuoter.ts b/src/api/utils/tradeQuoter.ts index ca1aee4..37da8cb 100644 --- a/src/api/utils/tradeQuoter.ts +++ b/src/api/utils/tradeQuoter.ts @@ -26,6 +26,7 @@ import { SwapQuoteOptions, TradeQuote, SwapQuote, + TradeOrderPair, ZeroExApiUrls } from '../../types/index'; @@ -37,6 +38,9 @@ import { import { Address } from '@setprotocol/set-protocol-v2/utils/types'; import { GasOracleService } from './gasOracle'; import { ZeroExTradeQuoter } from './zeroex'; +import SetTokenAPI from '../SetTokenAPI'; +import { SetDetails, SetDetailsWithStreamingInfo } from '../../types/common'; +import { NULL_ADDRESS } from '../../utils/constants'; export const ZERO_EX_ADAPTER_NAME = 'ZeroExApiAdapterV5'; @@ -92,7 +96,7 @@ export class TradeQuoter { const amount = this.sanitizeAmount(options.rawAmount, options.fromTokenDecimals); const setOnChainDetails = await options.setToken.fetchSetDetailsAsync( - fromAddress, [fromTokenAddress, toTokenAddress] + fromAddress, [NULL_ADDRESS] ); const fromTokenRequestAmount = this.calculateFromTokenAmount( @@ -101,18 +105,21 @@ export class TradeQuoter { amount ); + // This does not currently account for attempting "Max" sell (e.g. selling all USDC in Set) + // across multiple trades. To do that, we would need to update the remaining position + // quantities for each component being sold in the Set, so we can correctly detect + // when a "Max" sell trade is being attempted. const { fromTokenAmount, fromUnits, toTokenAmount, toUnits, calldata, - } = await this.fetchZeroExQuoteForTradeModule( // fetchQuote (and switch...) + } = await this.fetchZeroExQuoteForTradeModule( fromTokenAddress, toTokenAddress, fromTokenRequestAmount, - setOnChainDetails.manager, - (setOnChainDetails as any).totalSupply, // Typings incorrect, + setOnChainDetails, chainId, isFirmQuote, slippagePercentage, @@ -121,12 +128,12 @@ export class TradeQuoter { feePercentage, ); - // Sanity check response from quote APIs - this.validateQuoteValues( + // Check that the trade data we return to front-end will not generate dust positions. + this.validateTradeDoesNotProduceDustPositions( setOnChainDetails, fromTokenAddress, - toTokenAddress, fromUnits, + toTokenAddress, toUnits ); @@ -280,12 +287,56 @@ export class TradeQuoter { return ethersUtils.parseUnits(rawAmount, decimals); } + /** + * ZeroEx returns the total quantity of component to be traded as order data. + * This order data is submitted to Set's Trade Module, but it's format must be tweaked. + * Set's Trade Module accepts the quantity of components to be traded per Set Token + * in Total Supply. + * + * This helper converts ZeroEx's trade order data to something Trade Module can consume. + * The converted data: + * 1. Bakes a fee percentage to be paid to the Set Protocol. + * 2. Is also used to complete sanity checks on possible dust positions. + * 3. is eventually returned to the front-end, to be submitted on-chain directly. + * (ethers.FixedNumber does not work for this case) + * @param tokenQuantity Component quantity to be converted. + * @param setTotalSupply Total supply of the Set. + * @param isReceiveQuantity Boolean. True if the token is being received. + * @param slippagePercentage The amount of slippage allowed on the trade. + * @param feePercentage The fee percentage to be paid to Set. Applied if isReceiveQuantity is true. + */ + private convertTotalSetQuantitiesToPerTokenQuantities( + tokenQuantity: string, + setTotalSupply: string, + isReceiveQuantity?: boolean, + slippagePercentage?: number, + feePercentage?: number, + ): BigNumber { + if (!isReceiveQuantity) { + const tokenAmountBD = new BigDecimal(tokenQuantity.toString()); + const scaleBD = new BigDecimal(SCALE.toString()); + const setTotalSupplyBD = new BigDecimal(setTotalSupply.toString()); + + const tokenUnitsBD = tokenAmountBD.multiply(scaleBD).divide(setTotalSupplyBD, 10).ceil(); + return BigNumber.from(tokenUnitsBD.getValue()); + } + + // If we are converting "buy" quantities, we need to account for a trade fee percentage + // & slippage. We seem to lose some precisien merely multiplying the above + // by slippageTolerance so we re-do the math in full here. + const percentMultiplier = 1000; + const slippageAndFee = slippagePercentage + feePercentage; + const slippageToleranceBN = Math.floor(percentMultiplier * this.outputSlippageTolerance(slippageAndFee)); + const tokenAmountMinusSlippage = BigNumber.from(tokenQuantity).mul(slippageToleranceBN).div(percentMultiplier); + + return tokenAmountMinusSlippage.mul(SCALE).div(setTotalSupply); + } + private async fetchZeroExQuoteForTradeModule( fromTokenAddress: Address, toTokenAddress: Address, fromTokenRequestAmount: BigNumber, - manager: Address, - setTotalSupply: BigNumber, + setOnChainDetails: SetDetails | SetDetailsWithStreamingInfo, chainId: number, isFirmQuote: boolean, slippagePercentage: number, @@ -293,6 +344,9 @@ export class TradeQuoter { excludedSources: string[], feePercentage: number, ) { + const manager = setOnChainDetails.manager; + const setTotalSupply = (setOnChainDetails as any).totalSupply; + const zeroEx = new ZeroExTradeQuoter({ chainId: chainId, zeroExApiKey: this.zeroExApiKey, @@ -312,27 +366,42 @@ export class TradeQuoter { (feePercentage / 100) ); - const fromTokenAmount = quote.sellAmount; + const positionForFromToken = setOnChainDetails + .positions + .find((p: any) => p.component.toLowerCase() === fromTokenAddress.toLowerCase()); - // Convert to BigDecimal to get ceiling in fromUnits calculation - // This is necessary to derive the trade amount ZeroEx expects when scaling is - // done in the TradeModule contract. (ethers.FixedNumber does not work for this case) - const fromTokenAmountBD = new BigDecimal(fromTokenAmount.toString()); - const scaleBD = new BigDecimal(SCALE.toString()); - const setTotalSupplyBD = new BigDecimal(setTotalSupply.toString()); + const currentPositionUnits = BigNumber.from(positionForFromToken.unit); + const fromTokenImpliedMaxPositionInSet = + currentPositionUnits + .mul(setTotalSupply) + .div(SCALE.toString()); + + // If the trade quote returned form ZeroEx equals the target sell token's implied max + // position in the Set, we simply return the components current position in the Set as the + // "sell quantity" to be submitted to the Trade Module. + // If we tried to convert the ZeroEx trade quote data to the per-token quantity, we may + // to incorrectly calculate the per-token position, resulting in a dust position being created. + // Remember: the Trade Module accepts trade quantities on a per-token basis. + const fromTokenAmount = quote.sellAmount; + let fromUnits: BigNumber; - const fromUnitsBD = fromTokenAmountBD.multiply(scaleBD).divide(setTotalSupplyBD, 10).ceil(); - const fromUnits = BigNumber.from(fromUnitsBD.getValue()); + if (fromTokenAmount.eq(fromTokenImpliedMaxPositionInSet)) { + fromUnits = currentPositionUnits; + } else { + fromUnits = this.convertTotalSetQuantitiesToPerTokenQuantities( + fromTokenAmount.toString(), + setTotalSupply.toString(), + ); + } const toTokenAmount = quote.buyAmount; - - // BigNumber does not do fixed point math & FixedNumber underflows w/ numbers less than 1 - // Multiply the slippage by a factor and divide the end result by same... - const percentMultiplier = 1000; - const slippageAndFee = slippagePercentage + feePercentage; - const slippageToleranceBN = Math.floor(percentMultiplier * this.outputSlippageTolerance(slippageAndFee)); - const toTokenAmountMinusSlippage = toTokenAmount.mul(slippageToleranceBN).div(percentMultiplier); - const toUnits = toTokenAmountMinusSlippage.mul(SCALE).div(setTotalSupply); + const toUnits = this.convertTotalSetQuantitiesToPerTokenQuantities( + quote.buyAmount.toString(), + setTotalSupply.toString(), + true, + slippagePercentage, + feePercentage, + ); return { fromTokenAmount, @@ -343,12 +412,84 @@ export class TradeQuoter { }; } - private validateQuoteValues( + + /** + * Check that a given batch trade does not produce dust positions in tokens being + * sold across multiple trades. This may happen when a user tries to max sell + * out of a token position across multiple trades in a single batch. + * + * @param orderPairs A list of all trades to be made in a given batch trade txn + * @param setToken An instance fo the Set Token API, used to fetch total supply + * required to complete validation + * @param setTokenAddress Set Token that will be executing the batch trade txn + */ + public async validateBatchTradeDoesNotProduceDustPosition( + orderPairs: TradeOrderPair[], + setToken: SetTokenAPI, + setTokenAddress: Address + ): Promise { + const allSellQuantitiesByAddress = {}; + const sellTokensPresentInMultipleTrades = {}; + + orderPairs.forEach((orderEntry: TradeOrderPair) => { + const { fromToken: fromTokenAddress, fromTokenDecimals } = orderEntry; + + const fromTokenScaleBN = BigNumber.from(10).pow(fromTokenDecimals); + const fromTokenScaleBD = new BigDecimal(fromTokenScaleBN.toString()); + const fromTokenAmountBD = new BigDecimal(orderEntry.rawAmount).multiply(fromTokenScaleBD); + const fromTokenAmountBN = BigNumber.from(fromTokenAmountBD.getValue()); + + const totalSellQuantityForComponent = allSellQuantitiesByAddress[fromTokenAddress]; + + if (!totalSellQuantityForComponent) { + allSellQuantitiesByAddress[fromTokenAddress] = fromTokenAmountBN; + } else { + allSellQuantitiesByAddress[fromTokenAddress] = totalSellQuantityForComponent.add(fromTokenAmountBN); + sellTokensPresentInMultipleTrades[fromTokenAddress] = true; + } + }); + + const setOnChainDetails = await setToken.fetchSetDetailsAsync( + setTokenAddress, [NULL_ADDRESS] + ); + + // Check that each component being sold will not have dust position leftover. + // We only want to check poential sell tokens that are spread across multiple trades. + // We have other logic in place in fetchZeroExTradeQuoteForTradeModule to allow users to + // properly max sell out of a position in a single trade. + Object.keys(sellTokensPresentInMultipleTrades).forEach( + (fromTokenAddress: Address) => { + const totalSellQuantity = allSellQuantitiesByAddress[fromTokenAddress]; + const perTokenSellQuantity = this.convertTotalSetQuantitiesToPerTokenQuantities( + totalSellQuantity.toString(), + (setOnChainDetails as any).totalSupply, + ); + + this.validateTradeDoesNotProduceDustPositions( + setOnChainDetails, + fromTokenAddress, + perTokenSellQuantity, + ); + } + ); + } + + /** + * Ensures that a sell or buy quantity generated by a trade quote would not result + * in the target Set having a very small dust position upon trade execution. + * + * @param setOnChainDetails Set Token whose components are being traded + * @param fromTokenAddress Address of token being sold + * @param toTokenAddress Quantity of token being sold + * @param fromTokenQuantity Address of token being bought + * @param toTokenQuantity Quantity of token being bought + */ + private validateTradeDoesNotProduceDustPositions( setOnChainDetails: any, fromTokenAddress: Address, - toTokenAddress: Address, - quoteFromRemainingUnits: BigNumber, - quoteToUnits: BigNumber + fromTokenQuantity: BigNumber, + toTokenAddress?: Address, + toTokenQuantity?: BigNumber ) { // fromToken const positionForFromToken = setOnChainDetails @@ -356,26 +497,30 @@ export class TradeQuoter { .find((p: any) => p.component.toLowerCase() === fromTokenAddress.toLowerCase()); const currentPositionUnits = BigNumber.from(positionForFromToken.unit); - const remainingPositionUnits = currentPositionUnits.sub(quoteFromRemainingUnits); + const remainingPositionUnits = currentPositionUnits.sub(fromTokenQuantity); const remainingPositionUnitsTooSmall = remainingPositionUnits.gt(0) && remainingPositionUnits.lt(50); if (remainingPositionUnitsTooSmall) { - throw new Error('Remaining units too small, incorrectly attempting max'); + throw new Error('Remaining units too small, incorrectly attempting sell max'); } + // Sometimes we use this method to only check for dust positions + // in the sell component. + if (!toTokenAddress && !toTokenQuantity) return; + // toToken const positionForToToken = setOnChainDetails .positions .find((p: any) => p.component.toLowerCase() === toTokenAddress.toLowerCase()); const newToPositionUnits = (positionForToToken !== undefined) - ? positionForToToken.unit.add(quoteToUnits) - : quoteToUnits; + ? positionForToToken.unit.add(toTokenQuantity) + : toTokenQuantity; const newToUnitsTooSmall = newToPositionUnits.gt(0) && newToPositionUnits.lt(50); if (newToUnitsTooSmall) { - throw new Error('Receive units too small'); + throw new Error('Receive units too small, attempting to purchase dust quantity of tokens'); } } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index de9a08d..2f47af5 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,2 +1,5 @@ +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; + export const DEFAULT_GAS_LIMIT: number = 12500000; // default of 12.5 million gas export const DEFAULT_GAS_PRICE: number = 6000000000; // 6 gwei +export const NULL_ADDRESS: Address = '0x0000000000000000000000000000000000000000'; diff --git a/src/wrappers/set-protocol-v2/ProtocolViewerWrapper.ts b/src/wrappers/set-protocol-v2/ProtocolViewerWrapper.ts index a1a244f..bfbb33b 100644 --- a/src/wrappers/set-protocol-v2/ProtocolViewerWrapper.ts +++ b/src/wrappers/set-protocol-v2/ProtocolViewerWrapper.ts @@ -137,7 +137,7 @@ export default class ProtocolViewerWrapper { * the initialization statuses of each of the modules for the SetToken * * @param setTokenAddress Address of SetToken to fetch details for - * @param moduleAddresses Addresses of ERC20 contracts to check balance for + * @param moduleAddresses Addresses of modules to check initialization statuses for * @param callerAddress Address to use as the caller (optional) */ public async getSetDetails( From 929dba5160d083ec88ad25b104680e8869e4f647 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Tue, 10 May 2022 13:38:09 -0700 Subject: [PATCH 23/26] Upgrade version to batch.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5c27b61..960feab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.6.0-batch.6", + "version": "0.6.0-batch.7", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", From ac2513309fa35f790c31e4fae7595912250d90e6 Mon Sep 17 00:00:00 2001 From: Dylan Tran Date: Wed, 18 May 2022 16:31:22 -0700 Subject: [PATCH 24/26] calculate dust position validation via notional units rather than per set units --- src/api/utils/tradeQuoter.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/api/utils/tradeQuoter.ts b/src/api/utils/tradeQuoter.ts index 37da8cb..60d26ee 100644 --- a/src/api/utils/tradeQuoter.ts +++ b/src/api/utils/tradeQuoter.ts @@ -92,12 +92,14 @@ export class TradeQuoter { toTokenAddress, fromAddress, } = this.sanitizeAddress(options.fromToken, options.toToken, options.fromAddress); + console.log('raw amount', options.rawAmount); const amount = this.sanitizeAmount(options.rawAmount, options.fromTokenDecimals); const setOnChainDetails = await options.setToken.fetchSetDetailsAsync( fromAddress, [NULL_ADDRESS] ); + console.log('sanitized amount', amount.toString()); const fromTokenRequestAmount = this.calculateFromTokenAmount( setOnChainDetails, @@ -105,6 +107,8 @@ export class TradeQuoter { amount ); + console.log('from token request amount 1', fromTokenRequestAmount.toString()); + // This does not currently account for attempting "Max" sell (e.g. selling all USDC in Set) // across multiple trades. To do that, we would need to update the remaining position // quantities for each component being sold in the Set, so we can correctly detect @@ -353,6 +357,8 @@ export class TradeQuoter { zeroExApiUrls: this.zeroExApiUrls, }); + console.log('from token request amount', fromTokenRequestAmount.toString()); + const quote = await zeroEx.fetchTradeQuote( fromTokenAddress, toTokenAddress, @@ -371,11 +377,14 @@ export class TradeQuoter { .find((p: any) => p.component.toLowerCase() === fromTokenAddress.toLowerCase()); const currentPositionUnits = BigNumber.from(positionForFromToken.unit); + console.log('current position units', currentPositionUnits.toString()); const fromTokenImpliedMaxPositionInSet = currentPositionUnits .mul(setTotalSupply) .div(SCALE.toString()); + console.log('implied max quantity', fromTokenImpliedMaxPositionInSet.toString()); + // If the trade quote returned form ZeroEx equals the target sell token's implied max // position in the Set, we simply return the components current position in the Set as the // "sell quantity" to be submitted to the Trade Module. @@ -385,6 +394,8 @@ export class TradeQuoter { const fromTokenAmount = quote.sellAmount; let fromUnits: BigNumber; + console.log('0x sell quantity quote', fromTokenAmount.toString()); + console.log('set total supply', setTotalSupply.toString()); if (fromTokenAmount.eq(fromTokenImpliedMaxPositionInSet)) { fromUnits = currentPositionUnits; } else { @@ -392,6 +403,7 @@ export class TradeQuoter { fromTokenAmount.toString(), setTotalSupply.toString(), ); + console.log('converted 0x sell quantity into Set position units', fromUnits.toString()); } const toTokenAmount = quote.buyAmount; @@ -496,9 +508,15 @@ export class TradeQuoter { .positions .find((p: any) => p.component.toLowerCase() === fromTokenAddress.toLowerCase()); + const setTotalSupply = (setOnChainDetails as any).totalSupply; const currentPositionUnits = BigNumber.from(positionForFromToken.unit); - const remainingPositionUnits = currentPositionUnits.sub(fromTokenQuantity); - const remainingPositionUnitsTooSmall = remainingPositionUnits.gt(0) && remainingPositionUnits.lt(50); + + const preImpliedNotional = currentPositionUnits.mul(setTotalSupply).div(SCALE); + const fromTokenNotional = fromTokenQuantity.mul(setTotalSupply).div(SCALE); + const difference = preImpliedNotional.sub(fromTokenNotional); + const remainingUnits = difference.mul(SCALE).div(setTotalSupply); + + const remainingPositionUnitsTooSmall = remainingUnits.gt(0) && remainingUnits.lt(50); if (remainingPositionUnitsTooSmall) { throw new Error('Remaining units too small, incorrectly attempting sell max'); @@ -547,6 +565,8 @@ export class TradeQuoter { } else if (isMax) { return impliedMaxNotional.toString(); } else { + // this rounds down. + // Could we just return amount here? const amountMulScaleOverTotalSupply = amount.mul(SCALE).div(totalSupply); return amountMulScaleOverTotalSupply.mul(totalSupply).div(SCALE); } From e889ab3ad1fd2ab4472bc1e4e97f60ac1295f3ba Mon Sep 17 00:00:00 2001 From: Dylan Tran Date: Thu, 19 May 2022 12:56:20 -0700 Subject: [PATCH 25/26] think we nailed the conversion of 0x resopnse to position units, but only need to apply it to the first trade for agiven from component in a batch --- src/api/utils/tradeQuoter.ts | 135 ++++++++++++++++++++++++++--------- src/types/utils.ts | 1 + 2 files changed, 103 insertions(+), 33 deletions(-) diff --git a/src/api/utils/tradeQuoter.ts b/src/api/utils/tradeQuoter.ts index 60d26ee..958f2a1 100644 --- a/src/api/utils/tradeQuoter.ts +++ b/src/api/utils/tradeQuoter.ts @@ -85,6 +85,8 @@ export class TradeQuoter { const feeRecipient = options.feeRecipient || this.feeRecipient; const excludedSources = options.excludedSources || this.excludedSources; + console.log('Generating Quote for Trade... Is First Trade?', options.isFirstTrade); + const exchangeAdapterName = ZERO_EX_ADAPTER_NAME; const { @@ -92,14 +94,12 @@ export class TradeQuoter { toTokenAddress, fromAddress, } = this.sanitizeAddress(options.fromToken, options.toToken, options.fromAddress); - console.log('raw amount', options.rawAmount); const amount = this.sanitizeAmount(options.rawAmount, options.fromTokenDecimals); const setOnChainDetails = await options.setToken.fetchSetDetailsAsync( fromAddress, [NULL_ADDRESS] ); - console.log('sanitized amount', amount.toString()); const fromTokenRequestAmount = this.calculateFromTokenAmount( setOnChainDetails, @@ -107,12 +107,11 @@ export class TradeQuoter { amount ); - console.log('from token request amount 1', fromTokenRequestAmount.toString()); - // This does not currently account for attempting "Max" sell (e.g. selling all USDC in Set) // across multiple trades. To do that, we would need to update the remaining position // quantities for each component being sold in the Set, so we can correctly detect // when a "Max" sell trade is being attempted. + const { fromTokenAmount, fromUnits, @@ -130,6 +129,7 @@ export class TradeQuoter { feeRecipient, excludedSources, feePercentage, + options.isFirstTrade, ); // Check that the trade data we return to front-end will not generate dust positions. @@ -310,21 +310,59 @@ export class TradeQuoter { * @param feePercentage The fee percentage to be paid to Set. Applied if isReceiveQuantity is true. */ private convertTotalSetQuantitiesToPerTokenQuantities( - tokenQuantity: string, - setTotalSupply: string, + tokenQuantity: BigNumber, + setTotalSupply: BigNumber, + isFirstTrade?: boolean, isReceiveQuantity?: boolean, slippagePercentage?: number, feePercentage?: number, ): BigNumber { + console.log('is first trade?', isFirstTrade); if (!isReceiveQuantity) { - const tokenAmountBD = new BigDecimal(tokenQuantity.toString()); - const scaleBD = new BigDecimal(SCALE.toString()); - const setTotalSupplyBD = new BigDecimal(setTotalSupply.toString()); + const initialNotionalQuantity = tokenQuantity; + const initialPositionQuantity = initialNotionalQuantity.mul(SCALE).div(setTotalSupply); + const positionMulTotalSupply = initialPositionQuantity.mul(setTotalSupply); + const finalNotionalQuantity = positionMulTotalSupply.div(SCALE); + + console.log('=================PARAMS======================='); + console.log('initial notional quantity', initialNotionalQuantity.toString()); + console.log('implied notional quantity', finalNotionalQuantity.toString()); + + if (isFirstTrade && finalNotionalQuantity.lt(initialNotionalQuantity)) { + console.log('FIXING THE FIRST TRADE'); + const fixedPositionQuantity = initialPositionQuantity.add(1); + const fixedPositionMulTotalSupply = fixedPositionQuantity.mul(setTotalSupply); + const fixedFinalNotional = fixedPositionMulTotalSupply.div(SCALE); - const tokenUnitsBD = tokenAmountBD.multiply(scaleBD).divide(setTotalSupplyBD, 10).ceil(); - return BigNumber.from(tokenUnitsBD.getValue()); + // console.log('fixed position quantity', fixedPositionQuantity.toString()); + // console.log('fixed position mul total supply', fixedPositionMulTotalSupply.toString()); + + console.log('fixed final notional quantity', fixedFinalNotional.toString()); + + if (fixedFinalNotional.eq(initialNotionalQuantity)) { + return fixedPositionQuantity; + } + + return initialPositionQuantity; + } + + return initialPositionQuantity; + // return BigNumber.from(tokenUnitsBD.getValue()); } + + + + // console.log('===================BIG DECIMAL OUTPUTS====================='); + + // const tokenAmountBD = new BigDecimal(tokenQuantity.toString()); + // const scaleBD = new BigDecimal(SCALE.toString()); + // const setTotalSupplyBD = new BigDecimal(setTotalSupply.toString()); + // const tokenUnitsBD = tokenAmountBD.multiply(scaleBD).divide(setTotalSupplyBD, 10).ceil(); + + // console.log('Big Decimal old value', tokenUnitsBD.getValue()); + + // If we are converting "buy" quantities, we need to account for a trade fee percentage // & slippage. We seem to lose some precisien merely multiplying the above // by slippageTolerance so we re-do the math in full here. @@ -347,7 +385,9 @@ export class TradeQuoter { feeRecipient: Address, excludedSources: string[], feePercentage: number, + isFirstTrade?: boolean, ) { + console.log('Fetching Zero Ex Quote for First Trade?', isFirstTrade); const manager = setOnChainDetails.manager; const setTotalSupply = (setOnChainDetails as any).totalSupply; @@ -357,8 +397,6 @@ export class TradeQuoter { zeroExApiUrls: this.zeroExApiUrls, }); - console.log('from token request amount', fromTokenRequestAmount.toString()); - const quote = await zeroEx.fetchTradeQuote( fromTokenAddress, toTokenAddress, @@ -372,19 +410,17 @@ export class TradeQuoter { (feePercentage / 100) ); + const positionForFromToken = setOnChainDetails .positions .find((p: any) => p.component.toLowerCase() === fromTokenAddress.toLowerCase()); const currentPositionUnits = BigNumber.from(positionForFromToken.unit); - console.log('current position units', currentPositionUnits.toString()); const fromTokenImpliedMaxPositionInSet = currentPositionUnits .mul(setTotalSupply) .div(SCALE.toString()); - console.log('implied max quantity', fromTokenImpliedMaxPositionInSet.toString()); - // If the trade quote returned form ZeroEx equals the target sell token's implied max // position in the Set, we simply return the components current position in the Set as the // "sell quantity" to be submitted to the Trade Module. @@ -394,22 +430,21 @@ export class TradeQuoter { const fromTokenAmount = quote.sellAmount; let fromUnits: BigNumber; - console.log('0x sell quantity quote', fromTokenAmount.toString()); - console.log('set total supply', setTotalSupply.toString()); if (fromTokenAmount.eq(fromTokenImpliedMaxPositionInSet)) { fromUnits = currentPositionUnits; } else { fromUnits = this.convertTotalSetQuantitiesToPerTokenQuantities( - fromTokenAmount.toString(), - setTotalSupply.toString(), + fromTokenAmount, + setTotalSupply, + isFirstTrade, ); - console.log('converted 0x sell quantity into Set position units', fromUnits.toString()); } const toTokenAmount = quote.buyAmount; const toUnits = this.convertTotalSetQuantitiesToPerTokenQuantities( - quote.buyAmount.toString(), - setTotalSupply.toString(), + quote.buyAmount, + setTotalSupply, + isFirstTrade, true, slippagePercentage, feePercentage, @@ -472,16 +507,19 @@ export class TradeQuoter { Object.keys(sellTokensPresentInMultipleTrades).forEach( (fromTokenAddress: Address) => { const totalSellQuantity = allSellQuantitiesByAddress[fromTokenAddress]; + const perTokenSellQuantity = this.convertTotalSetQuantitiesToPerTokenQuantities( - totalSellQuantity.toString(), + totalSellQuantity, (setOnChainDetails as any).totalSupply, ); - this.validateTradeDoesNotProduceDustPositions( - setOnChainDetails, - fromTokenAddress, - perTokenSellQuantity, - ); + perTokenSellQuantity; + + // this.validateTradeDoesNotProduceDustPositions( + // setOnChainDetails, + // fromTokenAddress, + // perTokenSellQuantity, + // ); } ); } @@ -516,6 +554,8 @@ export class TradeQuoter { const difference = preImpliedNotional.sub(fromTokenNotional); const remainingUnits = difference.mul(SCALE).div(setTotalSupply); + console.log('expected remaining units', remainingUnits.toString()); + const remainingPositionUnitsTooSmall = remainingUnits.gt(0) && remainingUnits.lt(50); if (remainingPositionUnitsTooSmall) { @@ -556,6 +596,8 @@ export class TradeQuoter { } const totalSupply = setOnChainDetails.totalSupply; + + // This is the same as on smart contract const impliedMaxNotional = positionForFromToken.unit.mul(totalSupply).div(SCALE); const isGreaterThanMax = amount.gt(impliedMaxNotional); const isMax = amount.eq(impliedMaxNotional); @@ -565,10 +607,37 @@ export class TradeQuoter { } else if (isMax) { return impliedMaxNotional.toString(); } else { - // this rounds down. - // Could we just return amount here? - const amountMulScaleOverTotalSupply = amount.mul(SCALE).div(totalSupply); - return amountMulScaleOverTotalSupply.mul(totalSupply).div(SCALE); + // Do we still want this rounded? + // const roundedAmount = amount.mul(SCALE).div(totalSupply).mul(totalSupply).div(SCALE); + // console.log('rounded amount', roundedAmount.toString()); + // return amount; + + // const targetAmount = BigNumber.from(amount.toString()); + // let baseAmount = BigNumber.from(amount.toString()); + + // let perSetAmount = baseAmount.mul(SCALE).div(totalSupply); + // let roundedAmount = totalSupply.mul(perSetAmount).div(SCALE); + + + // console.log('target amount is', targetAmount.toString()); + // console.log('initial per set amount', perSetAmount.toString()); + // console.log('initial rounded amount', roundedAmount.toString()); + + // while (roundedAmount.lt(targetAmount)) { + // baseAmount = baseAmount.add(1); + // perSetAmount = baseAmount.mul(SCALE).div(totalSupply); + // roundedAmount = totalSupply.mul(perSetAmount).div(SCALE); + + // console.log('========================================'); + // console.log('base ammount insufficient, adding total supply.'); + // console.log('now at', baseAmount.toString()); + // console.log('per set amount', perSetAmount.toString()); + // console.log('rounded amount is now', roundedAmount.toString()); + // } + + + // console.log('rounded amount', roundedAmount.toString()); + return amount; } } diff --git a/src/types/utils.ts b/src/types/utils.ts index 1a4006e..dbc9166 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -47,6 +47,7 @@ export type TradeQuoteOptions = { feePercentage?: number, feeRecipient?: Address, excludedSources?: string[], + isFirstTrade?: boolean, }; export type SwapQuoteOptions = { From 3dadcbc01a762d035efd95afd795027db8d80ef3 Mon Sep 17 00:00:00 2001 From: Dylan Tran Date: Thu, 19 May 2022 14:34:07 -0700 Subject: [PATCH 26/26] add for loop --- src/api/UtilsAPI.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/UtilsAPI.ts b/src/api/UtilsAPI.ts index d409474..cb2bb84 100644 --- a/src/api/UtilsAPI.ts +++ b/src/api/UtilsAPI.ts @@ -397,7 +397,8 @@ export default class UtilsAPI { fromAddress ); - for (const pair of orderPairs) { + for (let i = 0; i < orderPairs.length; i++) { + const pair = orderPairs[i]; const order = new Promise(async function (resolve, reject) { await new Promise(r => setTimeout(() => r(true), delay));