D25-F01-A03 / Complete engineering topic

StableSwap Invariant

A production-minded guide to StableSwap Invariant.

D25 · DIGITAL ASSETS AND ON-CHAIN F…
D25-F01-A03Canonical / Tested / Open
D25 / D25-F01

Automated market makers are often introduced with one memorable equation and then treated as if the equation were the product. That shortcut hides the decisions that cause most implementation mistakes: which token is input, which way price is quoted, where the fee is charged, which balance reaches the pool, and whether the post-trade invariant should stay equal or grow.

For StableSwap Invariant, the useful question is precise: How can a pool behave almost like constant sum near a peg yet recover constant-product protection as balances diverge? This tutorial answers it with one frozen contract rather than a cloud of nearby variants. It serves financial engineers, quantitative developers, DeFi analysts, and practitioners who understand fractions and reserve balances but do not need prior smart-contract experience.

Start with the economic promise

Amplification flattens the curve near equal balances. The product term dominates farther from equilibrium, preventing the hard inventory cliff of constant sum. That behavior is not accidental. It is the trade-off selected by the invariant. A pool can make trading deep near one state, preserve a portfolio weight, spread liquidity over every positive price, or focus liquidity inside a finite range—but it cannot obtain every property for free.

The canonical variant is Whitepaper StableSwap invariant over normalized balances, Ann=A·n^n, Newton iteration for D and the output balance, and an output-side fee retained in the pool. Its output is D, Ann, solver iterations, gross and net output, retained fee, post-fee D, execution price, and peg-distance state. The mathematical definition traces to the primary StableSwap whitepaper. The examples below are synthetic inputs and author-derived calculations, not live-pool observations.

Read the equation with orientation attached

Annxi+D=ADnn+Dn+1nnxiAn^n\sum x_i+D=ADn^n+\frac{D^{n+1}}{n^n\prod x_i}
SymbolMeaningUnit / orientation
x_idecimal-normalized balance of coin icommon precision
nnumber of pooled coinscount
Awhitepaper amplification coefficientdimensionless
AnnA multiplied by n to the power ndimensionless
DStableSwap invariant solved by Newton iterationnormalized balance units

The package always quotes token1 per token0. If ten token0 produce 9.8 token1, the execution price is 0.98 token1/token0. Reversing that number is not a display preference; it changes percentage comparisons and can reverse a risk conclusion.

Fees deserve the same care. Some models reduce input before applying the invariant while the full gross input reaches the reserve. Curve-style examples commonly solve gross output from the state move and deduct an output fee. Both retain value, but their intermediate balances differ.

Work the canonical calculation

Synthetic teaching input: normalized balances [1,000, 1,000], gross input 10, A=100, two coins, and output fee 0.0004.

  1. Ann = A × n^n = 100 × 2² = 400.0.
  2. Equal balances solve exactly to D = 2000.0.
  3. Holding D, Newton iteration solves the new output balance in 7 iterations.
  4. Gross output is 9.999502463058; output fee is 0.003999800985; trader output is 9.995502662073.
  5. Fee retention moves D to 2000.004000000989. This is author-derived floating-point arithmetic, not a live Curve quote.

The trace exposes the intermediate amount and state branch. Reproduce it before trusting the precomputed JSON. Then change one input at a time in the guided lab so the reason for a changed output remains identifiable.

See the geometry before reading code

The following visual is a synthetic, topic-specific explanation of the selected invariant or position branch. Notice the labeled boundary and the direction of the highlighted move.

StableSwap Invariant annotated geometry

Open the annotated visual at full size · Open the guided playground

Explore five controlled scenarios

The first scenario begins with balanced normalized inventory and a small trade. The second changes only trade size, so the difference exposes curvature. The third begins imbalanced. The fourth uses an 80/20 profile. The fifth uses shallow inventory and an edge condition.

Every scenario contains 81 calculations. The marker selects a precomputed reference result rather than interpolating a decorative line. Guidance explains what changed, the active rule, and why the diagnostic moved. A visible synthetic-data badge prevents the fixture from looking like a live quote.

What the output can and cannot say

An amount-out quote answers a conditional question: given these normalized balances, this invariant, this fee convention, and this direction, what does the arithmetic return? It does not prove fair value, promise inclusion at that state, or include gas and transaction ordering.

Spot price is marginal. Execution price is average output divided by gross input. Price impact compares those values using one orientation. In a fee-free state move the invariant is preserved; after a retained fee, the measured actual-reserve invariant can rise.

Implement without hiding assumptions

The Python and TypeScript references use the same sequence:

Plain text
validate balances, distinct coin indices, amount, A, and fee
set Ann = A * n^n and solve D by Newton iteration
increase the selected input balance by gross input
solve the output balance y while holding D
apply the output fee, update balances, and re-solve post-fee D

Both languages reject non-finite and unsupported inputs rather than clipping them. Double precision is an explicit pedagogical choice. A deployment needs fixed-point scaling, conservative rounding, token-transfer accounting, gas constraints, and exact version checks.

The topic checks include a canonical value, a mathematical property, a boundary, and an invalid state. StableSwap additionally checks that coin indices remain distinct even for zero input. Passing these checks establishes definition fidelity for the declared model; it does not establish smart-contract security or economic profitability.

Where implementations fail

The central obstacle is keeping amplification scaling, balance normalization, Newton convergence, and fee placement consistent. This appears as swapped exponents, reciprocal prices, double-counted fees, rounded intermediates, or a reserve update that uses an effective amount where the full amount arrived.

A high amplification factor cannot make a broken peg safe. It can instead let informed traders remove the stronger asset at a favorable internal price. This is an author inference from the invariant’s behavior, not a claim that every deployment has identical risk. Production controls include slippage limits, deadlines, token ordering, state freshness, and appropriate oracle design.

Why the examples are synthetic

A trustworthy historical quote needs a chain, block, pool address, verified code version, raw balances, decimals, wrapper rates, fee parameters, and transaction ordering. Without those fields a precise-looking historical number is not reproducible. Controlled fixtures isolate the mechanism and remain redistributable offline.

Practical review checklist

State the price orientation. Identify gross input, effective input, gross output, net output, and retained fee. Recalculate before and after balances. Verify the invariant or branch. Compare marginal and average prices in the same units. Inspect exhaustion, convergence, weight, or range state. Finally, list excluded production details.

Summary and next topic

The lesson is not that one invariant wins. Each makes a legible bargain. Continue through Constant-Product AMM, Constant-Sum AMM, StableSwap Invariant, Weighted-Product AMM, and Concentrated-Liquidity Position to compare increasingly specialized capital allocation. Then move to Impermanent-Loss Calculation, which evaluates the value consequence of inventory changes instead of producing the quote.

Primary references

system flow

Rendering system map…
ReferencesPrimary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

Sources are attached to specific claims rather than treated as generic further reading. Protocol papers define the mathematical variants; the local data contract freezes educational simplifications and orientation. No provider observations, filed facts, or live-pool values appear in this package.

SRC-01 — Uniswap v2 Core whitepaper

  • Organization or authors: Uniswap
  • Source type: Primary protocol paper or official maintained documentation
  • Publication or effective date: 2020-03
  • Version: v2 Core whitepaper
  • URL or DOI: Uniswap v2 Core whitepaper
  • Accessed: 2026-07-29
  • Jurisdiction: Protocol mathematics; not jurisdiction-specific
  • Supports: Primary protocol paper for the constant-product reserve invariant, swap accounting, and retained fees.
  • Limitations: Defines Uniswap v2 core behavior; this tutorial omits integer rounding, token-transfer behavior, and deployment state.

SRC-02 — StableSwap whitepaper

  • Organization or authors: Curve Finance / Michael Egorov
  • Source type: Primary protocol paper or official maintained documentation
  • Publication or effective date: 2019-11
  • Version: StableSwap whitepaper
  • URL or DOI: StableSwap whitepaper
  • Accessed: 2026-07-29
  • Jurisdiction: Protocol mathematics; not jurisdiction-specific
  • Supports: Primary protocol paper for the amplified invariant and Newton-solved D.
  • Limitations: Paper notation differs from some contract-stored amplification scaling and does not define this package's floating-point presentation policy.

SRC-03 — Balancer whitepaper

  • Organization or authors: Balancer Labs
  • Source type: Primary protocol paper or official maintained documentation
  • Publication or effective date: 2019-09
  • Version: Balancer whitepaper
  • URL or DOI: Balancer whitepaper
  • Accessed: 2026-07-29
  • Jurisdiction: Protocol mathematics; not jurisdiction-specific
  • Supports: Primary protocol paper for the weighted geometric-mean value function and out-given-in trade equation.
  • Limitations: Production fixed-point limits, rounding, Vault behavior, and later protocol versions remain outside this educational quote.

SRC-04 — Uniswap v3 Core whitepaper

  • Organization or authors: Uniswap
  • Source type: Primary protocol paper or official maintained documentation
  • Publication or effective date: 2021-03
  • Version: v3 Core whitepaper
  • URL or DOI: Uniswap v3 Core whitepaper
  • Accessed: 2026-07-29
  • Jurisdiction: Protocol mathematics; not jurisdiction-specific
  • Supports: Primary protocol paper for finite-range liquidity and virtual-reserve accounting.
  • Limitations: The package teaches one continuous position and omits ticks, fee growth, NFT state, and transaction execution.

Source discipline

The package does not use live pool values as evidence. Formula claims trace to protocol-authored papers or official developer documentation. Economic warnings are labeled model consequences derived from the invariant, not claims that every deployment shares identical controls. Linked source documents remain owned by their publishers and are not redistributed in the fixture.

amm-pricing.ts
export type AmmResult = Record<string, unknown>;

const EPSILON = 1e-12;
const round = (value: number): number => Number(value.toFixed(12));
const positive = (name: string, value: number): number => {
  if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be finite and greater than zero`);
  return value;
};
const nonnegative = (name: string, value: number): number => {
  if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be finite and at least zero`);
  return value;
};
const validFee = (value: number): number => {
  nonnegative("feeRate", value);
  if (value >= 1) throw new Error("feeRate must be less than one");
  return value;
};

export function constantProductQuote(
  reserve0: number,
  reserve1: number,
  amount0In: number,
  feeRate = 0.003,
): AmmResult {
  const x = positive("reserve0", reserve0);
  const y = positive("reserve1", reserve1);
  const dx = nonnegative("amount0In", amount0In);
  const fee = validFee(feeRate);
  const effective = dx * (1 - fee);
  const amount1Out = y * effective / (x + effective);
  const xAfter = x + dx;
  const yAfter = y - amount1Out;
  const spotBefore = y / x;
  const execution = dx > 0 ? amount1Out / dx : spotBefore;
  const impact = dx > 0 ? 1 - execution / spotBefore : 0;
  return {
    model: "constant-product",
    reserve0Before: x,
    reserve1Before: y,
    amount0In: dx,
    effectiveAmount0In: round(effective),
    feeAmount0: round(dx - effective),
    amount1Out: round(amount1Out),
    reserve0After: round(xAfter),
    reserve1After: round(yAfter),
    invariantBefore: round(x * y),
    invariantAfter: round(xAfter * yAfter),
    spotPriceBeforeToken1PerToken0: round(spotBefore),
    spotPriceAfterToken1PerToken0: round(yAfter / xAfter),
    executionPriceToken1PerToken0: round(execution),
    priceImpactFraction: round(impact),
    state: dx === 0 ? "no-trade" : impact >= 0.05 ? "large-impact" : "quoted",
  };
}

export function constantSumQuote(
  reserve0: number,
  reserve1: number,
  amount0In: number,
  feeRate = 0.003,
): AmmResult {
  const x = positive("reserve0", reserve0);
  const y = positive("reserve1", reserve1);
  const dx = nonnegative("amount0In", amount0In);
  const fee = validFee(feeRate);
  const effective = dx * (1 - fee);
  if (effective >= y && dx > 0) throw new Error("insufficient reserve1 for an exact-fill constant-sum quote");
  const yAfter = y - effective;
  return {
    model: "constant-sum",
    reserve0Before: x,
    reserve1Before: y,
    amount0In: dx,
    effectiveAmount0In: round(effective),
    feeAmount0: round(dx - effective),
    amount1Out: round(effective),
    reserve0After: round(x + dx),
    reserve1After: round(yAfter),
    invariantBefore: round(x + y),
    invariantAfter: round(x + dx + yAfter),
    spotPriceBeforeToken1PerToken0: 1,
    executionPriceToken1PerToken0: round(dx > 0 ? effective / dx : 1),
    remainingExactFillCapacityToken0: round(yAfter / (1 - fee)),
    inventoryUtilizationFraction: round(effective / y),
    state: dx === 0 ? "no-trade" : yAfter / y <= 0.1 ? "near-exhaustion" : "quoted",
  };
}

export function stableSwapInvariant(
  balances: number[],
  amplification: number,
  tolerance = EPSILON,
  maxIterations = 256,
): AmmResult {
  if (!Array.isArray(balances) || balances.length < 2) throw new Error("balances must contain at least two coins");
  const xp = balances.map((value, index) => positive(`balances[${index}]`, value));
  const amp = positive("amplification", amplification);
  const n = xp.length;
  const ann = amp * n ** n;
  const total = xp.reduce((sum, value) => sum + value, 0);
  let d = total;
  for (let iteration = 1; iteration <= maxIterations; iteration += 1) {
    let dProduct = d;
    for (const balance of xp) dProduct = dProduct * d / (balance * n);
    const previous = d;
    d = ((ann * total + dProduct * n) * d) / ((ann - 1) * d + (n + 1) * dProduct);
    if (Math.abs(d - previous) <= tolerance * Math.max(1, Math.abs(d))) {
      return { D: round(d), iterations: iteration, converged: true, amplification: amp, Ann: round(ann), coinCount: n };
    }
  }
  throw new Error("StableSwap D solver did not converge");
}

function stableSwapGetY(
  inputIndex: number,
  outputIndex: number,
  inputBalanceAfter: number,
  balances: number[],
  amplification: number,
  invariantD: number,
): [number, number] {
  const n = balances.length;
  if (inputIndex === outputIndex || inputIndex < 0 || outputIndex < 0 || inputIndex >= n || outputIndex >= n) {
    throw new Error("inputIndex and outputIndex must be distinct valid indices");
  }
  const ann = amplification * n ** n;
  let c = invariantD;
  let subtotal = 0;
  balances.forEach((oldBalance, index) => {
    if (index === outputIndex) return;
    const balance = index === inputIndex ? inputBalanceAfter : oldBalance;
    subtotal += balance;
    c = c * invariantD / (balance * n);
  });
  c = c * invariantD / (ann * n);
  const b = subtotal + invariantD / ann;
  let y = invariantD;
  for (let iteration = 1; iteration <= 256; iteration += 1) {
    const previous = y;
    y = (y * y + c) / (2 * y + b - invariantD);
    if (Math.abs(y - previous) <= EPSILON * Math.max(1, Math.abs(y))) return [y, iteration];
  }
  throw new Error("StableSwap y solver did not converge");
}

export function stableSwapQuote(
  balances: number[],
  amountIn: number,
  amplification = 100,
  feeRate = 0.0004,
  inputIndex = 0,
  outputIndex = 1,
): AmmResult {
  const xp = balances.map((value, index) => positive(`balances[${index}]`, value));
  const dx = nonnegative("amountIn", amountIn);
  const amp = positive("amplification", amplification);
  const fee = validFee(feeRate);
  if (
    !Number.isInteger(inputIndex) ||
    !Number.isInteger(outputIndex) ||
    inputIndex === outputIndex ||
    inputIndex < 0 ||
    outputIndex < 0 ||
    inputIndex >= xp.length ||
    outputIndex >= xp.length
  ) {
    throw new Error("inputIndex and outputIndex must be distinct valid indices");
  }
  const solved = stableSwapInvariant(xp, amp);
  const d = solved.D as number;
  const [yNew, yIterations] = dx === 0
    ? [xp[outputIndex], 0]
    : stableSwapGetY(inputIndex, outputIndex, xp[inputIndex] + dx, xp, amp, d);
  const grossOut = Math.max(0, xp[outputIndex] - yNew);
  const feeOut = grossOut * fee;
  const netOut = grossOut - feeOut;
  if (netOut >= xp[outputIndex]) throw new Error("insufficient output balance");
  const after = [...xp];
  after[inputIndex] += dx;
  after[outputIndex] -= netOut;
  const solvedAfter = stableSwapInvariant(after, amp);
  const execution = dx > 0 ? netOut / dx : 1;
  return {
    model: "stableswap",
    balancesBefore: xp,
    balancesAfter: after.map(round),
    inputIndex,
    outputIndex,
    amountIn: dx,
    grossAmountOut: round(grossOut),
    feeAmountOut: round(feeOut),
    amountOut: round(netOut),
    executionPriceOutputPerInput: round(execution),
    priceImpactFromPegFraction: round(dx > 0 ? 1 - execution / (1 - fee) : 0),
    DBefore: d,
    DAfterFeeRetained: solvedAfter.D,
    DIterations: solved.iterations,
    yIterations,
    amplification: amp,
    Ann: solved.Ann,
    state: dx === 0 ? "no-trade" : Math.abs(xp[0] / xp[1] - 1) > 0.2 ? "off-peg" : "near-peg",
  };
}

export function weightedProductQuote(
  balanceIn: number,
  balanceOut: number,
  weightIn: number,
  weightOut: number,
  amountIn: number,
  feeRate = 0.003,
): AmmResult {
  const bi = positive("balanceIn", balanceIn);
  const bo = positive("balanceOut", balanceOut);
  const wi = positive("weightIn", weightIn);
  const wo = positive("weightOut", weightOut);
  if (Math.abs(wi + wo - 1) > 1e-10) throw new Error("two-token weights must sum to one");
  const dx = nonnegative("amountIn", amountIn);
  const fee = validFee(feeRate);
  const effective = dx * (1 - fee);
  const amountOut = bo * (1 - (bi / (bi + effective)) ** (wi / wo));
  const biAfter = bi + dx;
  const boAfter = bo - amountOut;
  const spotBefore = (bo / wo) / (bi / wi);
  const execution = dx > 0 ? amountOut / dx : spotBefore;
  const impact = dx > 0 ? 1 - execution / spotBefore : 0;
  return {
    model: "weighted-product",
    balanceInBefore: bi,
    balanceOutBefore: bo,
    weightIn: wi,
    weightOut: wo,
    amountIn: dx,
    effectiveAmountIn: round(effective),
    feeAmountIn: round(dx - effective),
    amountOut: round(amountOut),
    balanceInAfter: round(biAfter),
    balanceOutAfter: round(boAfter),
    invariantBefore: round(bi ** wi * bo ** wo),
    invariantAfterFeeRetained: round(biAfter ** wi * boAfter ** wo),
    spotPriceBeforeOutputPerInput: round(spotBefore),
    spotPriceAfterOutputPerInput: round((boAfter / wo) / (biAfter / wi)),
    executionPriceOutputPerInput: round(execution),
    priceImpactFraction: round(impact),
    state: dx === 0 ? "no-trade" : impact >= 0.05 ? "large-impact" : "quoted",
  };
}

export function concentratedLiquidityPosition(
  liquidity: number,
  priceLower: number,
  priceUpper: number,
  currentPrice: number,
): AmmResult {
  const L = positive("liquidity", liquidity);
  const lower = positive("priceLower", priceLower);
  const upper = positive("priceUpper", priceUpper);
  const price = positive("currentPrice", currentPrice);
  if (lower >= upper) throw new Error("priceLower must be less than priceUpper");
  const sa = Math.sqrt(lower);
  const sb = Math.sqrt(upper);
  const sp = Math.sqrt(price);
  let amount0: number;
  let amount1: number;
  let state: string;
  let active: boolean;
  if (price <= lower) {
    [amount0, amount1, state, active] = [L * (1 / sa - 1 / sb), 0, "below-range", false];
  } else if (price >= upper) {
    [amount0, amount1, state, active] = [0, L * (sb - sa), "above-range", false];
  } else {
    [amount0, amount1, state, active] = [L * (1 / sp - 1 / sb), L * (sp - sa), "active", true];
  }
  return {
    model: "concentrated-liquidity-position",
    liquidity: L,
    priceLowerToken1PerToken0: lower,
    priceUpperToken1PerToken0: upper,
    currentPriceToken1PerToken0: price,
    sqrtPriceLower: round(sa),
    sqrtPriceUpper: round(sb),
    sqrtPriceCurrent: round(sp),
    amount0: round(amount0),
    amount1: round(amount1),
    valueInToken1AtCurrentPrice: round(amount0 * price + amount1),
    state,
    active,
    boundaryConvention: "price<=lower is below; lower<price<upper is active; price>=upper is above",
  };
}
Full-height labplaygroundOpen full screen