@0x/asset-swapper: Add affiliate fee support to EP swap quote consumer

This commit is contained in:
Michael Zhu 2020-08-05 22:39:21 -07:00
parent d5180d3ebc
commit 341c5782e5
7 changed files with 101 additions and 19 deletions

View File

@ -1,6 +1,7 @@
import { BigNumber } from '@0x/utils'; import { BigNumber } from '@0x/utils';
import { import {
ExchangeProxyContractOpts,
ExtensionContractType, ExtensionContractType,
ForwarderExtensionContractOpts, ForwarderExtensionContractOpts,
OrderPrunerOpts, OrderPrunerOpts,
@ -20,6 +21,7 @@ const NULL_ADDRESS = '0x0000000000000000000000000000000000000000';
const MAINNET_CHAIN_ID = 1; const MAINNET_CHAIN_ID = 1;
const ONE_SECOND_MS = 1000; const ONE_SECOND_MS = 1000;
const DEFAULT_PER_PAGE = 1000; const DEFAULT_PER_PAGE = 1000;
const ZERO_AMOUNT = new BigNumber(0);
const DEFAULT_ORDER_PRUNER_OPTS: OrderPrunerOpts = { const DEFAULT_ORDER_PRUNER_OPTS: OrderPrunerOpts = {
expiryBufferMs: 120000, // 2 minutes expiryBufferMs: 120000, // 2 minutes
@ -60,8 +62,23 @@ const DEFAULT_FORWARDER_SWAP_QUOTE_GET_OPTS: SwapQuoteGetOutputOpts = {
extensionContractOpts: DEFAULT_FORWARDER_EXTENSION_CONTRACT_OPTS, 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_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 = { const DEFAULT_SWAP_QUOTE_REQUEST_OPTS: SwapQuoteRequestOpts = {
...DEFAULT_GET_MARKET_ORDERS_OPTS, ...DEFAULT_GET_MARKET_ORDERS_OPTS,
}; };
@ -74,7 +91,7 @@ export const constants = {
ETH_GAS_STATION_API_URL, ETH_GAS_STATION_API_URL,
PROTOCOL_FEE_MULTIPLIER, PROTOCOL_FEE_MULTIPLIER,
NULL_BYTES, NULL_BYTES,
ZERO_AMOUNT: new BigNumber(0), ZERO_AMOUNT,
NULL_ADDRESS, NULL_ADDRESS,
MAINNET_CHAIN_ID, MAINNET_CHAIN_ID,
DEFAULT_ORDER_PRUNER_OPTS, DEFAULT_ORDER_PRUNER_OPTS,
@ -85,6 +102,8 @@ export const constants = {
DEFAULT_FORWARDER_SWAP_QUOTE_GET_OPTS, DEFAULT_FORWARDER_SWAP_QUOTE_GET_OPTS,
DEFAULT_FORWARDER_SWAP_QUOTE_EXECUTE_OPTS, DEFAULT_FORWARDER_SWAP_QUOTE_EXECUTE_OPTS,
DEFAULT_SWAP_QUOTE_REQUEST_OPTS, DEFAULT_SWAP_QUOTE_REQUEST_OPTS,
DEFAULT_EXCHANGE_PROXY_SWAP_QUOTE_GET_OPTS,
DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS,
DEFAULT_PER_PAGE, DEFAULT_PER_PAGE,
DEFAULT_RFQT_REQUEST_OPTS, DEFAULT_RFQT_REQUEST_OPTS,
NULL_ERC20_ASSET_DATA, NULL_ERC20_ASSET_DATA,

View File

@ -40,6 +40,7 @@ export { InsufficientAssetLiquidityError } from './errors';
export { SwapQuoteConsumer } from './quote_consumers/swap_quote_consumer'; export { SwapQuoteConsumer } from './quote_consumers/swap_quote_consumer';
export { SwapQuoter } from './swap_quoter'; export { SwapQuoter } from './swap_quoter';
export { export {
AffiliateFee,
CalldataInfo, CalldataInfo,
ExchangeProxyContractOpts, ExchangeProxyContractOpts,
ExtensionContractType, ExtensionContractType,

View File

@ -2,6 +2,7 @@ import { ContractAddresses } from '@0x/contract-addresses';
import { ITransformERC20Contract } from '@0x/contract-wrappers'; import { ITransformERC20Contract } from '@0x/contract-wrappers';
import { import {
assetDataUtils, assetDataUtils,
encodeAffiliateFeeTransformerData,
encodeFillQuoteTransformerData, encodeFillQuoteTransformerData,
encodePayTakerTransformerData, encodePayTakerTransformerData,
encodeWethTransformerData, encodeWethTransformerData,
@ -18,6 +19,7 @@ import * as _ from 'lodash';
import { constants } from '../constants'; import { constants } from '../constants';
import { import {
CalldataInfo, CalldataInfo,
ExchangeProxyContractOpts,
MarketBuySwapQuote, MarketBuySwapQuote,
MarketOperation, MarketOperation,
MarketSellSwapQuote, MarketSellSwapQuote,
@ -41,6 +43,7 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase {
wethTransformer: number; wethTransformer: number;
payTakerTransformer: number; payTakerTransformer: number;
fillQuoteTransformer: number; fillQuoteTransformer: number;
affiliateFeeTransformer: number;
}; };
private readonly _transformFeature: ITransformERC20Contract; private readonly _transformFeature: ITransformERC20Contract;
@ -70,6 +73,10 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase {
contractAddresses.transformers.fillQuoteTransformer, contractAddresses.transformers.fillQuoteTransformer,
contractAddresses.exchangeProxyTransformerDeployer, contractAddresses.exchangeProxyTransformerDeployer,
), ),
affiliateFeeTransformer: findTransformerNonce(
contractAddresses.transformers.affiliateFeeTransformer,
contractAddresses.exchangeProxyTransformerDeployer,
),
}; };
} }
@ -78,14 +85,11 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase {
opts: Partial<SwapQuoteGetOutputOpts> = {}, opts: Partial<SwapQuoteGetOutputOpts> = {},
): Promise<CalldataInfo> { ): Promise<CalldataInfo> {
assert.isValidSwapQuote('quote', quote); assert.isValidSwapQuote('quote', quote);
const { isFromETH, isToETH } = { // tslint:disable-next-line:no-object-literal-type-assertion
...constants.DEFAULT_FORWARDER_SWAP_QUOTE_GET_OPTS, const { affiliateFee, isFromETH, isToETH } = {
extensionContractOpts: { ...constants.DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS,
isFromETH: false, ...opts.extensionContractOpts,
isToETH: false, } as ExchangeProxyContractOpts;
},
...opts,
}.extensionContractOpts;
const sellToken = getTokenFromAssetData(quote.takerAssetData); const sellToken = getTokenFromAssetData(quote.takerAssetData);
const buyToken = getTokenFromAssetData(quote.makerAssetData); 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. // The final transformer will send all funds to the taker.
transforms.push({ transforms.push({
deploymentNonce: this.transformerNonces.payTakerTransformer, 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 const calldataHexString = this._transformFeature
.transformERC20( .transformERC20(
isFromETH ? ETH_TOKEN_ADDRESS : sellToken, isFromETH ? ETH_TOKEN_ADDRESS : sellToken,
isToETH ? ETH_TOKEN_ADDRESS : buyToken, isToETH ? ETH_TOKEN_ADDRESS : buyToken,
sellAmount, sellAmount,
quote.worstCaseQuoteInfo.makerAssetAmount, minBuyAmount,
transforms, transforms,
) )
.getABIEncodedTransactionData(); .getABIEncodedTransactionData();

View File

@ -124,13 +124,21 @@ export interface ForwarderExtensionContractOpts {
feeRecipient: string; feeRecipient: string;
} }
export interface AffiliateFee {
recipient: string;
buyTokenFeeAmount: BigNumber;
sellTokenFeeAmount: BigNumber;
}
/** /**
* @param isFromETH Whether the input token is ETH. * @param isFromETH Whether the input token is ETH.
* @param isToETH Whether the output 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 { export interface ExchangeProxyContractOpts {
isFromETH: boolean; isFromETH: boolean;
isToETH: boolean; isToETH: boolean;
affiliateFee: AffiliateFee;
} }
export type SwapQuote = MarketBuySwapQuote | MarketSellSwapQuote; export type SwapQuote = MarketBuySwapQuote | MarketSellSwapQuote;

View File

@ -16,13 +16,7 @@ import {
import { MarketOperationUtils } from './market_operation_utils'; import { MarketOperationUtils } from './market_operation_utils';
import { convertNativeOrderToFullyFillableOptimizedOrders } from './market_operation_utils/orders'; import { convertNativeOrderToFullyFillableOptimizedOrders } from './market_operation_utils/orders';
import { import { FeeSchedule, FillData, GetMarketOrdersOpts, OptimizedMarketOrder } from './market_operation_utils/types';
FeeSchedule,
FillData,
GetMarketOrdersOpts,
OptimizedMarketOrder,
OptimizedOrdersAndQuoteReport,
} from './market_operation_utils/types';
import { isSupportedAssetDataInOrders } from './utils'; import { isSupportedAssetDataInOrders } from './utils';
import { QuoteReport } from './quote_report_generator'; import { QuoteReport } from './quote_report_generator';

View File

@ -2,6 +2,7 @@ import { getContractAddressesForChainOrThrow } from '@0x/contract-addresses';
import { constants as contractConstants, getRandomInteger, Numberish, randomAddress } from '@0x/contracts-test-utils'; import { constants as contractConstants, getRandomInteger, Numberish, randomAddress } from '@0x/contracts-test-utils';
import { import {
assetDataUtils, assetDataUtils,
decodeAffiliateFeeTransformerData,
decodeFillQuoteTransformerData, decodeFillQuoteTransformerData,
decodePayTakerTransformerData, decodePayTakerTransformerData,
decodeWethTransformerData, decodeWethTransformerData,
@ -28,7 +29,7 @@ chaiSetup.configure();
const expect = chai.expect; const expect = chai.expect;
const { NULL_ADDRESS } = constants; const { NULL_ADDRESS } = constants;
const { MAX_UINT256 } = contractConstants; const { MAX_UINT256, ZERO_AMOUNT } = contractConstants;
// tslint:disable: custom-no-magic-numbers // tslint:disable: custom-no-magic-numbers
@ -265,5 +266,37 @@ describe('ExchangeProxySwapQuoteConsumer', () => {
expect(wethTransformerData.amount).to.bignumber.eq(MAX_UINT256); expect(wethTransformerData.amount).to.bignumber.eq(MAX_UINT256);
expect(wethTransformerData.token).to.eq(contractAddresses.etherToken); 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');
});
}); });
}); });

View File

@ -183,5 +183,5 @@ export function encodeAffiliateFeeTransformerData(data: AffiliateFeeTransformerD
* ABI-decode a `AffiliateFeeTransformer.TransformData` type. * ABI-decode a `AffiliateFeeTransformer.TransformData` type.
*/ */
export function decodeAffiliateFeeTransformerData(encoded: string): AffiliateFeeTransformerData { export function decodeAffiliateFeeTransformerData(encoded: string): AffiliateFeeTransformerData {
return affiliateFeeTransformerDataEncoder.decode(encoded).data; return affiliateFeeTransformerDataEncoder.decode(encoded);
} }