@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 {
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,

View File

@ -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,

View File

@ -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<SwapQuoteGetOutputOpts> = {},
): Promise<CalldataInfo> {
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();

View File

@ -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;

View File

@ -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';

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 {
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');
});
});
});

View File

@ -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);
}