diff --git a/packages/asset-swapper/src/constants.ts b/packages/asset-swapper/src/constants.ts index c7dc690e32..d24ff0154a 100644 --- a/packages/asset-swapper/src/constants.ts +++ b/packages/asset-swapper/src/constants.ts @@ -1,6 +1,7 @@ import { BigNumber } from '@0x/utils'; import { + ExchangeProxyContractOpts, ExtensionContractType, ForwarderExtensionContractOpts, OrderPrunerOpts, @@ -20,6 +21,7 @@ const NULL_ADDRESS = '0x0000000000000000000000000000000000000000'; const MAINNET_CHAIN_ID = 1; const ONE_SECOND_MS = 1000; const DEFAULT_PER_PAGE = 1000; +const ZERO_AMOUNT = new BigNumber(0); const DEFAULT_ORDER_PRUNER_OPTS: OrderPrunerOpts = { expiryBufferMs: 120000, // 2 minutes @@ -60,8 +62,23 @@ const DEFAULT_FORWARDER_SWAP_QUOTE_GET_OPTS: SwapQuoteGetOutputOpts = { extensionContractOpts: DEFAULT_FORWARDER_EXTENSION_CONTRACT_OPTS, }; +const DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS: ExchangeProxyContractOpts = { + isFromETH: false, + isToETH: false, + affiliateFee: { + recipient: NULL_ADDRESS, + buyTokenFeeAmount: ZERO_AMOUNT, + sellTokenFeeAmount: ZERO_AMOUNT, + }, +}; + const DEFAULT_FORWARDER_SWAP_QUOTE_EXECUTE_OPTS: SwapQuoteExecutionOpts = DEFAULT_FORWARDER_SWAP_QUOTE_GET_OPTS; +const DEFAULT_EXCHANGE_PROXY_SWAP_QUOTE_GET_OPTS: SwapQuoteGetOutputOpts = { + useExtensionContract: ExtensionContractType.ExchangeProxy, + extensionContractOpts: DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS, +}; + const DEFAULT_SWAP_QUOTE_REQUEST_OPTS: SwapQuoteRequestOpts = { ...DEFAULT_GET_MARKET_ORDERS_OPTS, }; @@ -74,7 +91,7 @@ export const constants = { ETH_GAS_STATION_API_URL, PROTOCOL_FEE_MULTIPLIER, NULL_BYTES, - ZERO_AMOUNT: new BigNumber(0), + ZERO_AMOUNT, NULL_ADDRESS, MAINNET_CHAIN_ID, DEFAULT_ORDER_PRUNER_OPTS, @@ -85,6 +102,8 @@ export const constants = { DEFAULT_FORWARDER_SWAP_QUOTE_GET_OPTS, DEFAULT_FORWARDER_SWAP_QUOTE_EXECUTE_OPTS, DEFAULT_SWAP_QUOTE_REQUEST_OPTS, + DEFAULT_EXCHANGE_PROXY_SWAP_QUOTE_GET_OPTS, + DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS, DEFAULT_PER_PAGE, DEFAULT_RFQT_REQUEST_OPTS, NULL_ERC20_ASSET_DATA, diff --git a/packages/asset-swapper/src/index.ts b/packages/asset-swapper/src/index.ts index a7ffc0cf2c..1f75620bd2 100644 --- a/packages/asset-swapper/src/index.ts +++ b/packages/asset-swapper/src/index.ts @@ -40,6 +40,7 @@ export { InsufficientAssetLiquidityError } from './errors'; export { SwapQuoteConsumer } from './quote_consumers/swap_quote_consumer'; export { SwapQuoter } from './swap_quoter'; export { + AffiliateFee, CalldataInfo, ExchangeProxyContractOpts, ExtensionContractType, diff --git a/packages/asset-swapper/src/quote_consumers/exchange_proxy_swap_quote_consumer.ts b/packages/asset-swapper/src/quote_consumers/exchange_proxy_swap_quote_consumer.ts index 89dc4a95d6..5fb2c44f63 100644 --- a/packages/asset-swapper/src/quote_consumers/exchange_proxy_swap_quote_consumer.ts +++ b/packages/asset-swapper/src/quote_consumers/exchange_proxy_swap_quote_consumer.ts @@ -2,6 +2,7 @@ import { ContractAddresses } from '@0x/contract-addresses'; import { ITransformERC20Contract } from '@0x/contract-wrappers'; import { assetDataUtils, + encodeAffiliateFeeTransformerData, encodeFillQuoteTransformerData, encodePayTakerTransformerData, encodeWethTransformerData, @@ -18,6 +19,7 @@ import * as _ from 'lodash'; import { constants } from '../constants'; import { CalldataInfo, + ExchangeProxyContractOpts, MarketBuySwapQuote, MarketOperation, MarketSellSwapQuote, @@ -41,6 +43,7 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { wethTransformer: number; payTakerTransformer: number; fillQuoteTransformer: number; + affiliateFeeTransformer: number; }; private readonly _transformFeature: ITransformERC20Contract; @@ -70,6 +73,10 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { contractAddresses.transformers.fillQuoteTransformer, contractAddresses.exchangeProxyTransformerDeployer, ), + affiliateFeeTransformer: findTransformerNonce( + contractAddresses.transformers.affiliateFeeTransformer, + contractAddresses.exchangeProxyTransformerDeployer, + ), }; } @@ -78,14 +85,11 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { opts: Partial = {}, ): Promise { assert.isValidSwapQuote('quote', quote); - const { isFromETH, isToETH } = { - ...constants.DEFAULT_FORWARDER_SWAP_QUOTE_GET_OPTS, - extensionContractOpts: { - isFromETH: false, - isToETH: false, - }, - ...opts, - }.extensionContractOpts; + // tslint:disable-next-line:no-object-literal-type-assertion + const { affiliateFee, isFromETH, isToETH } = { + ...constants.DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS, + ...opts.extensionContractOpts, + } as ExchangeProxyContractOpts; const sellToken = getTokenFromAssetData(quote.takerAssetData); const buyToken = getTokenFromAssetData(quote.makerAssetData); @@ -129,6 +133,28 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { }); } + // This transformer pays affiliate fees. + const { buyTokenFeeAmount, sellTokenFeeAmount, recipient: feeRecipient } = affiliateFee; + + if (buyTokenFeeAmount.isGreaterThan(0) && feeRecipient !== NULL_ADDRESS) { + transforms.push({ + deploymentNonce: this.transformerNonces.affiliateFeeTransformer, + data: encodeAffiliateFeeTransformerData({ + fees: [ + { + token: isToETH ? ETH_TOKEN_ADDRESS : buyToken, + amount: buyTokenFeeAmount, + recipient: feeRecipient, + }, + ], + }), + }); + } + + if (sellTokenFeeAmount.isGreaterThan(0) && feeRecipient !== NULL_ADDRESS) { + throw new Error('Affiliate fees denominated in sell token are not yet supported'); + } + // The final transformer will send all funds to the taker. transforms.push({ deploymentNonce: this.transformerNonces.payTakerTransformer, @@ -138,12 +164,13 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { }), }); + const minBuyAmount = BigNumber.max(0, quote.worstCaseQuoteInfo.makerAssetAmount.minus(buyTokenFeeAmount)); const calldataHexString = this._transformFeature .transformERC20( isFromETH ? ETH_TOKEN_ADDRESS : sellToken, isToETH ? ETH_TOKEN_ADDRESS : buyToken, sellAmount, - quote.worstCaseQuoteInfo.makerAssetAmount, + minBuyAmount, transforms, ) .getABIEncodedTransactionData(); diff --git a/packages/asset-swapper/src/types.ts b/packages/asset-swapper/src/types.ts index dc496342b4..bd193ef406 100644 --- a/packages/asset-swapper/src/types.ts +++ b/packages/asset-swapper/src/types.ts @@ -124,13 +124,21 @@ export interface ForwarderExtensionContractOpts { feeRecipient: string; } +export interface AffiliateFee { + recipient: string; + buyTokenFeeAmount: BigNumber; + sellTokenFeeAmount: BigNumber; +} + /** * @param isFromETH Whether the input token is ETH. * @param isToETH Whether the output token is ETH. + * @param affiliateFee Fee denominated in taker or maker asset to send to specified recipient. */ export interface ExchangeProxyContractOpts { isFromETH: boolean; isToETH: boolean; + affiliateFee: AffiliateFee; } export type SwapQuote = MarketBuySwapQuote | MarketSellSwapQuote; diff --git a/packages/asset-swapper/src/utils/swap_quote_calculator.ts b/packages/asset-swapper/src/utils/swap_quote_calculator.ts index 47646007bd..23f406edd8 100644 --- a/packages/asset-swapper/src/utils/swap_quote_calculator.ts +++ b/packages/asset-swapper/src/utils/swap_quote_calculator.ts @@ -16,13 +16,7 @@ import { import { MarketOperationUtils } from './market_operation_utils'; import { convertNativeOrderToFullyFillableOptimizedOrders } from './market_operation_utils/orders'; -import { - FeeSchedule, - FillData, - GetMarketOrdersOpts, - OptimizedMarketOrder, - OptimizedOrdersAndQuoteReport, -} from './market_operation_utils/types'; +import { FeeSchedule, FillData, GetMarketOrdersOpts, OptimizedMarketOrder } from './market_operation_utils/types'; import { isSupportedAssetDataInOrders } from './utils'; import { QuoteReport } from './quote_report_generator'; diff --git a/packages/asset-swapper/test/exchange_proxy_swap_quote_consumer_test.ts b/packages/asset-swapper/test/exchange_proxy_swap_quote_consumer_test.ts index 156e09e905..03052506fc 100644 --- a/packages/asset-swapper/test/exchange_proxy_swap_quote_consumer_test.ts +++ b/packages/asset-swapper/test/exchange_proxy_swap_quote_consumer_test.ts @@ -2,6 +2,7 @@ import { getContractAddressesForChainOrThrow } from '@0x/contract-addresses'; import { constants as contractConstants, getRandomInteger, Numberish, randomAddress } from '@0x/contracts-test-utils'; import { assetDataUtils, + decodeAffiliateFeeTransformerData, decodeFillQuoteTransformerData, decodePayTakerTransformerData, decodeWethTransformerData, @@ -28,7 +29,7 @@ chaiSetup.configure(); const expect = chai.expect; const { NULL_ADDRESS } = constants; -const { MAX_UINT256 } = contractConstants; +const { MAX_UINT256, ZERO_AMOUNT } = contractConstants; // tslint:disable: custom-no-magic-numbers @@ -265,5 +266,37 @@ describe('ExchangeProxySwapQuoteConsumer', () => { expect(wethTransformerData.amount).to.bignumber.eq(MAX_UINT256); expect(wethTransformerData.token).to.eq(contractAddresses.etherToken); }); + it('Appends an affiliate fee transformer after the fill if a buy token affiliate feeĀ is provided', async () => { + const quote = getRandomSellQuote(); + const affiliateFee = { + recipient: randomAddress(), + buyTokenFeeAmount: getRandomAmount(), + sellTokenFeeAmount: ZERO_AMOUNT, + }; + const callInfo = await consumer.getCalldataOrThrowAsync(quote, { + extensionContractOpts: { affiliateFee }, + }); + const callArgs = callDataEncoder.decode(callInfo.calldataHexString) as CallArgs; + expect(callArgs.transformations[1].deploymentNonce.toNumber()).to.eq( + consumer.transformerNonces.affiliateFeeTransformer, + ); + const affiliateFeeTransformerData = decodeAffiliateFeeTransformerData(callArgs.transformations[1].data); + expect(affiliateFeeTransformerData.fees).to.deep.equal([ + { token: MAKER_TOKEN, amount: affiliateFee.buyTokenFeeAmount, recipient: affiliateFee.recipient }, + ]); + }); + it('Throws if a sell token affiliate fee is provided', async () => { + const quote = getRandomSellQuote(); + const affiliateFee = { + recipient: randomAddress(), + buyTokenFeeAmount: ZERO_AMOUNT, + sellTokenFeeAmount: getRandomAmount(), + }; + expect( + consumer.getCalldataOrThrowAsync(quote, { + extensionContractOpts: { affiliateFee }, + }), + ).to.eventually.be.rejectedWith('Affiliate fees denominated in sell token are not yet supported'); + }); }); }); diff --git a/packages/order-utils/src/transformer_data_encoders.ts b/packages/order-utils/src/transformer_data_encoders.ts index 00a7831f61..cb3bb17d11 100644 --- a/packages/order-utils/src/transformer_data_encoders.ts +++ b/packages/order-utils/src/transformer_data_encoders.ts @@ -183,5 +183,5 @@ export function encodeAffiliateFeeTransformerData(data: AffiliateFeeTransformerD * ABI-decode a `AffiliateFeeTransformer.TransformData` type. */ export function decodeAffiliateFeeTransformerData(encoded: string): AffiliateFeeTransformerData { - return affiliateFeeTransformerDataEncoder.decode(encoded).data; + return affiliateFeeTransformerDataEncoder.decode(encoded); }