Skip to content

Dynamic fees

Most AMMs charge a flat fee on every swap. ClearPortX instead charges a two-component fee: a fixed base fee plus a variable dynamic fee that grows with realized volatility. This page documents the complete mechanism as implemented in production.

Canton structurally prevents MEV — there is no public mempool, and the sequencer only sees encrypted payloads. The usual motivation for dynamic fees (penalizing sandwich attacks) does not apply here.

ClearPortX uses dynamic fees for LP income optimization: during volatile periods, liquidity providers bear more impermanent loss. A higher fee during those periods compensates them proportionally, keeping liquidity present when the market needs it most. During calm periods, the variable fee drops to near zero and trading is cheap.

ftotal=min ⁣(fbase+fvariable,  10%)f_{total} = \min\!\left(f_{base} + f_{variable},\; 10\%\right)

The 10% hard cap prevents runaway fees under any condition.

The base fee is a fixed parameter stored in the pool configuration at creation. It does not change between swaps:

fbase=pool.feeRatef_{base} = \texttt{pool.feeRate}

Typical values range from 0.05% (stablecoin pairs) to 0.30% (volatile pairs). The pool creator selects this value based on the expected volatility profile of the pair.

The variable fee is quadratic in the volatility accumulator:

fvariable=min ⁣(va2s2,  2%)f_{variable} = \min\!\left(v_a^2 \cdot s^2,\; 2\%\right)

where:

  • vav_a is the volatility accumulator — a value that tracks how much trading activity has occurred recently
  • ss is the bin step (e.g., 0.01 for 1% bin spacing, 0.005 for 0.5%)

The quadratic relationship is the key design choice: doubling the volatility accumulator quadruples the variable fee. This creates a natural brake on rapid, large trades while keeping small calm trades essentially free of variable fees.

For a CC/cBTC pool with bin_step = 0.01:

Scenariovav_afvariablef_{variable}ftotalf_{total} (0.30% base)
Single swap, cold start1.00.01%0.31%
Rapid follow-up swap (under 1s)1.50.0225%0.32%
Burst of 3 rapid swaps2.50.0625%0.36%
Five rapid swaps4.00.16%0.46%
Ten rapid swaps8.00.64%0.94%

The accumulator has two components:

  1. Volatility reference (vrv_r) — a persistent value that carries forward between swaps and decays over time
  2. Instantaneous bins crossed (kk) — the number of bins traversed by the current swap

The accumulator for a given swap is:

va=vr+kv_a = v_r + k

where kk is always at least 1 (even a single-bin swap contributes 1 to the accumulator).

The reference vrv_r decays between swaps based on elapsed time, using three regimes:

Elapsed time (tt)ConditionEffect
t<tft < t_f (filter period)High-frequency stackingvrv_r unchanged. Rapid swaps accumulate volatility without relief.
tft<tdt_f \leq t < t_d (decay zone)Gradual decayvr=Rvr,prevv_r = R \cdot v_{r,\text{prev}} where RR is the decay factor
ttdt \geq t_d (full reset)Complete cool-downvr=0v_r = 0. The system returns to baseline.

Default parameters (configurable per pool):

ParameterDefaultPurpose
filter_period_ms1,000 msSwaps faster than this stack without decay
decay_period_ms10,000 msAfter this duration, full reset
decay_factor0.5How much volatility survives from one swap to the next

The following sequence illustrates the accumulator behavior:

Swap 1 (cold start): t=0s v_r=0 → v_a=0+1=1 → fee=0.31%
Swap 2 (200ms later): t=0.2s v_r=1 → v_a=1+1=2 → fee=0.34%
↑ within filter period: v_r carries forward without decay
Swap 3 (300ms later): t=0.5s v_r=2 → v_a=2+1=3 → fee=0.39%
↑ still within filter: stacking continues
Swap 4 (2s later): t=2.5s v_r=1.5 → v_a=1.5+1=2.5 → fee=0.36%
↑ past filter, in decay zone: v_r decayed by 0.5×
Swap 5 (12s later): t=14.5s v_r=0 → v_a=0+1=1 → fee=0.31%
↑ past decay period: full reset to baseline

The volatility state (v_r, index reference, last swap timestamp) is stored in PostgreSQL and read with a FOR UPDATE lock on each swap. This ensures correctness across ClearPortX’s PM2 cluster mode where multiple Node.js workers share the database but not memory.

-- Read state at swap start
SELECT volatility_ref, index_ref, last_swap_ts
FROM pools WHERE id = $1 FOR UPDATE;
-- Write state after swap
UPDATE pools SET volatility_ref = $1, index_ref = $2, last_swap_ts = $3
WHERE id = $4;

The dynamic fee is implemented in server.js:

function computeDynamicFee(pool, binsCrossed, volatilityState) {
const baseFee = pool.feeRate;
const bw = pool.binWidth;
// Volatility accumulator: persistent ref + bins crossed
const va = volatilityState.vr + binsCrossed;
// Variable fee: quadratic in va, scaled by binWidth²
const variableFee = va * va * bw * bw;
const cappedVariable = Math.min(variableFee, 0.02); // 2% cap
const totalFee = Math.min(baseFee + cappedVariable, 0.1); // 10% hard cap
return { baseFee, variableFee: cappedVariable, totalFee, volatilityAccumulator: va };
}

Every swap response includes the fee breakdown, enabling transparent display in any frontend:

{
"feeBreakdown": {
"baseFee": 0.003,
"variableFee": 0.0001,
"totalFeeRate": 0.0031,
"volatilityAccumulator": 1.5
}
}