Incorporating trading fees

Last modified:

Let ϕ[0,1)\phi \in [0,1) denote the pool fee. For example, if the pool fee is 0.3%0.3\%, then ϕ=0.003\phi = 0.003. Let xx and yy denote the pre-trade reserves of tokens XX and YY, respectively, so that

xy=k.x \cdot y = k.

Suppose a trader deposits a gross amount Δy\Delta y of token YY and receives an amount Δx\Delta x of token XX. The actual post-trade pool reserves will be (xΔx,  y+Δy)(x - \Delta x,\; y + \Delta y), because the trader sends the gross amount Δy\Delta y to the pool and withdraws Δx\Delta x. However, since the fee ϕ\phi is charged on the input side, only the effective amount (1ϕ)Δy(1-\phi)\Delta y participates in the constant-product update. The fee-adjusted swap condition is therefore

(xΔx)(y+(1ϕ)Δy)=xy=k.(x - \Delta x)\bigl(y + (1-\phi)\Delta y\bigr) = x \cdot y = k.

Expanding the left-hand side, we obtain

xy+x(1ϕ)ΔyyΔx(1ϕ)ΔxΔy=xy.xy + x(1-\phi)\Delta y - y\Delta x - (1-\phi)\Delta x\Delta y = xy.

Hence,

x(1ϕ)ΔyyΔx(1ϕ)ΔxΔy=0.x(1-\phi)\Delta y - y\Delta x - (1-\phi)\Delta x\Delta y = 0.

Solving for Δx\Delta x, we obtain

Δx=x(1ϕ)Δyy+(1ϕ)Δy.\Delta x = \frac{x(1-\phi)\Delta y}{y + (1-\phi)\Delta y}.

Thus, if a trader sends an input amount Δy\Delta y of token YY, the amount of token XX received is

Δx=x(1ϕ)Δyy+(1ϕ)Δy.\Delta x = \frac{x(1-\phi)\Delta y}{y + (1-\phi)\Delta y}.

Likewise, solving for Δy\Delta y, we obtain

Δy=yΔx(1ϕ)(xΔx).\Delta y = \frac{y\Delta x}{(1-\phi)(x-\Delta x)}.

This formula is the exact real-valued input. On-chain integer arithmetic applies rounding rules: getAmountOut floors the quotient, while getAmountIn returns floor division plus one wei (shown in the Solidity implementation below).

Therefore, if a trader wants to receive an output amount Δx\Delta x of token XX, the required gross input amount of token YY is

Δy=yΔx(1ϕ)(xΔx).\Delta y = \frac{y\Delta x}{(1-\phi)(x-\Delta x)}.

It can also be written in the canonical CenturionDEX v2 code-style notation:

reserveIn, reserveOut, amountIn, amountOut.\text{reserveIn},\ \text{reserveOut},\ \text{amountIn},\ \text{amountOut}.

Worked example (with fee):

x0=100, y0=100, Δy=50, ϕ=0.003, k=10000x_0 = 100,\ y_0 = 100,\ \Delta y = 50,\ \phi = 0.003,\ k = 10000 Δy=(1ϕ)Δy=49.85\Delta y' = (1-\phi)\Delta y = 49.85 xnew=ky0+Δy=10000149.8566.7334x_{\text{new}} = \frac{k}{y_0 + \Delta y'} = \frac{10000}{149.85} \approx 66.7334 Δx=x0xnew=10066.733433.2666\Delta x = x_0 - x_{\text{new}} = 100 - 66.7334 \approx 33.2666

Using the canonical notation introduced above, the correspondence between the implementation variables and our mathematical notation is

reserveIn=y,reserveOut=x,amountIn=Δy,amountOut=Δx.\texttt{reserveIn} = y,\qquad \texttt{reserveOut} = x,\qquad \texttt{amountIn} = \Delta y,\qquad \texttt{amountOut} = \Delta x.

Moreover, the code in the next block hardcodes a trading fee of 0.3%0.3\%. For this reason, the implementation uses the integer factors 997997 and 10001000 instead of writing the fee term as (1ϕ)(1-\phi) directly. This scaling is only a convenient way to avoid floating-point arithmetic in the smart contract; algebraically, it is equivalent to the same swap formula once the quotient is simplified.

// given an input amount of an asset and pair reserves, returns the maximum output amount of the other asset
function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
    require(amountIn > 0, 'CenturionV2Library: INSUFFICIENT_INPUT_AMOUNT');
    require(reserveIn > 0 && reserveOut > 0, 'CenturionV2Library: INSUFFICIENT_LIQUIDITY');
    uint amountInWithFee = amountIn.mul(997);
    uint numerator = amountInWithFee.mul(reserveOut);
    uint denominator = reserveIn.mul(1000).add(amountInWithFee);
    amountOut = numerator / denominator;
}
 
// given an output amount of an asset and pair reserves, returns a required input amount of the other asset
function getAmountIn(uint amountOut, uint reserveIn, uint reserveOut) internal pure returns (uint amountIn) {
    require(amountOut > 0, 'CenturionV2Library: INSUFFICIENT_OUTPUT_AMOUNT');
    require(reserveIn > 0 && reserveOut > 0, 'CenturionV2Library: INSUFFICIENT_LIQUIDITY');
    uint numerator = reserveIn.mul(amountOut).mul(1000);
    uint denominator = reserveOut.sub(amountOut).mul(997);
    amountIn = (numerator / denominator).add(1);
}

Note that getAmountIn returns (numerator / denominator) + 1. The +1 is a deliberate ceiling-style adjustment that ensures the post-swap balances satisfy the on-chain K check (balance0Adjusted * balance1Adjusted >= reserve0 * reserve1 * 1000^2) even when integer division truncates.

It is important to note that, after a swap, the pool reserves are updated as follows:

x=xΔx,y=y+Δy.x' = x - \Delta x,\qquad y' = y + \Delta y.

Accordingly, the product parameter is updated from kk to a new value k>0k' > 0, where

k=xy=(xΔx)(y+Δy).k' = x'y' = (x - \Delta x)(y + \Delta y).

Expanding this expression, we obtain

k=xy+xΔyyΔxΔxΔy=k+(xΔx)ΔyyΔx.k' = xy + x\Delta y - y\Delta x - \Delta x \Delta y = k + (x-\Delta x)\Delta y - y\Delta x.

From the fee-adjusted swap equation,

yΔx=(xΔx)(1ϕ)Δy,y\Delta x = (x-\Delta x)(1-\phi)\Delta y,

it follows that

k=k+ϕΔy(xΔx).k' = k + \phi \Delta y (x-\Delta x).

Therefore,

k=k+ϕΔy(xΔx).k' = k + \phi \Delta y (x-\Delta x).

In particular, if ϕ>0\phi > 0, then k>kk' > k. In other words, whenever the pool charges a positive fee, the product parameter increases slightly after each trade. This increase reflects the fact that part of the input amount remains in the pool and is captured by liquidity providers.

The spot price also changes after the swap. The new spot price of token XX in terms of token YY is

p=yx=y+ΔyxΔx.p' = \frac{y'}{x'} = \frac{y+\Delta y}{x-\Delta x}.

In summary, if the pool has no fees, that is, if ϕ=0\phi = 0, then k=kk' = k, and the pre-trade and post-trade reserve states lie on the same constant-product curve xy=kx*y=k. By contrast, if ϕ>0\phi > 0, then k>kk' > k, so the post-trade reserve state lies on a higher constant-product curve.

If the liquidity pool charges a positive fee ϕ\phi, then the product parameter increases after each trade. Let xx and yy denote the pool reserves before the trade, and suppose a trader sends a gross amount Δy\Delta y of token YY to the pool and receives an amount Δx\Delta x of token XX. Since the fee is charged on the input side, only the amount (1ϕ)Δy(1-\phi)\Delta y is effective for the swap calculation. Therefore, the fee-adjusted constant-product condition is

(xΔx)(y+(1ϕ)Δy)=k.(x-\Delta x)\bigl(y+(1-\phi)\Delta y\bigr)=k.

This is the natural canonical version of the same relation.

As shown above, the fee-adjusted swap condition implies that the effective post-trade state

(xΔx,  y+(1ϕ)Δy)\bigl(x-\Delta x,\; y+(1-\phi)\Delta y\bigr)

still lies on the original constant-product curve, since

(xΔx)(y+(1ϕ)Δy)=k.(x-\Delta x)\bigl(y+(1-\phi)\Delta y\bigr)=k.

This corresponds to the solid curve in the animation above. However, the actual pool reserves after the trade are

(x,y)=(xΔx,  y+Δy).(x',y')=\bigl(x-\Delta x,\; y+\Delta y\bigr).

Moreover,

(xΔx,  y+Δy)=(xΔx,  y+(1ϕ)Δy)+(0,ϕΔy).\bigl(x-\Delta x,\; y+\Delta y\bigr) = \bigl(x-\Delta x,\; y+(1-\phi)\Delta y\bigr) + \bigl(0,\phi\Delta y\bigr).

Thus, the actual post-trade reserve state is vertically above the fee-adjusted point by the amount of fees collected in token YY. As a result, the updated reserves no longer lie on the original curve xy=kx*y=k, but instead lie on a new constant-product curve with a larger product parameter. This is the dashed curve in the next animation:

Increase in the Average Execution Price

Rounding convention for this example: displayed numeric results are rounded to two decimals.

Consider a CenturionDEX v2 pool between CTN and USDC with trading fee ϕ=0.003\phi = 0.003. Let the initial reserves be

x0=8,000(CTN),y0=24,000,000(USDC).x_0 = 8{,}000 \quad \text{(CTN)}, \qquad y_0 = 24{,}000{,}000 \quad \text{(USDC)}.

The initial spot price is therefore

y0x0=3,000 USDC/CTN.\frac{y_0}{x_0} = 3{,}000 \ \text{USDC/CTN}.

Suppose a trader wants to buy a fixed amount

Δx=400\Delta x = 400

of token XX, that is, 400 CTN. Using the fee-adjusted output formula,

Δy=yΔx(1ϕ)(xΔx),\Delta y = \frac{y\Delta x}{(1-\phi)(x-\Delta x)},

the required USDC input for the first purchase is

Δy1=24,000,0004000.997(8,000400).\Delta y_1 = \frac{24{,}000{,}000 \cdot 400}{0.997(8{,}000-400)}.

Numerically, this gives

Δy11,266,958.77 USDC.\Delta y_1 \approx 1{,}266{,}958.77 \ \text{USDC}.

Hence, the average execution price of the first purchase is

pˉ1=Δy1Δx3,167.40 USDC/CTN.\bar p_1=\frac{\Delta y_1}{\Delta x}\approx 3{,}167.40 \ \text{USDC/CTN}.

So, buying 400 CTN initially costs about 1,266,958.77 USDC, which corresponds to an average price of about 3,167.40 USDC per CTN.

After this first trade, the pool reserves are updated to

x1=x0Δx=7,600,y1=y0+Δy125,266,958.77.x_1 = x_0-\Delta x = 7{,}600, \qquad y_1 = y_0+\Delta y_1 \approx 25{,}266{,}958.77.

Thus, after the first purchase, the pool contains 7,600 CTN and approximately 25,266,958.77 USDC.

Now suppose the trader wants to buy another 400 CTN. Applying the same formula again, but this time using the updated reserve state (x1,y1)(x_1,y_1), we obtain

Δy2=y1Δx(1ϕ)(x1Δx)=25,266,958.774000.997(7,600400).\Delta y_2 = \frac{y_1\Delta x}{(1-\phi)(x_1-\Delta x)} = \frac{25{,}266{,}958.77 \cdot 400}{0.997(7{,}600-400)}.

Therefore,

Δy21,407,943.76 USDC,\Delta y_2 \approx 1{,}407{,}943.76 \ \text{USDC},

and the average execution price of the second purchase is

pˉ2=Δy2Δx3,519.86 USDC/CTN.\bar p_2=\frac{\Delta y_2}{\Delta x}\approx 3{,}519.86 \ \text{USDC/CTN}.

So, the second 400-CTN purchase is more expensive than the first one: it requires about 1,407,943.76 USDC, which corresponds to an average price of about 3,519.86 USDC per CTN.

This illustrates a general feature of constant-product AMMs: repeated buys of the same asset push the average execution price upward. After each purchase, the reserve of token XX decreases while the reserve of token YY increases, so subsequent buyers face a less favorable point on the pricing curve.

More generally, if a trader buys an amount Δx\Delta x of token XX from a pool with current reserves (x,y)(x,y), then the total amount of token YY that must be paid is

Δy=yΔx(1ϕ)(xΔx).\Delta y=\frac{y\Delta x}{(1-\phi)(x-\Delta x)}.

Accordingly, the average execution price is

pˉ(Δx;x,y)=ΔyΔx=y(1ϕ)(xΔx).\bar p(\Delta x;x,y)=\frac{\Delta y}{\Delta x} = \frac{y}{(1-\phi)(x-\Delta x)}.

This expression makes the previous effect explicit: as xx becomes smaller and yy becomes larger after each buy, the average purchase price increases.