Tokens & Bonding Curves
Curve Parameters & Units

Curve Parameters & Units

Every integer that crosses the boundary between your code and a Superhero contract is scaled. AE amounts are in aettos (10^18 to the AE), token counts are in the token's own decimal units (also 10^18), and the curve formula is written in whole tokens and whole AE. Mixing those three conventions is how implementations end up off by a factor of 10^18.

Read Bonding Curves first for the formula itself.

The deployed constants

The constants are compile-time values in BondingCurveExponential, not deployment parameters, so every curve deployed from it carries the same values.

ConstantIntegerIn curve terms
a10^150.001, the exponential's scale factor, in aettos
k10^101e-8, the growth rate per whole token of supply
initial_price10^110.0000001 AE, the spot price at supply zero
decimals10^18The fixed-point scale, matching aettos and the token's own decimals
minimum_count10^12The smallest tradable amount: 10^-6 tokens
sell_return_percentage995099.50%
sell_return_precision10000The denominator that 9950 is a percentage of

Two derived values are also entrypoints: curve_type() returns the string "TAYLOR_EXPONENTIAL_V1", and supported_decimals() returns 18.

The scaling rule: for a curve f(x) = α·e^(β·x) − γ, the constants are a = α × 10^18, k = β × 10^18 and initial_price = (α − γ) × 10^18. With α = 0.001, β = 1e-8 and γ = 0.0009999 that gives exactly the three integers above. The c = 0.0009999 offset is a − initial_price, reconstructed by the arithmetic rather than stored.

minimum_count

calculate_price requires count >= minimum_count and aborts with MINIMUM_TOKENS_REQUIRED otherwise. minimum_count is 10^12 token decimal units (10^12 / 10^18 = 10^-6 tokens, one millionth of a token), so it only guards against a trade rounding to zero inside the integer arithmetic. In practice it fires when you pass a whole-token count where the contract expects decimal units: sending 5 instead of 5000000000000000000 aborts with MINIMUM_TOKENS_REQUIRED rather than silently buying five attotokens.

The sell factor

calculate_sell_return multiplies the curve integral by sell_return_percentage and divides by sell_return_precision: 9950 / 10000, that is 0.995. Use 10000 as the precision in your own code, or read sell_return() on the sale contract instead of reconstructing the factor.

The three unit systems

QuantityUnit on the wireUnit in the formulaConversion
AE amountaettos, an integerAE, a decimaldivide by 10^18
Token counttoken decimal units, an integerwhole tokens, a decimaldivide by 10^(decimals)
Curve valueFrac.frac rational, or an integer of aettosAE per tokensee below

1 AE = 10^18 aettos. The æternity SDK exposes this as AE_AMOUNT_FORMATS.AETTOS / AE_AMOUNT_FORMATS.AE and the helpers toAe() and formatAmount(). Do the division with bignumber.js or BigInt, never in floating point, for anything you intend to sign.

Where the decimals come from

There are two decimal figures, read from two different contracts:

SourceEntrypointWhat it governs
The bonding curvesupported_decimals()The scale the curve's own arithmetic expects
The AEX-9 tokenmeta_info().decimalsThe scale of balances, transfers and allowances

They cannot disagree. AffiliationBondingCurveTokenSale.init requires decimals == bonding_curve.supported_decimals() (BONDING_CURVE_SUPPORTED_DECIMALS_MISSMATCH), so a sale whose token decimals differ from its curve's cannot be deployed. Both are 18 for every token the factory creates: create_community passes decimals = 18 to the sale, and BondingCurveExponential.supported_decimals() returns 18.

Read the value from one of those entrypoints rather than hardcoding 18.

GET /api/tokens/{address} also returns decimals (as a string) alongside total_supply. total_supply from the API is already in token decimal units. Divide it by 10^18 before feeding it to the curve.

What each entrypoint expects

Everything below takes and returns plain integers. Nothing is a float.

CallArgumentsReturns
BondingCurve.buy_price(total_supply)supply in token decimal unitsFrac.ceil(buy_curve(supply)) (see below)
BondingCurve.sell_price(total_supply)supply in token decimal unitsFrac.floor(sell_curve(supply)) (see below)
BondingCurve.calculate_buy_price(total_supply, count)both in token decimal unitstotal cost, aettos
BondingCurve.calculate_sell_return(total_supply, sell_tokens)both in token decimal unitstotal return, aettos
BondingCurve.buy_curve(x)supply in token decimal unitsFrac.frac
TokenSale.price(count)count in token decimal unitsaettos to send with buy
TokenSale.sell_return(count)count in token decimal unitsaettos you would receive
TokenSale.buy(count)count in token decimal units, AE attached in aettosmints to the caller
TokenSale.sell(count, minimal_return_required)count in token decimal units, floor in aettosaettos returned
FungibleTokenFull.create_allowance(for_account, value)value in token decimal unitsNone

The conversion the agent skill uses:

import BigNumber from 'bignumber.js';
 
function toTokenDecimals(count, denominationDecimals, decimals) {
  return new BigNumber(count.toString())
    .shiftedBy(Number(-denominationDecimals) - Number(-decimals))
    .toFixed();
}
 
// A user typing "5" tokens, with an 18-decimal token:
const countTokenDecimals = toTokenDecimals(5, 0n, 18n);
// => "5000000000000000000"

denominationDecimals is the scale of the number you are holding (0 for a whole-token count) and decimals is the scale the contract wants.

buy_price() and sell_price()

buy_price(total_supply) is Frac.ceil(buy_curve(total_supply)) and sell_price(total_supply) is Frac.floor(sell_curve(total_supply)). buy_curve returns AE per whole token (equivalently, aettos per attotoken), so rounding that ratio to an integer loses the value:

Supply (tokens)buy_curve valuebuy_price() returnssell_price() returns
01.0e-710
1,000,0001.015017e-510
10,000,0001.052709e-410
690,000,0000.9912710
700,000,0001.0956321

buy_price() stays at 1 until the curve value passes 1 AE per token, at a supply of roughly 6.9e8 tokens, and sell_price() floors to 0 below that. Use calculate_buy_price and calculate_sell_return for quotes (or price and sell_return on the sale contract), and buy_curve(x) for the spot value as an exact rational.

Frac.frac

buy_curve(x) and sell_curve(x) return a Sophia rational rather than a scaled integer, because a curve value like 1.05e-4 AE cannot be represented exactly in the contract's integer arithmetic. The type comes from the Sophia standard library's Frac namespace:

Frac.frac = Pos(int, int) | Zero | Neg(int, int)

Each constructor carries a numerator and a denominator, with the sign in the constructor name rather than in the integers: both fields of Pos and Neg are strictly positive. Decoded by the JavaScript SDK you get a tagged object, and the value is numerator over denominator:

// decodedResult of buy_curve(x) is shaped like this:
// { Pos: [numerator, denominator] }   ->  +numerator / denominator
// { Zero: [] }                        ->  0
// { Neg: [numerator, denominator] }   ->  -numerator / denominator
 
function fracToBigNumber(frac) {
  if (frac.Zero) return new BigNumber(0);
  const [num, den] = frac.Pos ?? frac.Neg;
  const value = new BigNumber(num.toString()).dividedBy(den.toString());
  return frac.Neg ? value.negated() : value;
}

Three rules carry over from the Sophia standard library:

  1. Inside Sophia, build a frac with Frac.make_frac, which normalises (make_frac(2, 4) equals make_frac(1, 2)); off-chain, treat a decoded value as normalised and do not assume a particular denominator.
  2. Compare with Frac.lt, Frac.geq and Frac.eq in Sophia; off-chain, cross-multiply or convert to BigNumber first.
  3. The denominator is not fixed and can be large. Do not coerce it to a JavaScript number.

buy_curve(x) evaluates to AE per whole token, or identically aettos per attotoken, since both sides scale by 10^18. At supply zero it is 1e-7, matching the 0.0000001 AE launch price. Multiply by 10^18 for a spot price in aettos per whole token.

For pricing a trade you rarely need buy_curve at all: calculate_buy_price and calculate_sell_return already do the integration and hand you aettos.

What the contract computes

calculate_price does not evaluate e^x directly. It evaluates a bounded Maclaurin expansion of it in integer arithmetic; the closed form on Bonding Curves is the idealised curve that expansion approximates. The whole of the pricing logic:

function calculate_price(total_supply : int, count : int) =
  require(count >= minimum_count, "MINIMUM_TOKENS_REQUIRED")
  let current_supply_exponent = (k * total_supply)
  let next_supply_exponent    = (k * (total_supply + count))
  (initial_price * count) / decimals
    + ((a * (mc2(next_supply_exponent) - mc2(current_supply_exponent))) / (k * decimals))

// Maclaurin expansion of e^x starting at the quadratic term
function mc2(x : int) = exp_helper(x, x, 0, 2, decimals * decimals)

function exp_helper(x : int, term : int, sum : int, i : int, scale : int) : int =
  if (i > 100 || term < 1)
    sum
  else
    let next_term = (term * x) / (i * scale)   // x^i / i!
    exp_helper(x, next_term, sum + next_term, i + 1, scale)

Three properties matter if you are reimplementing this:

  1. The series is truncated twice. It stops at i > 100, and it stops as soon as a term floors to 0 under integer division. In the supply range these tokens trade in, the second bound always fires first; the terms collapse long before the hundredth.
  2. mc2 starts at the quadratic term. It returns scale · (e^z − 1 − z) rather than scale · e^z, where z = k·supply / 10^18 is the curve exponent. The missing linear term is what the (initial_price * count) / decimals summand restores; that is where the −0.0009999 offset of the closed form comes from.
  3. Every division floors, and the order is load-bearing. (term * x) / (i * scale) divides once, after both multiplications. Splitting it into term * x / i / scale or pre-scaling x changes the truncation and the answers drift apart.

sell_price is not a separate curve evaluation: calculate_sell_return(supply, n) is calculate_price(supply − n, n) × 9950 / 10000, floored.

Reference implementation: exact

A line-for-line replica in BigInt. It returns the same integer the contract returns, because it performs the same floor divisions in the same order. BigInt division truncates toward zero, which matches Sophia's / for the non-negative values used here.

const A                      = 10n ** 15n;
const K                      = 10n ** 10n;
const INITIAL_PRICE          = 10n ** 11n;
const DECIMALS               = 10n ** 18n;
const MINIMUM_COUNT          = 10n ** 12n;
const SELL_RETURN_PERCENTAGE = 9950n;
const SELL_RETURN_PRECISION  = 10000n;
const SCALE                  = DECIMALS * DECIMALS;
 
function expHelper(x, term, sum, i, scale) {
  while (i <= 100n && term >= 1n) {
    term = (term * x) / (i * scale);     // one division, after both multiplications
    sum += term;
    i += 1n;
  }
  return sum;
}
 
const mc2 = (x) => expHelper(x, x, 0n, 2n, SCALE);
 
/** Aettos to buy `count` (token decimal units) from `totalSupply` (token decimal units). */
export function calculateBuyPrice(totalSupply, count) {
  if (count < MINIMUM_COUNT) throw new Error('MINIMUM_TOKENS_REQUIRED');
  const current = K * totalSupply;
  const next    = K * (totalSupply + count);
  return (INITIAL_PRICE * count) / DECIMALS
       + (A * (mc2(next) - mc2(current))) / (K * DECIMALS);
}
 
/** Aettos returned for burning `sellTokens` down from `totalSupply`. */
export function calculateSellReturn(totalSupply, sellTokens) {
  return (calculateBuyPrice(totalSupply - sellTokens, sellTokens)
          * SELL_RETURN_PERCENTAGE) / SELL_RETURN_PRECISION;
}
 
const T = 10n ** 18n;  // one whole token
calculateBuyPrice(0n, 1000n * T);                  // 105000016666708n
calculateBuyPrice(10_000_000n * T, 1000n * T);     // 105276443948657564n
calculateSellReturn(10_001_000n * T, 1000n * T);   // 104750061728914276n

Note the last line: a sell is quoted against the supply that exists after your buy, so the round trip of 1,000 tokens at supply 10,000,000 costs 105276443948657564 aettos of curve value and returns 104750061728914276. The gap is the 0.5% sell spread, before the affiliation fee on the way in. See Fees & Slippage.

Reference implementation: floating point

For an estimate (a chart, a slider, a sanity check), the closed form is faster and easier to read. It is not exact at the aettos level. Supply and count go in as whole tokens; the result comes out as aettos.

The antiderivative of buy(x) = A·e^(K·x) − C is F(x) = (A/K)·e^(K·x) − C·x, so the cost of minting n tokens from supply S is F(S + n) − F(S). Factor the exponential difference and use Math.expm1 rather than subtracting two values near A/K = 100,000 directly. The naive form returns 0.00010500001371838152 for the first example below, correct to only seven significant figures.

import BigNumber from 'bignumber.js';
 
const A = 0.001;          // scale
const K = 0.00000001;     // growth rate, 1e-8
const C = 0.0009999;      // offset
const SELL_FACTOR = 0.995;
const DECIMALS = new BigNumber(10).pow(18);
 
// F(S+n) - F(S) = (A/K) * e^(K*S) * (e^(K*n) - 1) - C*n
// expm1 computes (e^x - 1) accurately for small x, which is the whole trick.
function costAe(supply, count) {
  return (A / K) * Math.exp(K * supply) * Math.expm1(K * count) - C * count;
}
 
/** Cost in aettos of buying `count` whole tokens starting from `supply` whole tokens. */
export function buyPriceAettos(supply, count) {
  return new BigNumber(costAe(supply, count)).multipliedBy(DECIMALS);
}
 
/** AE returned in aettos for selling `count` whole tokens down from `supply` whole tokens. */
export function sellReturnAettos(supply, count) {
  return new BigNumber(costAe(supply - count, count) * SELL_FACTOR).multipliedBy(DECIMALS);
}
 
// Worked examples, against the contract's own integer result:
costAe(0, 1000);           // 0.000105000016666912   (contract: 0.000105000016666708)
costAe(10_000_000, 1000);  // 0.105276443948658      (contract: 0.105276443948657564)
 
buyPriceAettos(0, 1000).toFixed(0);          // "105000016666912"    vs 105000016666708
buyPriceAettos(10_000_000, 1000).toFixed(0); // "105276443948657760" vs 105276443948657564

The residual, a couple of hundred aettos on each, is double rounding, not a difference in the model: the contract's truncated Taylor series tracks the closed form to within 4e-19 AE across this range. A double has about 15–16 significant digits in total while an aettos figure needs 18, so no floating-point implementation can be exact at the aettos level. Use these for estimates and the contract's own return value for anything you sign.

Trapezoidal estimates

The web app and the agent skill's create command integrate numerically with a 100-interval trapezoidal rule rather than using the antiderivative:

function integrateTrapezoidal(f, aStart, bEnd, n = 100) {
  const h = (bEnd - aStart) / n;
  let sum = 0.5 * (f(aStart) + f(bEnd));
  for (let i = 1; i < n; i += 1) sum += f(aStart + i * h);
  return sum * h;
}

Because the curve is convex, the trapezoidal rule always overestimates slightly:

TradeExact (AE)Trapezoidal, n = 100 (AE)Error
1,000 tokens from supply 00.0001050000.000105000+0.03 ppm
1,000,000 tokens from supply 05.1167084175.116709254+0.16 ppm
10,000,000 tokens from supply 0518.091807565518.092683989+1.69 ppm
100,000,000 tokens from supply 071,838.18284590471,839.614745042+19.9 ppm

Twenty parts per million on a 70,000 AE trade is about 1.4 AE. That is real money on large orders, though far inside any sane slippage tolerance. Treat every off-chain calculation as an estimate and the contract's own return value as the price. The agent skill uses bisection only to pick a token count, then calls calculate_buy_price on-chain for the amount it attaches.

Reading the constants from a deployed curve

The constants are plain read-only entrypoints, so a dry-run call costs nothing:

import { AeSdk, Node, Contract } from '@aeternity/aepp-sdk';
import fs from 'fs';
 
const aeSdk = new AeSdk({
  nodes: [{ name: 'mainnet', instance: new Node('https://mainnet.aeternity.io') }],
});
 
// The curve address is on the token record as `bonding_curve_address`,
// or from the factory: factory.bonding_curve()
const curve = await Contract.initialize({
  aci: JSON.parse(fs.readFileSync('./contracts/BondingCurveExponential.aci.json', 'utf8')),
  address: 'ct_...',           // bonding_curve_address
  onNode: aeSdk.api,
  onAccount: aeSdk,
});
 
console.log({
  curve_type: (await curve.curve_type()).decodedResult,
  supported_decimals: (await curve.supported_decimals()).decodedResult,
  k: (await curve.get_k()).decodedResult,
  initial_price: (await curve.get_initial_price()).decodedResult,
  minimum_count: (await curve.get_minimum_count()).decodedResult,
  sell_return_percentage: (await curve.get_sell_return_percentage()).decodedResult,
});

A curve with curve_type() == "TAYLOR_EXPONENTIAL_V1" returns k = 10000000000, initial_price = 100000000000, minimum_count = 1000000000000, supported_decimals = 18 and sell_return_percentage = 9950. Anything else is a different curve contract, and every figure above needs re-deriving for it.

You can also call these entrypoints without code from aeScan (opens in a new tab) by opening the contract at bonding_curve_address.

Related