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.
| Constant | Integer | In curve terms |
|---|---|---|
a | 10^15 | 0.001, the exponential's scale factor, in aettos |
k | 10^10 | 1e-8, the growth rate per whole token of supply |
initial_price | 10^11 | 0.0000001 AE, the spot price at supply zero |
decimals | 10^18 | The fixed-point scale, matching aettos and the token's own decimals |
minimum_count | 10^12 | The smallest tradable amount: 10^-6 tokens |
sell_return_percentage | 9950 | 99.50% |
sell_return_precision | 10000 | The 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
| Quantity | Unit on the wire | Unit in the formula | Conversion |
|---|---|---|---|
| AE amount | aettos, an integer | AE, a decimal | divide by 10^18 |
| Token count | token decimal units, an integer | whole tokens, a decimal | divide by 10^(decimals) |
| Curve value | Frac.frac rational, or an integer of aettos | AE per token | see 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:
| Source | Entrypoint | What it governs |
|---|---|---|
| The bonding curve | supported_decimals() | The scale the curve's own arithmetic expects |
| The AEX-9 token | meta_info().decimals | The 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.
| Call | Arguments | Returns |
|---|---|---|
BondingCurve.buy_price(total_supply) | supply in token decimal units | Frac.ceil(buy_curve(supply)) (see below) |
BondingCurve.sell_price(total_supply) | supply in token decimal units | Frac.floor(sell_curve(supply)) (see below) |
BondingCurve.calculate_buy_price(total_supply, count) | both in token decimal units | total cost, aettos |
BondingCurve.calculate_sell_return(total_supply, sell_tokens) | both in token decimal units | total return, aettos |
BondingCurve.buy_curve(x) | supply in token decimal units | Frac.frac |
TokenSale.price(count) | count in token decimal units | aettos to send with buy |
TokenSale.sell_return(count) | count in token decimal units | aettos you would receive |
TokenSale.buy(count) | count in token decimal units, AE attached in aettos | mints to the caller |
TokenSale.sell(count, minimal_return_required) | count in token decimal units, floor in aettos | aettos returned |
FungibleTokenFull.create_allowance(for_account, value) | value in token decimal units | None |
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 value | buy_price() returns | sell_price() returns |
|---|---|---|---|
| 0 | 1.0e-7 | 1 | 0 |
| 1,000,000 | 1.015017e-5 | 1 | 0 |
| 10,000,000 | 1.052709e-4 | 1 | 0 |
| 690,000,000 | 0.99127 | 1 | 0 |
| 700,000,000 | 1.09563 | 2 | 1 |
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:
- Inside Sophia, build a
fracwithFrac.make_frac, which normalises (make_frac(2, 4)equalsmake_frac(1, 2)); off-chain, treat a decoded value as normalised and do not assume a particular denominator. - Compare with
Frac.lt,Frac.geqandFrac.eqin Sophia; off-chain, cross-multiply or convert toBigNumberfirst. - 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:
- The series is truncated twice. It stops at
i > 100, and it stops as soon as a term floors to0under integer division. In the supply range these tokens trade in, the second bound always fires first; the terms collapse long before the hundredth. mc2starts at the quadratic term. It returnsscale · (e^z − 1 − z)rather thanscale · e^z, wherez = k·supply / 10^18is the curve exponent. The missing linear term is what the(initial_price * count) / decimalssummand restores; that is where the−0.0009999offset of the closed form comes from.- Every division floors, and the order is load-bearing.
(term * x) / (i * scale)divides once, after both multiplications. Splitting it intoterm * x / i / scaleor pre-scalingxchanges 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); // 104750061728914276nNote 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 105276443948657564The 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:
| Trade | Exact (AE) | Trapezoidal, n = 100 (AE) | Error |
|---|---|---|---|
| 1,000 tokens from supply 0 | 0.000105000 | 0.000105000 | +0.03 ppm |
| 1,000,000 tokens from supply 0 | 5.116708417 | 5.116709254 | +0.16 ppm |
| 10,000,000 tokens from supply 0 | 518.091807565 | 518.092683989 | +1.69 ppm |
| 100,000,000 tokens from supply 0 | 71,838.182845904 | 71,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
- Bonding Curves: the formula these units belong to, with the integration worked out
- Buying & Selling: where these conversions get used on a real trade
- Calling contracts: SDK setup, ACIs and dry-run calls in general
- Bonding curve contract: entrypoint-level reference
- AEX-9 tokens: the token standard whose
decimalsyou are reading - Fees & Slippage: what
price()adds on top of the curve integral computed here - Sophia standard library (opens in a new tab): the
Fracnamespace