Skip to content

Commit 9ab5856

Browse files
feat: support sell token affiliate fees in calldata generation (0xProject#1254)
1 parent 83fcb26 commit 9ab5856

File tree

9 files changed

+172
-65
lines changed

9 files changed

+172
-65
lines changed

src/asset-swapper/constants.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,8 @@ const DEFAULT_SWAP_QUOTER_OPTS: SwapQuoterOpts = {
5858
const DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS: ExchangeProxyContractOpts = {
5959
isFromETH: false,
6060
isToETH: false,
61-
affiliateFees: [
62-
{
63-
feeType: AffiliateFeeType.None,
64-
recipient: NULL_ADDRESS,
65-
buyTokenFeeAmount: ZERO_AMOUNT,
66-
sellTokenFeeAmount: ZERO_AMOUNT,
67-
},
68-
],
61+
sellTokenAffiliateFees: [],
62+
buyTokenAffiliateFees: [],
6963
refundReceiver: NULL_ADDRESS,
7064
shouldSellEntireBalance: false,
7165
};

src/asset-swapper/quote_consumers/feature_rules/transform_erc20_rule.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,40 @@ export class TransformERC20Rule extends AbstractFeatureRule {
6565

6666
public createCalldata(quote: SwapQuote, opts: ExchangeProxyContractOpts): CalldataInfo {
6767
// TODO(kyu-c): further breakdown calldata creation logic.
68-
const { affiliateFees, positiveSlippageFee, isFromETH, isToETH, shouldSellEntireBalance } = opts;
68+
const {
69+
sellTokenAffiliateFees,
70+
buyTokenAffiliateFees,
71+
positiveSlippageFee,
72+
isFromETH,
73+
isToETH,
74+
shouldSellEntireBalance,
75+
} = opts;
6976

7077
const swapContext = this.getSwapContext(quote, opts);
7178
const { sellToken, buyToken, sellAmount, ethAmount } = swapContext;
7279
let minBuyAmount = swapContext.minBuyAmount;
7380

7481
// Build up the transformations.
7582
const transformations = [] as ERC20Transformation[];
83+
84+
// Create an AffiliateFeeTransformer if there are fees in sell token.
85+
// Must be before the FillQuoteTransformer.
86+
// Also prefer to take fees in ETH if possible, so must be before the WETH transformer.
87+
if (sellTokenAffiliateFees.length > 0) {
88+
transformations.push({
89+
deploymentNonce: this.transformerNonces.affiliateFeeTransformer,
90+
data: encodeAffiliateFeeTransformerData({
91+
fees: sellTokenAffiliateFees
92+
.filter((fee) => fee.sellTokenFeeAmount.gt(0))
93+
.map((fee) => ({
94+
token: isFromETH ? ETH_TOKEN_ADDRESS : sellToken,
95+
amount: fee.sellTokenFeeAmount,
96+
recipient: fee.recipient,
97+
})),
98+
}),
99+
});
100+
}
101+
76102
// Create a WETH wrapper if coming from ETH.
77103
// Don't add the wethTransformer to CELO. There is no wrap/unwrap logic for CELO.
78104
if (isFromETH && this.chainId !== ChainId.Celo) {
@@ -85,6 +111,7 @@ export class TransformERC20Rule extends AbstractFeatureRule {
85111
});
86112
}
87113

114+
// Add the FillQuoteTransformer (FQT), which will convert the sell token to the buy token.
88115
transformations.push(...this.createFillQuoteTransformations(quote, opts));
89116

90117
// Create a WETH unwrapper if going to ETH.
@@ -100,10 +127,10 @@ export class TransformERC20Rule extends AbstractFeatureRule {
100127
}
101128

102129
let gasOverhead = ZERO_AMOUNT;
103-
const fees = [...affiliateFees];
104-
positiveSlippageFee && fees.push(positiveSlippageFee); // Append positive slippage fee if present
105-
fees.forEach((fee) => {
106-
const { feeType, buyTokenFeeAmount, sellTokenFeeAmount, recipient: feeRecipient } = fee;
130+
const buyTokenFees = [...buyTokenAffiliateFees];
131+
positiveSlippageFee && buyTokenFees.push(positiveSlippageFee); // Append positive slippage fee if present
132+
buyTokenFees.forEach((fee) => {
133+
const { feeType, buyTokenFeeAmount, recipient: feeRecipient } = fee;
107134
if (feeRecipient === NULL_ADDRESS) {
108135
return;
109136
} else if (feeType === AffiliateFeeType.None) {
@@ -152,9 +179,6 @@ export class TransformERC20Rule extends AbstractFeatureRule {
152179
// Adjust the minimum buy amount by the fee.
153180
minBuyAmount = BigNumber.max(0, minBuyAmount.minus(buyTokenFeeAmount));
154181
}
155-
if (sellTokenFeeAmount.isGreaterThan(0)) {
156-
throw new Error('Affiliate fees denominated in sell token are not yet supported');
157-
}
158182
} else if (feeType === AffiliateFeeType.GaslessFee) {
159183
if (buyTokenFeeAmount.isGreaterThan(0)) {
160184
transformations.push({
@@ -172,9 +196,6 @@ export class TransformERC20Rule extends AbstractFeatureRule {
172196
// Adjust the minimum buy amount by the fee.
173197
minBuyAmount = BigNumber.max(0, minBuyAmount.minus(buyTokenFeeAmount));
174198
}
175-
if (sellTokenFeeAmount.isGreaterThan(0)) {
176-
throw new Error('Affiliate fees denominated in sell token are not yet supported');
177-
}
178199
} else {
179200
// A compile time check that we've handled all cases of feeType
180201
((_: never) => {

src/asset-swapper/quote_consumers/quote_consumer_utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ export function requiresTransformERC20(opts: ExchangeProxyContractOpts): boolean
204204
return true;
205205
}
206206
// Has an affiliate fee.
207-
if (opts.affiliateFees.some((f) => f.buyTokenFeeAmount.isGreaterThan(0) || f.sellTokenFeeAmount.isGreaterThan(0))) {
207+
const affiliateFees = [...opts.sellTokenAffiliateFees, ...opts.buyTokenAffiliateFees];
208+
if (affiliateFees.some((f) => f.buyTokenFeeAmount.gt(0) || f.sellTokenFeeAmount.gt(0))) {
208209
return true;
209210
}
210211

src/asset-swapper/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,8 @@ enum ExchangeProxyRefundReceiver {
196196
export interface ExchangeProxyContractOpts {
197197
isFromETH: boolean;
198198
isToETH: boolean;
199-
affiliateFees: readonly AffiliateFeeAmount[];
199+
sellTokenAffiliateFees: readonly AffiliateFeeAmount[];
200+
buyTokenAffiliateFees: readonly AffiliateFeeAmount[];
200201
positiveSlippageFee?: AffiliateFeeAmount; // TODO: use a different type to represent Positive Slippage Fee
201202
refundReceiver: string | ExchangeProxyRefundReceiver;
202203
metaTransactionVersion?: 'v1' | 'v2'; // Only present if this is a MetaTransaction

src/services/swap_service.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -364,18 +364,21 @@ export class SwapService implements ISwapService {
364364
const { protocolFeeInWeiAmount: protocolFee, gas: worstCaseGas } = swapQuote.worstCaseQuoteInfo;
365365
const { gasPrice, sourceBreakdown, quoteReport, extendedQuoteReportSources } = swapQuote;
366366

367-
const {
368-
gasCost: affiliateFeeGasCost,
369-
buyTokenFeeAmount,
370-
sellTokenFeeAmount,
371-
} = serviceUtils.getAffiliateFeeAmounts(swapQuote, affiliateFee);
367+
// Prepare Sell Token Fees
368+
const sellTokenFeeAmounts: AffiliateFeeAmount[] = [];
369+
370+
// Prepare Buy Token Fees
371+
const { gasCost: affiliateFeeGasCost, buyTokenFeeAmount } = serviceUtils.getBuyTokenFeeAmounts(
372+
swapQuote,
373+
affiliateFee,
374+
);
372375

373-
const affiliateFeeAmounts: AffiliateFeeAmount[] = [
376+
const buyTokenFeeAmounts: AffiliateFeeAmount[] = [
374377
{
375378
recipient: affiliateFee.recipient,
376379
feeType: affiliateFee.feeType,
377380
buyTokenFeeAmount,
378-
sellTokenFeeAmount,
381+
sellTokenFeeAmount: ZERO,
379382
},
380383
];
381384

@@ -416,7 +419,8 @@ export class SwapService implements ISwapService {
416419
isETHBuy,
417420
shouldSellEntireBalance,
418421
affiliateAddress,
419-
affiliateFeeAmounts,
422+
buyTokenFeeAmounts,
423+
sellTokenFeeAmounts,
420424
positiveSlippageFee,
421425
metaTransactionVersion,
422426
);
@@ -742,7 +746,8 @@ export class SwapService implements ISwapService {
742746
isToETH: boolean,
743747
shouldSellEntireBalance: boolean,
744748
affiliateAddress: string | undefined,
745-
affiliateFees: AffiliateFeeAmount[],
749+
buyTokenAffiliateFees: AffiliateFeeAmount[],
750+
sellTokenAffiliateFees: AffiliateFeeAmount[],
746751
positiveSlippageFee?: AffiliateFeeAmount,
747752
metaTransactionVersion?: 'v1' | 'v2',
748753
): SwapQuoteResponsePartialTransaction & { gasOverhead: BigNumber } {
@@ -751,7 +756,8 @@ export class SwapService implements ISwapService {
751756
isToETH,
752757
metaTransactionVersion,
753758
shouldSellEntireBalance,
754-
affiliateFees,
759+
buyTokenAffiliateFees,
760+
sellTokenAffiliateFees,
755761
positiveSlippageFee,
756762
};
757763

src/utils/service_utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export const serviceUtils = {
9595

9696
return [...singleSourceLiquiditySources, ...multihopLiquiditySources];
9797
},
98-
getAffiliateFeeAmounts(quote: SwapQuote, fee: AffiliateFee): AffiliateFeeAmounts {
98+
getBuyTokenFeeAmounts(quote: SwapQuote, fee: AffiliateFee): AffiliateFeeAmounts {
9999
if (fee.feeType === AffiliateFeeType.None || fee.recipient === NULL_ADDRESS || fee.recipient === '') {
100100
return {
101101
sellTokenFeeAmount: ZERO,

test/asset-swapper/quote_consumer/feature_rules/transform_erc20_rule_test.ts

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,97 @@ describe('TransformERC20Rule', () => {
203203
expect(wethTransformerData.token).to.eq(contractAddresses.etherToken);
204204
});
205205

206+
it('Appends an affiliate fee transformer before the FQT if sell token fees are specified', () => {
207+
const gasPrice = 20_000_000_000;
208+
const makerAmountPerEth = new BigNumber(2);
209+
const quote = createSimpleSellSwapQuoteWithBridgeOrder({
210+
source: ERC20BridgeSource.UniswapV2,
211+
takerToken: TAKER_TOKEN,
212+
makerToken: MAKER_TOKEN,
213+
takerAmount: ONE_ETHER,
214+
makerAmount: ONE_ETHER.times(2),
215+
makerAmountPerEth,
216+
gasPrice,
217+
slippage: 0,
218+
});
219+
const integratorRecipient = randomAddress();
220+
const sellTokenFeeAmount = ONE_ETHER.times(0.01);
221+
222+
const callInfo = rule.createCalldata(quote, {
223+
...constants.DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS,
224+
sellTokenAffiliateFees: [
225+
{
226+
recipient: integratorRecipient,
227+
buyTokenFeeAmount: ZERO_AMOUNT,
228+
sellTokenFeeAmount,
229+
feeType: AffiliateFeeType.PercentageFee,
230+
},
231+
],
232+
});
233+
234+
const callArgs = decodeTransformERC20(callInfo.calldataHexString);
235+
expect(getTransformerNonces(callArgs)).to.deep.eq(
236+
[NONCES.affiliateFeeTransformer, NONCES.fillQuoteTransformer, NONCES.payTakerTransformer],
237+
'Correct ordering of the transformers',
238+
);
239+
240+
const affiliateFeeTransformerData = decodeAffiliateFeeTransformerData(callArgs.transformations[0].data);
241+
expect(affiliateFeeTransformerData.fees).to.deep.equal(
242+
[{ token: TAKER_TOKEN, amount: sellTokenFeeAmount, recipient: integratorRecipient }],
243+
'Affiliate Fee',
244+
);
245+
});
246+
247+
it('Appends an AffiliateFeeTransformer before the WETH transformer and FQT and prefers ETH_TOKEN fee if isFromETH when sell token fees are present', () => {
248+
const gasPrice = 20_000_000_000;
249+
const makerAmountPerEth = new BigNumber(2);
250+
const quote = createSimpleSellSwapQuoteWithBridgeOrder({
251+
source: ERC20BridgeSource.UniswapV2,
252+
takerToken: TAKER_TOKEN,
253+
makerToken: MAKER_TOKEN,
254+
takerAmount: ONE_ETHER,
255+
makerAmount: ONE_ETHER.times(2),
256+
makerAmountPerEth,
257+
gasPrice,
258+
slippage: 0,
259+
});
260+
const integratorRecipient = randomAddress();
261+
const sellTokenFeeAmount = ONE_ETHER.times(0.01);
262+
263+
const callInfo = rule.createCalldata(quote, {
264+
...constants.DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS,
265+
sellTokenAffiliateFees: [
266+
{
267+
recipient: integratorRecipient,
268+
buyTokenFeeAmount: ZERO_AMOUNT,
269+
sellTokenFeeAmount,
270+
feeType: AffiliateFeeType.PercentageFee,
271+
},
272+
],
273+
isFromETH: true,
274+
});
275+
276+
const callArgs = decodeTransformERC20(callInfo.calldataHexString);
277+
expect(getTransformerNonces(callArgs)).to.deep.eq(
278+
[
279+
NONCES.affiliateFeeTransformer,
280+
NONCES.wethTransformer,
281+
NONCES.fillQuoteTransformer,
282+
NONCES.payTakerTransformer,
283+
],
284+
'Correct ordering of the transformers',
285+
);
286+
287+
const affiliateFeeTransformerData = decodeAffiliateFeeTransformerData(callArgs.transformations[0].data);
288+
expect(affiliateFeeTransformerData.fees).to.deep.equal(
289+
[{ token: ETH_TOKEN_ADDRESS, amount: sellTokenFeeAmount, recipient: integratorRecipient }],
290+
'Affiliate Fee',
291+
);
292+
const wethTransformerData = decodeWethTransformerData(callArgs.transformations[1].data);
293+
expect(wethTransformerData.amount).to.bignumber.eq(quote.worstCaseQuoteInfo.totalTakerAmount);
294+
expect(wethTransformerData.token).to.eq(ETH_TOKEN_ADDRESS);
295+
});
296+
206297
it('Appends an affiliate fee transformer when buyTokenFeeAmount is provided (Gasless)', () => {
207298
const recipient = randomAddress();
208299
const buyTokenFeeAmount = ONE_ETHER.times(0.01);
@@ -212,7 +303,7 @@ describe('TransformERC20Rule', () => {
212303

213304
{
214305
...constants.DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS,
215-
affiliateFees: [
306+
buyTokenAffiliateFees: [
216307
{
217308
recipient,
218309
buyTokenFeeAmount,
@@ -243,7 +334,7 @@ describe('TransformERC20Rule', () => {
243334

244335
const callInfo = rule.createCalldata(quote, {
245336
...constants.DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS,
246-
affiliateFees: [
337+
buyTokenAffiliateFees: [
247338
{
248339
recipient,
249340
buyTokenFeeAmount,
@@ -283,7 +374,7 @@ describe('TransformERC20Rule', () => {
283374

284375
const callInfo = rule.createCalldata(quote, {
285376
...constants.DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS,
286-
affiliateFees: [
377+
buyTokenAffiliateFees: [
287378
{
288379
recipient,
289380
buyTokenFeeAmount: ZERO_AMOUNT,
@@ -333,7 +424,7 @@ describe('TransformERC20Rule', () => {
333424

334425
const callInfo = rule.createCalldata(quote, {
335426
...constants.DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS,
336-
affiliateFees: [
427+
buyTokenAffiliateFees: [
337428
{
338429
recipient: integratorRecipient,
339430
buyTokenFeeAmount,
@@ -383,22 +474,6 @@ describe('TransformERC20Rule', () => {
383474
);
384475
});
385476

386-
it('Throws if a sell token affiliate fee is provided', () => {
387-
expect(() =>
388-
rule.createCalldata(UNI_V2_SELL_QUOTE, {
389-
...constants.DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS,
390-
affiliateFees: [
391-
{
392-
recipient: randomAddress(),
393-
buyTokenFeeAmount: ZERO_AMOUNT,
394-
sellTokenFeeAmount: getRandomAmount(),
395-
feeType: AffiliateFeeType.PercentageFee,
396-
},
397-
],
398-
}),
399-
).to.throw('Affiliate fees denominated in sell token are not yet supported');
400-
});
401-
402477
it('Uses two `FillQuoteTransformer`s when given a two-hop sell quote', () => {
403478
const quote = createTwoHopSellQuote({
404479
takerToken: TAKER_TOKEN,

0 commit comments

Comments
 (0)