Files
protocol/apps-node/rfq-api/test/services/fee_service_test.ts

1661 lines
61 KiB
TypeScript

import { BigNumber } from '@0x/utils';
import { anything, instance, mock, when } from 'ts-mockito';
import { DEFAULT_FEE_MODEL_CONFIGURATION, FeeModelConfiguration } from '../../src/config';
import {
BPS_TO_RATIO,
DEFAULT_MIN_EXPIRY_DURATION_MS,
RFQM_TX_OTC_ORDER_GAS_ESTIMATE,
RFQT_GAS_IMPROVEMENT,
RFQT_GAS_IMPROVEMENT_FALLBACK_GAS_PRICE,
ZERO,
} from '../../src/core/constants';
import {
calculateDefaultFeeAmount,
calculatePriceImprovementAmount,
reviseQuoteWithFees,
FeeService,
} from '../../src/services/fee_service';
import { QuoteContext } from '../../src/services/types';
import { FeeWithDetails, IndicativeQuote, TokenMetadata } from '../../src/core/types';
import { ConfigManager } from '../../src/utils/config_manager';
import { GasStationAttendantEthereum } from '../../src/utils/GasStationAttendantEthereum';
import { TokenPriceOracle } from '../../src/utils/TokenPriceOracle';
import { AmmQuote, ZeroExApiClient } from '../../src/utils/ZeroExApiClient';
const feeTokenSymbol = 'fee';
const feeTokenAddress = 'feeTokenAddress';
const feeTokenDecimals = 18;
const buildFeeService = (overrides: {
feeModelConfiguration?: FeeModelConfiguration;
gasPrice?: BigNumber;
tradeTokenPrice?: BigNumber | null;
feeTokenPrice?: BigNumber | null;
ammQuote?: AmmQuote | null;
chainId?: number;
feeTokenMetadata?: TokenMetadata;
configManagerMock?: ConfigManager;
gasStationAttendantMock?: GasStationAttendantEthereum;
tokenPriceOracleMock?: TokenPriceOracle;
zeroExApiClientMock?: ZeroExApiClient;
}): FeeService => {
const chainId = overrides?.chainId || 1337;
const feeTokenMetadata = overrides?.feeTokenMetadata || {
symbol: feeTokenSymbol,
decimals: feeTokenDecimals,
tokenAddress: feeTokenAddress,
};
const feeModelConfiguration = overrides?.feeModelConfiguration || DEFAULT_FEE_MODEL_CONFIGURATION;
const gasPrice = overrides?.gasPrice || new BigNumber(1e9);
const tradeTokenPrice = overrides?.tradeTokenPrice || null;
const feeTokenPrice = overrides?.feeTokenPrice || null;
const ammQuote = overrides?.ammQuote || null;
const configManagerMock = mock(ConfigManager);
when(configManagerMock.getFeeModelConfiguration(chainId, anything(), anything())).thenReturn(feeModelConfiguration);
const gasStationAttendantMock = mock(GasStationAttendantEthereum);
when(gasStationAttendantMock.getExpectedTransactionGasRateAsync()).thenResolve(gasPrice);
const tokenPriceOracleMock = mock(TokenPriceOracle);
when(tokenPriceOracleMock.batchFetchTokenPriceAsync(anything())).thenResolve([tradeTokenPrice, feeTokenPrice]);
const zeroExApiClientMock = mock(ZeroExApiClient);
when(zeroExApiClientMock.fetchAmmQuoteAsync(anything())).thenResolve(ammQuote);
return new FeeService(
chainId,
feeTokenMetadata,
instance(overrides?.configManagerMock || configManagerMock),
instance(overrides?.gasStationAttendantMock || gasStationAttendantMock),
instance(overrides?.tokenPriceOracleMock || tokenPriceOracleMock),
instance(overrides?.zeroExApiClientMock || zeroExApiClientMock),
DEFAULT_MIN_EXPIRY_DURATION_MS,
);
};
describe('FeeService', () => {
const workflow = 'rfqm';
const txOrigin = 'registryAddress';
const makerToken = 'UsdcAddress';
const makerTokenDecimals = 6;
const makerTokenPrice = new BigNumber(1e-6);
const takerToken = 'WbtcAddress';
const takerTokenDecimals = 18;
const takerTokenPrice = new BigNumber(6e-14);
const gasPrice = new BigNumber(1e9);
const gasEstimate = RFQM_TX_OTC_ORDER_GAS_ESTIMATE;
const feeTokenPrice = new BigNumber(3e-15);
const integrator = {
apiKeys: [],
integratorId: 'integratorId',
allowedChainIds: [1, 3, 137, 1337],
label: 'dummy integrator',
rfqm: true,
rfqt: true,
};
const takerAddress = 'takerAddress';
afterAll(() => {
jest.useRealTimers();
});
describe('calculateFeeAsync v0', () => {
const feeModelVersion = 0;
it('should calculate v0 fee for RFQm correctly', async () => {
// Given
const isSelling = true;
const isUnwrap = false;
const assetFillAmount = new BigNumber(0.345e18);
const tradeSizeBps = 5;
const feeService: FeeService = buildFeeService({
feeModelConfiguration: {
marginRakeRatio: 0,
tradeSizeBps,
},
gasPrice,
tradeTokenPrice: takerTokenPrice,
feeTokenPrice,
});
// When
const { feeWithDetails: fee } = await feeService.calculateFeeAsync({
workflow,
chainId: 1337,
feeModelVersion,
txOrigin,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
takerAmount: assetFillAmount,
isFirm: true,
takerAddress,
trader: takerAddress,
integrator,
});
// Then
const expectedGasFeeAmount = gasPrice.times(gasEstimate);
const expectedFee: FeeWithDetails = {
type: 'fixed',
token: feeTokenAddress,
amount: expectedGasFeeAmount,
details: {
kind: 'gasOnly',
feeModelVersion,
gasFeeAmount: expectedGasFeeAmount,
gasPrice,
},
breakdown: {
gas: {
amount: expectedGasFeeAmount,
details: {
gasPrice,
estimatedGas: new BigNumber(gasEstimate),
},
},
},
conversionRates: {
nativeTokenBaseUnitPriceUsd: null,
feeTokenBaseUnitPriceUsd: null,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: null,
},
};
expect(fee).toMatchObject(expectedFee);
});
it('should calculate v0 fee for RFQt correctly', async () => {
// Given
const isSelling = true;
const isUnwrap = false;
const assetFillAmount = new BigNumber(0.345e18);
const tradeSizeBps = 5;
const feeService: FeeService = buildFeeService({
feeModelConfiguration: {
marginRakeRatio: 0,
tradeSizeBps,
},
gasPrice,
tradeTokenPrice: takerTokenPrice,
feeTokenPrice,
});
// When
const { feeWithDetails: fee } = await feeService.calculateFeeAsync({
workflow: 'rfqt',
chainId: 1337,
feeModelVersion,
txOrigin,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
takerAmount: assetFillAmount,
isFirm: true,
takerAddress,
trader: takerAddress,
integrator,
});
// Then
const expectedFee: FeeWithDetails = {
type: 'fixed',
token: feeTokenAddress,
amount: new BigNumber(0),
details: {
kind: 'gasOnly',
feeModelVersion,
gasFeeAmount: new BigNumber(0),
gasPrice: new BigNumber(0),
},
breakdown: {},
conversionRates: {
nativeTokenBaseUnitPriceUsd: null,
feeTokenBaseUnitPriceUsd: null,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: null,
},
};
expect(fee).toMatchObject(expectedFee);
});
});
describe('calculateFeeAsync v1', () => {
const feeModelVersion = 1;
it('should calculate v1 fee for RFQm selling correctly', async () => {
// Given
const isSelling = true;
const isUnwrap = false;
const assetFillAmount = new BigNumber(0.345e18);
const tradeSizeBps = 5;
const feeService: FeeService = buildFeeService({
feeModelConfiguration: {
marginRakeRatio: 0,
tradeSizeBps,
},
gasPrice,
tradeTokenPrice: takerTokenPrice,
feeTokenPrice,
});
// When
const { feeWithDetails: fee } = await feeService.calculateFeeAsync({
workflow,
chainId: 1337,
feeModelVersion,
txOrigin,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
takerAmount: assetFillAmount,
isFirm: true,
takerAddress,
trader: takerAddress,
integrator,
});
// Then
const expectedGasFeeAmount = gasPrice.times(gasEstimate);
const expectedZeroExFeeAmount = assetFillAmount
.times(tradeSizeBps * BPS_TO_RATIO)
.times(takerTokenPrice)
.div(feeTokenPrice)
.integerValue();
const expectedTotalFeeAmount = expectedZeroExFeeAmount.plus(expectedGasFeeAmount);
const expectedFee: FeeWithDetails = {
type: 'fixed',
token: feeTokenAddress,
amount: expectedTotalFeeAmount,
details: {
kind: 'default',
feeModelVersion,
gasFeeAmount: expectedGasFeeAmount,
gasPrice,
zeroExFeeAmount: expectedZeroExFeeAmount,
tradeSizeBps,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: takerTokenPrice,
makerTokenBaseUnitPriceUsd: null,
},
breakdown: {
gas: {
amount: expectedGasFeeAmount,
details: {
gasPrice,
estimatedGas: new BigNumber(gasEstimate),
},
},
zeroEx: {
amount: expectedZeroExFeeAmount,
details: {
kind: 'volume',
tradeSizeBps,
},
},
},
conversionRates: {
nativeTokenBaseUnitPriceUsd: feeTokenPrice,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: takerTokenPrice,
makerTokenBaseUnitPriceUsd: null,
},
};
expect(fee).toMatchObject(expectedFee);
});
it('should calculate v1 fee for RFQm buying correctly', async () => {
// Given
const isSelling = false;
const isUnwrap = false;
const assetFillAmount = new BigNumber(5000e6);
const tradeSizeBps = 4;
const feeService: FeeService = buildFeeService({
feeModelConfiguration: {
marginRakeRatio: 0,
tradeSizeBps,
},
gasPrice,
tradeTokenPrice: makerTokenPrice,
feeTokenPrice,
});
// When
const { feeWithDetails: fee } = await feeService.calculateFeeAsync({
workflow,
chainId: 1337,
feeModelVersion,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
makerAmount: assetFillAmount,
isFirm: false,
takerAddress,
integrator,
});
// Then
const expectedGasFeeAmount = gasPrice.times(gasEstimate);
const expectedZeroExFeeAmount = assetFillAmount
.times(tradeSizeBps * BPS_TO_RATIO)
.times(makerTokenPrice)
.div(feeTokenPrice)
.integerValue();
const expectedTotalFeeAmount = expectedZeroExFeeAmount.plus(expectedGasFeeAmount);
const expectedFee: FeeWithDetails = {
type: 'fixed',
token: feeTokenAddress,
amount: expectedTotalFeeAmount,
details: {
kind: 'default',
feeModelVersion,
gasFeeAmount: expectedGasFeeAmount,
gasPrice,
zeroExFeeAmount: expectedZeroExFeeAmount,
tradeSizeBps,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: makerTokenPrice,
},
breakdown: {
gas: {
amount: expectedGasFeeAmount,
details: {
gasPrice,
estimatedGas: new BigNumber(gasEstimate),
},
},
zeroEx: {
amount: expectedZeroExFeeAmount,
details: {
kind: 'volume',
tradeSizeBps,
},
},
},
conversionRates: {
nativeTokenBaseUnitPriceUsd: feeTokenPrice,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: makerTokenPrice,
},
};
expect(fee).toMatchObject(expectedFee);
});
it('should calculate v1 fee for RFQt selling correctly', async () => {
// Given
const isSelling = true;
const isUnwrap = false;
const assetFillAmount = new BigNumber(0.345e18);
const tradeSizeBps = 5;
const feeService: FeeService = buildFeeService({
feeModelConfiguration: {
marginRakeRatio: 0,
tradeSizeBps,
},
gasPrice,
tradeTokenPrice: takerTokenPrice,
feeTokenPrice,
});
const rfqtFixedFee = RFQT_GAS_IMPROVEMENT_FALLBACK_GAS_PRICE.times(RFQT_GAS_IMPROVEMENT);
// When
const { feeWithDetails: fee } = await feeService.calculateFeeAsync({
workflow: 'rfqt',
chainId: 1337,
feeModelVersion,
txOrigin,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
takerAmount: assetFillAmount,
isFirm: true,
takerAddress,
trader: takerAddress,
integrator,
});
// Then
const expectedZeroExFeeAmount = assetFillAmount
.times(tradeSizeBps * BPS_TO_RATIO)
.times(takerTokenPrice)
.div(feeTokenPrice)
.plus(rfqtFixedFee) // TODO: may need to delete this line if removing the RFQT gas improvement fee
.integerValue();
const expectedFee: FeeWithDetails = {
type: 'fixed',
token: feeTokenAddress,
amount: expectedZeroExFeeAmount,
details: {
kind: 'default',
feeModelVersion,
gasFeeAmount: new BigNumber(0),
gasPrice: new BigNumber(0),
zeroExFeeAmount: expectedZeroExFeeAmount,
tradeSizeBps,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: takerTokenPrice,
makerTokenBaseUnitPriceUsd: null,
},
breakdown: {
zeroEx: {
amount: expectedZeroExFeeAmount,
details: {
kind: 'volume',
tradeSizeBps,
},
},
},
conversionRates: {
nativeTokenBaseUnitPriceUsd: feeTokenPrice,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: takerTokenPrice,
makerTokenBaseUnitPriceUsd: null,
},
};
expect(fee).toMatchObject(expectedFee);
});
it('should not include zeroEx fee for non-configured pairs', async () => {
// Given
const isSelling = true;
const isUnwrap = false;
const assetFillAmount = new BigNumber(0.345e18);
const feeService: FeeService = buildFeeService({
gasPrice,
tradeTokenPrice: takerTokenPrice,
feeTokenPrice,
});
// When
const { feeWithDetails: fee } = await feeService.calculateFeeAsync({
workflow,
chainId: 1337,
feeModelVersion,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
takerAmount: assetFillAmount,
isFirm: false,
takerAddress,
integrator,
});
// Then
const expectedGasFeeAmount = gasPrice.times(gasEstimate);
const expectedFee: FeeWithDetails = {
type: 'fixed',
token: feeTokenAddress,
amount: expectedGasFeeAmount,
details: {
kind: 'default',
feeModelVersion,
gasFeeAmount: expectedGasFeeAmount,
gasPrice,
zeroExFeeAmount: new BigNumber(0),
tradeSizeBps: 0,
feeTokenBaseUnitPriceUsd: null,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: null,
},
breakdown: {
gas: {
amount: expectedGasFeeAmount,
details: {
gasPrice,
estimatedGas: new BigNumber(gasEstimate),
},
},
zeroEx: {
amount: new BigNumber(0),
details: {
kind: 'volume',
tradeSizeBps: 0,
},
},
},
conversionRates: {
nativeTokenBaseUnitPriceUsd: null,
feeTokenBaseUnitPriceUsd: null,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: null,
},
};
expect(fee).toMatchObject(expectedFee);
});
it('should not include zeroEx fee if price oracle is down', async () => {
// Given
const isSelling = false;
const isUnwrap = false;
const assetFillAmount = new BigNumber(5000e6);
const tradeSizeBps = 4;
const feeService: FeeService = buildFeeService({
feeModelConfiguration: {
marginRakeRatio: 0,
tradeSizeBps,
},
gasPrice,
tradeTokenPrice: makerTokenPrice,
feeTokenPrice: null,
});
// When
const { feeWithDetails: fee } = await feeService.calculateFeeAsync({
workflow,
chainId: 1337,
feeModelVersion,
txOrigin,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
makerAmount: assetFillAmount,
isFirm: true,
takerAddress,
trader: takerAddress,
integrator,
});
// Then
const expectedGasFeeAmount = gasPrice.times(gasEstimate);
const expectedFee: FeeWithDetails = {
type: 'fixed',
token: feeTokenAddress,
amount: expectedGasFeeAmount,
details: {
kind: 'gasOnly',
feeModelVersion,
gasFeeAmount: expectedGasFeeAmount,
gasPrice,
},
breakdown: {
gas: {
amount: expectedGasFeeAmount,
details: {
gasPrice,
estimatedGas: new BigNumber(gasEstimate),
},
},
},
conversionRates: {
nativeTokenBaseUnitPriceUsd: null,
feeTokenBaseUnitPriceUsd: null,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: null,
},
};
expect(fee).toMatchObject(expectedFee);
});
});
describe('calculateFeeAsync v2', () => {
const feeModelVersion = 2;
it('should calculate v2 `price improvement` based fee for sell correctly if price improvement detection succeeded', async () => {
// Given
const isSelling = true;
const isUnwrap = false;
const assetFillAmount = new BigNumber(1e18);
const marginRakeRatio = 0.5;
const ammMakerAmount = new BigNumber(3450e6);
const expectedSlippage = new BigNumber(-0.01);
const estimatedAmmGasFeeWei = new BigNumber(100e9);
const decodedUniqueId = '1234-5678';
const ammQuote: AmmQuote = {
makerAmount: ammMakerAmount,
takerAmount: assetFillAmount,
expectedSlippage,
estimatedGasFeeWei: estimatedAmmGasFeeWei,
decodedUniqueId,
};
const mm1MakerAmount = new BigNumber(3550e6);
const mm2MakerAmount = new BigNumber(3600e6);
const mmQuotes: IndicativeQuote[] = [
{
maker: 'maker1Address',
makerUri: 'http://maker1.com',
makerToken,
takerToken,
makerAmount: mm1MakerAmount,
takerAmount: assetFillAmount,
expiry: new BigNumber(1652722767),
},
{
maker: 'maker2Address',
makerUri: 'http://maker2.com',
makerToken,
takerToken,
makerAmount: mm2MakerAmount,
takerAmount: assetFillAmount,
expiry: new BigNumber(1652722767),
},
];
const feeService: FeeService = buildFeeService({
feeModelConfiguration: {
marginRakeRatio,
tradeSizeBps: 0,
},
gasPrice,
tradeTokenPrice: makerTokenPrice,
feeTokenPrice,
ammQuote,
});
const quoteContext: QuoteContext = {
workflow,
chainId: 1337,
isFirm: true,
feeModelVersion,
txOrigin,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
takerAmount: assetFillAmount,
takerAddress,
trader: takerAddress,
integrator,
};
// When
jest.useFakeTimers().setSystemTime(1650000000000);
const { feeWithDetails, quotesWithGasFee, ammQuoteUniqueId } = await feeService.calculateFeeAsync(
quoteContext,
async () => {
return Promise.resolve(mmQuotes);
},
);
// Then
const expectedGasFeeAmount = gasPrice.times(gasEstimate);
const expectedMargin = mm2MakerAmount
.minus(ammMakerAmount.times(new BigNumber(1).plus(expectedSlippage)))
.times(makerTokenPrice)
.div(feeTokenPrice)
.plus(estimatedAmmGasFeeWei)
.integerValue();
const expectedZeroExFeeAmount = expectedMargin.times(marginRakeRatio).integerValue();
const expectedTotalFeeAmount = expectedZeroExFeeAmount.plus(expectedGasFeeAmount);
const expectedFee: FeeWithDetails = {
type: 'fixed',
token: feeTokenAddress,
amount: expectedTotalFeeAmount,
details: {
kind: 'margin',
feeModelVersion,
gasFeeAmount: expectedGasFeeAmount,
gasPrice,
zeroExFeeAmount: expectedZeroExFeeAmount,
margin: expectedMargin,
marginRakeRatio,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: makerTokenPrice,
},
breakdown: {
gas: {
amount: expectedGasFeeAmount,
details: {
gasPrice,
estimatedGas: new BigNumber(gasEstimate),
},
},
zeroEx: {
amount: expectedZeroExFeeAmount,
details: {
kind: 'price_improvement',
priceImprovement: expectedMargin,
rakeRatio: marginRakeRatio,
},
},
},
conversionRates: {
nativeTokenBaseUnitPriceUsd: feeTokenPrice,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: makerTokenPrice,
},
};
expect(feeWithDetails).toMatchObject(expectedFee);
expect(quotesWithGasFee).toMatchObject(mmQuotes);
expect(ammQuoteUniqueId).toBe(decodedUniqueId);
// When
const revisedQuotes = await feeService.reviseQuotesAsync(
// $eslint-fix-me https://github.com/rhinodavid/eslint-fix-me
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
quotesWithGasFee!,
expectedZeroExFeeAmount,
quoteContext,
);
// Then
const expectedRevisedQuotes = mmQuotes.map((quote) =>
reviseQuoteWithFees(quote, expectedZeroExFeeAmount, isSelling, makerTokenPrice, feeTokenPrice),
);
expect(revisedQuotes).toMatchObject(expectedRevisedQuotes);
});
it('should calculate v2 `price improvement` based fee for buy correctly if price improvement detection succeeded', async () => {
// Given
const isSelling = false;
const isUnwrap = false;
const assetFillAmount = new BigNumber(1e18);
const marginRakeRatio = 0.4;
const ammTakerAmount = new BigNumber(3450e6);
const expectedSlippage = new BigNumber(-0.1);
const estimatedAmmGasFeeWei = new BigNumber(100e9);
const decodedUniqueId = '1234-5678';
const ammQuote: AmmQuote = {
makerAmount: assetFillAmount,
takerAmount: ammTakerAmount,
expectedSlippage,
estimatedGasFeeWei: estimatedAmmGasFeeWei,
decodedUniqueId,
};
const mm1TakerAmount = new BigNumber(3400e6);
const mm2TakerAmount = new BigNumber(3350e6);
const mmQuotes: IndicativeQuote[] = [
{
maker: 'maker1Address',
makerUri: 'http://maker1.com',
makerToken,
takerToken,
makerAmount: assetFillAmount,
takerAmount: mm1TakerAmount,
expiry: new BigNumber(1652722767),
},
{
maker: 'maker2Address',
makerUri: 'http://maker2.com',
makerToken,
takerToken,
makerAmount: assetFillAmount,
takerAmount: mm2TakerAmount,
expiry: new BigNumber(1652722767),
},
];
const feeService: FeeService = buildFeeService({
feeModelConfiguration: {
marginRakeRatio,
tradeSizeBps: 0,
},
gasPrice,
tradeTokenPrice: takerTokenPrice,
feeTokenPrice,
ammQuote,
});
const quoteContext: QuoteContext = {
workflow,
chainId: 1337,
feeModelVersion,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
makerAmount: assetFillAmount,
isFirm: false,
takerAddress,
integrator,
};
// When
jest.useFakeTimers().setSystemTime(1650000000000);
const { feeWithDetails, quotesWithGasFee, ammQuoteUniqueId } = await feeService.calculateFeeAsync(
quoteContext,
async () => {
return Promise.resolve(mmQuotes);
},
);
// Then
const expectedGasFeeAmount = gasPrice.times(gasEstimate);
const expectedMargin = ammTakerAmount
.times(new BigNumber(1).minus(expectedSlippage))
.minus(mm2TakerAmount)
.times(takerTokenPrice)
.div(feeTokenPrice)
.plus(estimatedAmmGasFeeWei)
.integerValue();
const expectedZeroExFeeAmount = expectedMargin.times(marginRakeRatio).integerValue();
const expectedTotalFeeAmount = expectedZeroExFeeAmount.plus(expectedGasFeeAmount);
const expectedFee: FeeWithDetails = {
type: 'fixed',
token: feeTokenAddress,
amount: expectedTotalFeeAmount,
details: {
kind: 'margin',
feeModelVersion,
gasFeeAmount: expectedGasFeeAmount,
gasPrice,
zeroExFeeAmount: expectedZeroExFeeAmount,
margin: expectedMargin,
marginRakeRatio,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: takerTokenPrice,
makerTokenBaseUnitPriceUsd: null,
},
breakdown: {
gas: {
amount: expectedGasFeeAmount,
details: {
gasPrice,
estimatedGas: new BigNumber(gasEstimate),
},
},
zeroEx: {
amount: expectedZeroExFeeAmount,
details: {
kind: 'price_improvement',
priceImprovement: expectedMargin,
rakeRatio: marginRakeRatio,
},
},
},
conversionRates: {
nativeTokenBaseUnitPriceUsd: feeTokenPrice,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: takerTokenPrice,
makerTokenBaseUnitPriceUsd: null,
},
};
expect(feeWithDetails).toMatchObject(expectedFee);
expect(quotesWithGasFee).toMatchObject(mmQuotes);
expect(ammQuoteUniqueId).toBe(decodedUniqueId);
// When
const revisedQuotes = await feeService.reviseQuotesAsync(
// $eslint-fix-me https://github.com/rhinodavid/eslint-fix-me
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
quotesWithGasFee!,
expectedZeroExFeeAmount,
quoteContext,
);
// Then
const expectedRevisedQuotes = mmQuotes.map((quote) =>
reviseQuoteWithFees(quote, expectedZeroExFeeAmount, isSelling, takerTokenPrice, feeTokenPrice),
);
expect(revisedQuotes).toMatchObject(expectedRevisedQuotes);
});
it('should calculate v2 `default` fee correctly if token price query succeeded but 0x-api query failed', async () => {
// Given
const isSelling = true;
const isUnwrap = false;
const assetFillAmount = new BigNumber(1e18);
const marginRakeRatio = 0.5;
const tradeSizeBps = 5;
const ammQuote = null;
const mm1MakerAmount = new BigNumber(3550e6);
const mm2MakerAmount = new BigNumber(3600e6);
const mmQuotes: IndicativeQuote[] = [
{
maker: 'maker1Address',
makerUri: 'http://maker1.com',
makerToken,
takerToken,
makerAmount: mm1MakerAmount,
takerAmount: assetFillAmount,
expiry: new BigNumber(1652722767),
},
{
maker: 'maker2Address',
makerUri: 'http://maker2.com',
makerToken,
takerToken,
makerAmount: mm2MakerAmount,
takerAmount: assetFillAmount,
expiry: new BigNumber(1652722767),
},
];
const feeService: FeeService = buildFeeService({
feeModelConfiguration: {
marginRakeRatio,
tradeSizeBps,
},
gasPrice,
tradeTokenPrice: makerTokenPrice,
feeTokenPrice,
ammQuote,
});
const quoteContext: QuoteContext = {
workflow,
chainId: 1337,
feeModelVersion,
txOrigin,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
takerAmount: assetFillAmount,
isFirm: true,
takerAddress,
trader: takerAddress,
integrator,
};
// When
jest.useFakeTimers().setSystemTime(1650000000000);
const { feeWithDetails, quotesWithGasFee, ammQuoteUniqueId } = await feeService.calculateFeeAsync(
quoteContext,
async () => {
return Promise.resolve(mmQuotes);
},
);
// Then
const expectedGasFeeAmount = gasPrice.times(gasEstimate);
const expectedZeroExFeeAmount = calculateDefaultFeeAmount(
mm2MakerAmount,
tradeSizeBps,
makerTokenPrice,
feeTokenPrice,
);
const expectedTotalFeeAmount = expectedZeroExFeeAmount.plus(expectedGasFeeAmount);
const expectedFee: FeeWithDetails = {
type: 'fixed',
token: feeTokenAddress,
amount: expectedTotalFeeAmount,
details: {
kind: 'default',
feeModelVersion,
gasFeeAmount: expectedGasFeeAmount,
gasPrice,
zeroExFeeAmount: expectedZeroExFeeAmount,
tradeSizeBps,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: makerTokenPrice,
},
breakdown: {
gas: {
amount: expectedGasFeeAmount,
details: {
gasPrice,
estimatedGas: new BigNumber(gasEstimate),
},
},
zeroEx: {
amount: expectedZeroExFeeAmount,
details: {
kind: 'volume',
tradeSizeBps,
},
},
},
conversionRates: {
nativeTokenBaseUnitPriceUsd: feeTokenPrice,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: makerTokenPrice,
},
};
expect(feeWithDetails).toMatchObject(expectedFee);
expect(quotesWithGasFee).toMatchObject(mmQuotes);
expect(ammQuoteUniqueId).toBe(undefined);
// When
const revisedQuotes = await feeService.reviseQuotesAsync(
// $eslint-fix-me https://github.com/rhinodavid/eslint-fix-me
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
quotesWithGasFee!,
expectedZeroExFeeAmount,
quoteContext,
);
// Then
const expectedRevisedQuotes = mmQuotes.map((quote) =>
reviseQuoteWithFees(quote, expectedZeroExFeeAmount, isSelling, makerTokenPrice, feeTokenPrice),
);
expect(revisedQuotes).toMatchObject(expectedRevisedQuotes);
});
it('should calculate v2 `gasOnly` fee correctly if token price query failed', async () => {
// Given
const isSelling = true;
const isUnwrap = false;
const assetFillAmount = new BigNumber(1e18);
const marginRakeRatio = 0.5;
const tradeSizeBps = 5;
const ammMakerAmount = new BigNumber(3450e6);
const expectedSlippage = new BigNumber(-0.01);
const estimatedAmmGasFeeWei = new BigNumber(100e9);
const decodedUniqueId = '1234-5678';
const ammQuote: AmmQuote = {
makerAmount: ammMakerAmount,
takerAmount: assetFillAmount,
expectedSlippage,
estimatedGasFeeWei: estimatedAmmGasFeeWei,
decodedUniqueId,
};
const mm1MakerAmount = new BigNumber(3550e6);
const mm2MakerAmount = new BigNumber(3600e6);
const mmQuotes: IndicativeQuote[] = [
{
maker: 'maker1Address',
makerUri: 'http://maker1.com',
makerToken,
takerToken,
makerAmount: mm1MakerAmount,
takerAmount: assetFillAmount,
expiry: new BigNumber(1652722767),
},
{
maker: 'maker2Address',
makerUri: 'http://maker2.com',
makerToken,
takerToken,
makerAmount: mm2MakerAmount,
takerAmount: assetFillAmount,
expiry: new BigNumber(1652722767),
},
];
const feeService: FeeService = buildFeeService({
feeModelConfiguration: {
marginRakeRatio,
tradeSizeBps,
},
gasPrice,
tradeTokenPrice: makerTokenPrice,
feeTokenPrice: null,
ammQuote,
});
// When
jest.useFakeTimers().setSystemTime(1650000000000);
const { feeWithDetails, quotesWithGasFee, ammQuoteUniqueId } = await feeService.calculateFeeAsync(
{
workflow,
chainId: 1337,
feeModelVersion,
txOrigin,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
takerAmount: assetFillAmount,
isFirm: true,
takerAddress,
trader: takerAddress,
integrator,
},
async () => {
return Promise.resolve(mmQuotes);
},
);
// Then
const expectedGasFeeAmount = gasPrice.times(gasEstimate);
const expectedFee: FeeWithDetails = {
type: 'fixed',
token: feeTokenAddress,
amount: expectedGasFeeAmount,
details: {
kind: 'gasOnly',
feeModelVersion,
gasFeeAmount: expectedGasFeeAmount,
gasPrice,
},
breakdown: {
gas: {
amount: expectedGasFeeAmount,
details: {
gasPrice,
estimatedGas: new BigNumber(gasEstimate),
},
},
},
conversionRates: {
nativeTokenBaseUnitPriceUsd: null,
feeTokenBaseUnitPriceUsd: null,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: null,
},
};
expect(feeWithDetails).toMatchObject(expectedFee);
expect(quotesWithGasFee).toMatchObject(mmQuotes);
expect(ammQuoteUniqueId).toBe(decodedUniqueId);
});
it('should calculate v2 `price improvement` based fee with zero zeroExFee if price improvement is zero', async () => {
// Given
const isSelling = true;
const isUnwrap = false;
const assetFillAmount = new BigNumber(1e18);
const marginRakeRatio = 0.5;
const ammMakerAmount = new BigNumber(4000e6);
const expectedSlippage = new BigNumber(-0.01);
const estimatedAmmGasFeeWei = new BigNumber(100e9);
const decodedUniqueId = '1234-5678';
const ammQuote: AmmQuote = {
makerAmount: ammMakerAmount,
takerAmount: assetFillAmount,
expectedSlippage,
estimatedGasFeeWei: estimatedAmmGasFeeWei,
decodedUniqueId,
};
const mm1MakerAmount = new BigNumber(3550e6);
const mm2MakerAmount = new BigNumber(3600e6);
const mmQuotes: IndicativeQuote[] = [
{
maker: 'maker1Address',
makerUri: 'http://maker1.com',
makerToken,
takerToken,
makerAmount: mm1MakerAmount,
takerAmount: assetFillAmount,
expiry: new BigNumber(1652722767),
},
{
maker: 'maker2Address',
makerUri: 'http://maker2.com',
makerToken,
takerToken,
makerAmount: mm2MakerAmount,
takerAmount: assetFillAmount,
expiry: new BigNumber(1652722767),
},
];
const feeService: FeeService = buildFeeService({
feeModelConfiguration: {
marginRakeRatio,
tradeSizeBps: 0,
},
gasPrice,
tradeTokenPrice: makerTokenPrice,
feeTokenPrice,
ammQuote,
});
// When
jest.useFakeTimers().setSystemTime(1650000000000);
const { feeWithDetails, quotesWithGasFee, ammQuoteUniqueId } = await feeService.calculateFeeAsync(
{
workflow,
chainId: 1337,
feeModelVersion,
txOrigin,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
takerAmount: assetFillAmount,
isFirm: true,
takerAddress,
trader: takerAddress,
integrator,
},
async () => {
return Promise.resolve(mmQuotes);
},
);
// Then
const expectedGasFeeAmount = gasPrice.times(gasEstimate);
const expectedFee: FeeWithDetails = {
type: 'fixed',
token: feeTokenAddress,
amount: expectedGasFeeAmount,
details: {
kind: 'margin',
feeModelVersion,
gasFeeAmount: expectedGasFeeAmount,
gasPrice,
zeroExFeeAmount: ZERO,
margin: ZERO,
marginRakeRatio,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: makerTokenPrice,
},
breakdown: {
gas: {
amount: expectedGasFeeAmount,
details: {
gasPrice,
estimatedGas: new BigNumber(gasEstimate),
},
},
zeroEx: {
amount: ZERO,
details: {
kind: 'price_improvement',
priceImprovement: ZERO,
rakeRatio: marginRakeRatio,
},
},
},
conversionRates: {
nativeTokenBaseUnitPriceUsd: feeTokenPrice,
feeTokenBaseUnitPriceUsd: feeTokenPrice,
takerTokenBaseUnitPriceUsd: null,
makerTokenBaseUnitPriceUsd: makerTokenPrice,
},
};
expect(feeWithDetails).toMatchObject(expectedFee);
expect(quotesWithGasFee).toMatchObject(mmQuotes);
expect(ammQuoteUniqueId).toBe(decodedUniqueId);
});
it('should throw if called from RFQt workflow', async () => {
// Given
const isSelling = true;
const isUnwrap = false;
const assetFillAmount = new BigNumber(1e18);
const marginRakeRatio = 0.5;
const ammMakerAmount = new BigNumber(3450e6);
const expectedSlippage = new BigNumber(-0.01);
const estimatedAmmGasFeeWei = new BigNumber(100e9);
const decodedUniqueId = '1234-5678';
const ammQuote: AmmQuote = {
makerAmount: ammMakerAmount,
takerAmount: assetFillAmount,
expectedSlippage,
estimatedGasFeeWei: estimatedAmmGasFeeWei,
decodedUniqueId,
};
const feeService: FeeService = buildFeeService({
feeModelConfiguration: {
marginRakeRatio,
tradeSizeBps: 0,
},
gasPrice,
tradeTokenPrice: makerTokenPrice,
feeTokenPrice,
ammQuote,
});
const quoteContext: QuoteContext = {
workflow: 'rfqt',
chainId: 1337,
isFirm: true,
feeModelVersion,
txOrigin,
makerToken,
takerToken,
originalMakerToken: makerToken,
makerTokenDecimals,
takerTokenDecimals,
isUnwrap,
isSelling,
assetFillAmount,
takerAmount: assetFillAmount,
takerAddress,
trader: takerAddress,
integrator,
};
// When
await expect(() => feeService.calculateFeeAsync(quoteContext)).rejects.toThrow('Not implemented');
});
});
describe('pure function calculateDefaultFeeAmount()', () => {
it('should calculate default fee amount correctly', async () => {
// Given
const tradeTokenAmount = new BigNumber(1e18);
const feeRateBps = 5;
const tradeTokenBaseUnitPriceUsd = new BigNumber(6e-14);
const feeTokenBaseUnitPriceUsd = new BigNumber(3e-15);
// When
const defaultFeeAmount = calculateDefaultFeeAmount(
tradeTokenAmount,
feeRateBps,
tradeTokenBaseUnitPriceUsd,
feeTokenBaseUnitPriceUsd,
);
// Then
const expectedDefaultFeeAmount = new BigNumber(1e16);
expect(defaultFeeAmount).toMatchObject(expectedDefaultFeeAmount);
});
it('should return zero if bps is zero', async () => {
// Given
const tradeTokenAmount = new BigNumber(1e18);
const feeRateBps = 0;
const tradeTokenBaseUnitPriceUsd = new BigNumber(6e-14);
const feeTokenBaseUnitPriceUsd = new BigNumber(3e-15);
// When
const defaultFeeAmount = calculateDefaultFeeAmount(
tradeTokenAmount,
feeRateBps,
tradeTokenBaseUnitPriceUsd,
feeTokenBaseUnitPriceUsd,
);
// Then
expect(defaultFeeAmount).toMatchObject(ZERO);
});
it('should return zero if either trade token price or fee token price is null', async () => {
// Given
const tradeTokenAmount = new BigNumber(1e18);
const feeRateBps = 5;
const tradeTokenBaseUnitPriceUsd = new BigNumber(6e-14);
const feeTokenBaseUnitPriceUsd = new BigNumber(3e-15);
// When
const defaultFeeAmount1 = calculateDefaultFeeAmount(
tradeTokenAmount,
feeRateBps,
null,
feeTokenBaseUnitPriceUsd,
);
const defaultFeeAmount2 = calculateDefaultFeeAmount(
tradeTokenAmount,
feeRateBps,
tradeTokenBaseUnitPriceUsd,
null,
);
// Then
expect(defaultFeeAmount1).toMatchObject(ZERO);
expect(defaultFeeAmount2).toMatchObject(ZERO);
});
});
describe('pure function calculatePriceImprovementAmount()', () => {
it('should calculate price improvement amount for selling correctly', async () => {
// Given
const isSelling = true;
const assetFillAmount = new BigNumber(3e17);
const makerQuoteWithGasFee: IndicativeQuote = {
maker: 'maker1Address',
makerUri: 'http://maker1.com',
makerToken,
takerToken,
makerAmount: new BigNumber(1100e6),
takerAmount: assetFillAmount,
expiry: new BigNumber(1652722767),
};
const ammQuote: AmmQuote = {
makerAmount: new BigNumber(1000e6),
takerAmount: assetFillAmount,
expectedSlippage: new BigNumber(-0.02),
estimatedGasFeeWei: new BigNumber(10e15),
};
const quoteTokenBaseUnitPriceUsd = new BigNumber(1e-6);
const feeTokenBaseUnitPriceUsd = new BigNumber(3e-15);
// When
const priceImprovementAmount = calculatePriceImprovementAmount(
makerQuoteWithGasFee,
ammQuote,
isSelling,
quoteTokenBaseUnitPriceUsd,
feeTokenBaseUnitPriceUsd,
);
// Then
const expectedPriceImprovementAmount = new BigNumber(50e15);
expect(priceImprovementAmount).toMatchObject(expectedPriceImprovementAmount);
});
it('should calculate price improvement amount for buying correctly', async () => {
// Given
const isSelling = false;
const assetFillAmount = new BigNumber(3e17);
const makerQuoteWithGasFee: IndicativeQuote = {
maker: 'maker1Address',
makerUri: 'http://maker1.com',
makerToken,
takerToken,
makerAmount: assetFillAmount,
takerAmount: new BigNumber(900e6),
expiry: new BigNumber(1652722767),
};
const ammQuote: AmmQuote = {
makerAmount: assetFillAmount,
takerAmount: new BigNumber(1000e6),
expectedSlippage: new BigNumber(-0.02),
estimatedGasFeeWei: new BigNumber(10e15),
};
const quoteTokenBaseUnitPriceUsd = new BigNumber(1e-6);
const feeTokenBaseUnitPriceUsd = new BigNumber(3e-15);
// When
const priceImprovementAmount = calculatePriceImprovementAmount(
makerQuoteWithGasFee,
ammQuote,
isSelling,
quoteTokenBaseUnitPriceUsd,
feeTokenBaseUnitPriceUsd,
);
// Then
const expectedPriceImprovementAmount = new BigNumber(50e15);
expect(priceImprovementAmount).toMatchObject(expectedPriceImprovementAmount);
});
it('should return zero if there is no price improvement', async () => {
// Given
const isSelling = false;
const assetFillAmount = new BigNumber(3e17);
const makerQuoteWithGasFee: IndicativeQuote = {
maker: 'maker1Address',
makerUri: 'http://maker1.com',
makerToken,
takerToken,
makerAmount: assetFillAmount,
takerAmount: new BigNumber(1051e6),
expiry: new BigNumber(1652722767),
};
const ammQuote: AmmQuote = {
makerAmount: assetFillAmount,
takerAmount: new BigNumber(1000e6),
expectedSlippage: new BigNumber(-0.02),
estimatedGasFeeWei: new BigNumber(10e15),
};
const quoteTokenBaseUnitPriceUsd = new BigNumber(1e-6);
const feeTokenBaseUnitPriceUsd = new BigNumber(3e-15);
// When
const priceImprovementAmount = calculatePriceImprovementAmount(
makerQuoteWithGasFee,
ammQuote,
isSelling,
quoteTokenBaseUnitPriceUsd,
feeTokenBaseUnitPriceUsd,
);
// Then
expect(priceImprovementAmount).toMatchObject(ZERO);
});
});
describe('pure function reviseQuoteWithZeroExFee()', () => {
it('should revise quote correctly for selling', async () => {
// Given
const isSelling = true;
const assetFillAmount = new BigNumber(3e17);
const makerQuoteWithGasFee: IndicativeQuote = {
maker: 'maker1Address',
makerUri: 'http://maker1.com',
makerToken,
takerToken,
makerAmount: new BigNumber(1000e6),
takerAmount: assetFillAmount,
expiry: new BigNumber(1652722767),
};
const zeroExFeeAmount = new BigNumber(10e15);
const quoteTokenBaseUnitPriceUsd = new BigNumber(1e-6);
const feeTokenBaseUnitPriceUsd = new BigNumber(3e-15);
// When
const revisedQuote = reviseQuoteWithFees(
makerQuoteWithGasFee,
zeroExFeeAmount,
isSelling,
quoteTokenBaseUnitPriceUsd,
feeTokenBaseUnitPriceUsd,
);
// Then
const expectedRevisedMakerAmount = new BigNumber(970e6);
expect(revisedQuote.makerAmount).toMatchObject(expectedRevisedMakerAmount);
});
it('should revise quote correctly for buying', async () => {
// Given
const isSelling = false;
const assetFillAmount = new BigNumber(3e17);
const makerQuoteWithGasFee: IndicativeQuote = {
maker: 'maker1Address',
makerUri: 'http://maker1.com',
makerToken,
takerToken,
makerAmount: assetFillAmount,
takerAmount: new BigNumber(1000e6),
expiry: new BigNumber(1652722767),
};
const zeroExFeeAmount = new BigNumber(10e15);
const quoteTokenBaseUnitPriceUsd = new BigNumber(1e-6);
const feeTokenBaseUnitPriceUsd = new BigNumber(3e-15);
// When
const revisedQuote = reviseQuoteWithFees(
makerQuoteWithGasFee,
zeroExFeeAmount,
isSelling,
quoteTokenBaseUnitPriceUsd,
feeTokenBaseUnitPriceUsd,
);
// Then
const expectedRevisedTakerAmount = new BigNumber(1030e6);
expect(revisedQuote.takerAmount).toMatchObject(expectedRevisedTakerAmount);
});
it('should not revise quote correctly for zero zeroExFee', async () => {
// Given
const isSelling = true;
const assetFillAmount = new BigNumber(3e17);
const makerQuoteWithGasFee: IndicativeQuote = {
maker: 'maker1Address',
makerUri: 'http://maker1.com',
makerToken,
takerToken,
makerAmount: new BigNumber(1000e6),
takerAmount: assetFillAmount,
expiry: new BigNumber(1652722767),
};
const zeroExFeeAmount = ZERO;
const quoteTokenBaseUnitPriceUsd = new BigNumber(1e-6);
const feeTokenBaseUnitPriceUsd = new BigNumber(3e-15);
// When
const revisedQuote = reviseQuoteWithFees(
makerQuoteWithGasFee,
zeroExFeeAmount,
isSelling,
quoteTokenBaseUnitPriceUsd,
feeTokenBaseUnitPriceUsd,
);
// Then
const expectedRevisedMakerAmount = new BigNumber(1000e6);
expect(revisedQuote.makerAmount).toMatchObject(expectedRevisedMakerAmount);
expect(revisedQuote.takerAmount).toMatchObject(assetFillAmount);
});
});
});