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.
- Choose Buy or Sell.
- 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.
- Review the quote: the average price you will pay, the price impact of your own trade, and, on a buy, the ProtocolDAO token reward.
- 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| Command | Needs a key | What it does |
|---|---|---|
lookup | No | Queries the token API by address or name, prints sale_address, price, market cap, holders, trending score |
price | Yes | Calls price(count) on the sale contract and prints the cost in both AE and aettos |
buy | Yes | Quotes, adds 3%, and sends buy(count) with that amount attached |
sell | Yes | Sets 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):
| Entrypoint | Notes |
|---|---|
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 supply | Supply must fall by… | …for the spot price to drop |
|---|---|---|
| 1,000,000 | 10,050 tokens (1.00%) | 1% |
| 1,000,000 | 30,152 tokens (3.02%) | 3% |
| 10,000,000 | 95,298 tokens (0.95%) | 1% |
| 10,000,000 | 286,168 tokens (2.86%) | 3% |
| 100,000,000 | 634,164 tokens (0.63%) | 1% |
| 100,000,000 | 1,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
| Cause | Direction | Typical size |
|---|---|---|
| Your own trade size walking up the curve | Always against you | Already inside the quote; see the slippage table on Bonding Curves |
| Other trades landing between quote and execution | Either way | Bounded by your tolerance; this is what the guards are for |
| The 0.5% affiliation fee on a buy | Against you | 0.5% of the curve value, already inside price(), not inside calculate_buy_price() |
| Off-chain estimates using a trapezoidal integral | Overstates by up to ~20 ppm on very large trades | Negligible, 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
- Bonding Curves: where the quote comes from, and why a big trade fills worse
- Fees & Slippage: the 1% spread, the fee split, and the slippage defaults side by side
- Curve Parameters & Units: the decimal scaling used throughout the code above
- Token sale contract: entrypoint-level reference for the sale contract
- Portfolio & PnL: checking what a trade did to your position afterwards
- AEX-9 standard (opens in a new tab): the allowance semantics used by the sell flow