Tokens & Bonding Curves
Buying & Selling

Buying & Selling

Trading a community token means minting or burning it against its bonding curve. There is no order book and no counterparty: you send AE and receive newly minted tokens, or you burn tokens and receive AE back. A sell needs an AEX-9 allowance first, and minimal_return_required is what stands between a sell and a bad fill.

From the token page

Open a token at /trends/tokens/NAME and use the Trade tab.

  1. Choose Buy or Sell.
  2. Type an amount in either field, AE or tokens. The other side is computed from the curve at the token's current supply as you type.
  3. Review the quote: the average price you will pay, the price impact of your own trade, and, on a buy, the ProtocolDAO token reward.
  4. Confirm in your wallet.

The slippage tolerance defaults to 1% and is remembered per browser. A buy sends that much extra AE (refunded if unused), and a sell sets its return floor that much below the quote.

A buy also mints ProtocolDAO tokens at 1,000× the AE paid; the Mint event on the ProtocolDAO token contract carries the exact figure. See The ProtocolDAO Token.

A sell is two transactions, and the UI says so with a "step 1 of 2" notice: first an AEX-9 allowance letting the sale contract move your tokens, then the sell itself. Both need a signature.

From the command line

node scripts/superhero-token-swap.mjs lookup "TokenName"          # resolve a name to a sale address
node scripts/superhero-token-swap.mjs price ct_<sale_address> 10  # AE cost for 10 tokens, no transaction
node scripts/superhero-token-swap.mjs buy   ct_<sale_address> 5
node scripts/superhero-token-swap.mjs sell  ct_<sale_address> 5
CommandNeeds a keyWhat it does
lookupNoQueries the token API by address or name, prints sale_address, price, market cap, holders, trending score
priceYesCalls price(count) on the sale contract and prints the cost in both AE and aettos
buyYesQuotes, adds 3%, and sends buy(count) with that amount attached
sellYesSets the allowance, quotes sell_return(count), subtracts 3%, and sends sell(count, floor)

Amounts are whole tokens; the script scales them by the token's own decimals. Run it from the skill's root directory; it reads its ACI files from ./contracts/.

Both buy and sell apply 3% slippage protection. To change it, edit SLIPPAGE_PERCENT in the script, or call the contract yourself as shown below.

The contract entrypoints

On the sale contract (AffiliationBondingCurveTokenSale):

EntrypointNotes
price(count)Read-only quote: aettos required to buy count, including the 0.5% affiliation fee
sell_return(count)Read-only quote: aettos you would receive for selling count
buy(count)Payable, stateful. Mints count tokens to the caller
buy_with_affiliation(count, affiliation_rewardee)Payable, stateful. Callable only by the factory, during create_community
sell(count, minimal_return_required)Stateful. Burns count tokens and pays out, reverting if the return is below the floor
token_contract()The AEX-9 contract address, needed for the allowance
bonding_curve()The pricing contract, if you want to compute quotes yourself

Every count is in token decimal units and every AE figure is in aettos. See Curve Parameters & Units.

Attach exactly price(count), or more. buy requires Call.value >= total_price and refunds the difference with Chain.spend in the same transaction. Attaching less aborts with AE_AMOUNT_NOT_SUFFICIENT. Do not add the affiliation fee on top of price(count) yourself. It is already included. See Fees & Slippage.

buy(count) attributes the affiliation reward to the buyer's own position in the affiliation tree; there is no per-transaction referrer argument. Who gets paid is decided by the buyer's inviter chain, populated once when the buyer redeems an invitation or calls register_invitation. See Affiliation.

A successful trade emits Buy(int, int, int) or Sell(int, int) on the sale contract, plus Mint or Burn on the token contract, plus a second Mint from the ProtocolDAO token contract on a buy. Read these events to learn what you actually received. They carry the executed numbers rather than the quoted ones.

minimal_return_required

sell(count, minimal_return_required) takes a floor in aettos. If the AE the curve would pay out is below that floor, the call reverts and nothing happens: no tokens burned, no AE moved, gas spent.

This is your only protection on a sell. A buy is bounded by the AE you attach, but a sell has no equivalent ceiling. Between the moment you read sell_return(count) and the moment your transaction is included, other people can sell; supply falls, the curve slides down, and your payout shrinks.

Choosing a floor

Take the quote and subtract your tolerance:

const quote = (await sale.sell_return(countTokenDecimals)).decodedResult;
 
const minReturn = new BigNumber(quote.toString())
  .minus(new BigNumber(quote.toString()).times(3).div(100))   // 3% tolerance
  .toFixed(0);
 
await sale.sell(countTokenDecimals, minReturn);

How much tolerance is enough depends on how much of the supply could plausibly leave before you are mined. Because the curve is exponential, a given price move corresponds to a roughly proportional move in supply:

Current supplySupply must fall by……for the spot price to drop
1,000,00010,050 tokens (1.00%)1%
1,000,00030,152 tokens (3.02%)3%
10,000,00095,298 tokens (0.95%)1%
10,000,000286,168 tokens (2.86%)3%
100,000,000634,164 tokens (0.63%)1%
100,000,0001,914,686 tokens (1.91%)3%

A 3% floor on a token with 10 million supply survives roughly 286,000 tokens being sold ahead of you. On a quiet token that is an enormous margin; on one where a single holder owns 20% of the supply it is not. Size the tolerance to the token.

  • Too tight and your sell reverts on ordinary activity. You pay gas and still hold the position.
  • Too loose and you have no protection. A floor of 0 means "fill me at any price", which on a falling curve is exactly what you do not want.

Your own trade's slippage is already in the quote: sell_return(count) integrates the curve across the tokens you are burning. The tolerance covers only what happens between quote and execution.

The allowance step

Before sell, grant an AEX-9 allowance on the token contract, for the sale contract, for at least the amount you are selling.

The allowance is granted to the sale contract's address rewritten from ct_ to ak_: AEX-9 allowances are keyed by account address, and a contract's account form is the same byte string under a different prefix.

const forAccount = saleAddress.replace('ct_', 'ak_');
 
const existing = await tokenContract
  .allowance({ from_account: myAddress, for_account: forAccount })
  .then((r) => r.decodedResult)
  .catch(() => undefined);
 
if (existing === undefined) {
  await tokenContract.create_allowance(forAccount, countTokenDecimals);
} else {
  const delta = new BigNumber(countTokenDecimals).minus(existing.toString()).toFixed();
  await tokenContract.change_allowance(forAccount, delta);
}

create_allowance fails if an allowance already exists, which is why the check matters. change_allowance takes a signed delta, not an absolute value: raising an allowance from 5 to 8 means passing 3, and lowering it from 8 to 5 means passing -3. reset_allowance(for_account) clears it entirely.

A standing allowance authorises the sale contract to move that many tokens until you reduce it. Set it to exactly the amount you are selling on each sell.

A complete buy and sell against the SDK

import { AeSdk, Node, MemoryAccount, Contract } from '@aeternity/aepp-sdk';
import BigNumber from 'bignumber.js';
import fs from 'fs';
 
const SALE = 'ct_...';            // sale_address from the token API
const SLIPPAGE = 3;               // percent
 
const aeSdk = new AeSdk({
  nodes: [{ name: 'mainnet', instance: new Node('https://mainnet.aeternity.io') }],
  accounts: [new MemoryAccount(process.env.AE_PRIVATE_KEY)],
});
 
const sale = await Contract.initialize({
  aci: JSON.parse(fs.readFileSync('./AffiliationBondingCurveTokenSale.aci.json', 'utf8')),
  address: SALE,
  onAccount: aeSdk,
  onNode: aeSdk.api,
});
 
// Resolve the AEX-9 token and its decimals.
const tokenAddress = (await sale.token_contract()).decodedResult;
const token = await Contract.initialize({
  aci: JSON.parse(fs.readFileSync('./FungibleTokenFull.aci.json', 'utf8')),
  address: tokenAddress,
  onAccount: aeSdk,
  onNode: aeSdk.api,
});
const { decimals } = (await token.meta_info()).decodedResult;
 
/** Whole tokens -> token decimal units. */
const scale = (whole) => new BigNumber(whole)
  .shiftedBy(Number(decimals))
  .toFixed(0);
 
// ---- BUY 5 tokens -------------------------------------------------------
const count = scale(5);
const price = (await sale.price(count)).decodedResult;
const amount = new BigNumber(price.toString())
  .times(100 + SLIPPAGE).div(100)
  .toFixed(0);
 
// Anything above price(count) comes straight back in the same transaction.
const bought = await sale.buy(count, { amount, omitUnknown: true });
console.log('buy tx', bought.hash);
 
// ---- SELL 5 tokens ------------------------------------------------------
const forAccount = SALE.replace('ct_', 'ak_');
await token.create_allowance(forAccount, count);     // or change_allowance, see above
 
const quote = (await sale.sell_return(count)).decodedResult;
const minReturn = new BigNumber(quote.toString())
  .times(100 - SLIPPAGE).div(100)
  .toFixed(0);
 
const sold = await sale.sell(count, minReturn, { omitUnknown: true });
console.log('sell tx', sold.hash);

omitUnknown: true tells the SDK to ignore ACI entries it cannot decode rather than throwing.

Why the executed price differs from the quote

CauseDirectionTypical size
Your own trade size walking up the curveAlways against youAlready inside the quote; see the slippage table on Bonding Curves
Other trades landing between quote and executionEither wayBounded by your tolerance; this is what the guards are for
The 0.5% affiliation fee on a buyAgainst you0.5% of the curve value, already inside price(), not inside calculate_buy_price()
Off-chain estimates using a trapezoidal integralOverstates by up to ~20 ppm on very large tradesNegligible, but see Curve Parameters & Units

The quote you get from price() or sell_return() is exact at the supply it was read at. Everything that moves it afterwards is other people.

What you received is recoverable after the fact. A buy emits Buy(total_price, affiliation_amount, total_supply) and a sell emits Sell(return, total_supply), both carrying the executed figures, and both are preceded by PriceChange(previous_price, next_price) with the unit price before and after your trade. The total_supply in those events is the supply before the trade.

Related