AffiliationBondingCurveTokenSale
AffiliationBondingCurveTokenSale.aes is the most frequently called contract in Superhero.
One instance exists per community, deployed by the factory. It holds the AE reserve, mints
tokens on buy, burns them on sell, splits the 1% spread between the community DAO and the
affiliation treasury, and triggers the ProtocolDAO token mint.
There is no fixed address. Resolve it per token with
factory.get_token_sale(collection_name, name), or from GET /api/tokens/<name>. The ACI is
AffiliationBondingCurveTokenSale.aci.json.
Two fixed values you do not need to call for: sale_type() returns
"AFFILIATION_BONDING_CURVE" and version() returns 1.
Entrypoints
| Entrypoint | Signature | Flags |
|---|---|---|
init | (beneficiary: address, name: string, decimals: int, symbol: string, bonding_curve: BondingCurve, overwrite_owner: option(address), affiliation_treasury: AffiliationTreasury) => state | stateful |
buy | (count: int) => unit | stateful, payable |
buy_with_affiliation | (count: int, affiliation_rewardee: address) => unit | stateful, payable |
sell | (count: int, minimal_return_required: int) => int | stateful |
price | (count: int) => int | |
sell_return | (count: int) => int | |
token_contract | () => FungibleTokenFull | |
bonding_curve | () => BondingCurve | |
beneficiary | () => address | |
set_beneficiary | (beneficiary: address) => unit | stateful |
owner | () => address | |
set_owner | (owner: address) => unit | stateful |
sale_type | () => string | |
version | () => int | |
get_state | () => state |
The quote entrypoints are price and sell_return. There is no buy_price entrypoint on
the sale contract. buy_price exists on the bonding curve, and it takes a supply rather than a
count.
price includes the affiliation fee; sell_return does not
entrypoint price(count : int) : int =
require(count > 0, "COUNT_NOT_POSITIVE")
let bonding_curve_price = state.bonding_curve.calculate_buy_price(
state.token_contract.total_supply(), count)
bonding_curve_price + affiliation_amount(bonding_curve_price) // + 0.5%
entrypoint sell_return(count : int) : int =
require(count > 0, "COUNT_NOT_POSITIVE")
state.bonding_curve.calculate_sell_return(
state.token_contract.total_supply(), count) // no fee added or removedprice(count) is the full amount to send: curve price plus the 0.5% affiliation fee,
computed as bonding_curve_price * fee_percentage / fee_precision read live from the
treasury. It is exactly the number buy compares Call.value against. Add nothing to it
except a slippage buffer.
sell_return(count) is the curve's own figure, already reduced by the curve's 0.995 sell
factor. No affiliation fee is taken on a sell.
That is where the 1% round-trip spread comes from: 0.5% added on the way in by this contract, 0.5% withheld on the way out by the curve.
Types
record state =
{ token_contract : FungibleTokenFull
, bonding_curve : BondingCurve
, affiliation_treasury : AffiliationTreasury
, owner : address
, factory : ICommunityFactory
, beneficiary : address
, dao_contract : ReplaceableDAO }get_state() is the way to get the DAO address from the sale, because the factory has no getter for
it.
Events
datatype event =
Buy(int, int, int) // total_price, affiliation_amount, total_supply
| Sell(int, int) // return, total_supply
| PriceChange(int, int) // previous_price, next_priceThe token count is not in any of these. An indexer that wants the traded amount decodes the
transaction's call arguments or reads the accompanying Mint / Burn event from the token
contract.
The total_supply in Buy and Sell is the supply before the trade, not after. Both
entrypoints capture it before the mint or burn and emit that value at the end.
PriceChange carries unit prices sampled before and after the trade. calculate_unit_price() is
price(10 ^ 18), the cost of one whole token including the affiliation fee.
A single buy emits four events across three contracts:
| Event | Emitted by | Carries |
|---|---|---|
Mint | the community AEX-9 token | buyer, tokens minted |
Mint | the ProtocolDAO AEX-9 token | buyer, ProtocolDAO tokens minted |
PriceChange | the sale | previous unit price, next unit price |
Buy | the sale | total price paid, affiliation amount, pre-buy total supply |
Distinguish the two Mint events by the emitting contract address, not by order. On the sale
contract itself PriceChange is emitted before Buy and before Sell.
Units: what count means
count is always in the token's smallest unit. Community tokens are created with 18
decimals, so one whole token is 10 ** 18.
const meta = await token.meta_info().then((r) => r.decodedResult); // { name, symbol, decimals }
const count = new BigNumber(5).shiftedBy(Number(meta.decimals)).toFixed(0); // 5 whole tokensThe bonding curve enforces a minimum_count of 10 ** 12 base units, one one-millionth of
a token. Quotes and trades below that are not meaningful.
Prices and returns are in aettos: 1 AE = 10 ** 18 aettos.
Buying
buy is payable. The token count goes in the argument, the AE goes in the call's amount
option:
const priceAettos = await sale.price(count).then((r) => r.decodedResult);
const amount = new BigNumber(priceAettos.toString())
.times(1.03) // 3% slippage buffer
.toFixed(0, BigNumber.ROUND_CEIL);
const result = await sale.buy(count, { amount, omitUnknown: true });The contract requires Call.value >= total_price and aborts with AE_AMOUNT_NOT_SUFFICIENT
otherwise. Sending the exact quoted price with no buffer is how buys fail: another buy landing
in the same micro block raises the supply, raises the price, and your call reverts.
Overpayment is refunded in full
There is no threshold, no dust floor and no retention:
let overpaid_refund = Call.value - total_price
if (overpaid_refund > 0) Chain.spend(Call.caller, overpaid_refund) else ()Everything downstream then works from Call.value - overpaid_refund, which is exactly
total_price. Your slippage buffer costs you nothing beyond gas if the price did not move,
and that includes the ProtocolDAO mint, which is computed on the net amount.
Where the AE goes
In the order the contract pays them, for a buy of count tokens:
| Slice | Destination | Amount |
|---|---|---|
| Affiliation fee | AffiliationTreasury.record_sale_transaction | bonding_curve_price * 0.5% |
| Refund | back to the caller | Call.value - total_price |
| Reserve | stays in this contract | calculate_sell_return(new_supply, count) |
| Spread | DAO.record_sale_transaction | whatever is left |
The reserve line is the solvency mechanism: the contract retains exactly what it would owe if
that same count were sold straight back, and only the remainder reaches the DAO. It is
computed against the supply after the mint.
Both outbound contract calls are made with protected = true, so a DAO or treasury that
aborts cannot block a buy. The AE stays in the sale contract.
buy versus buy_with_affiliation
payable stateful entrypoint buy(count : int) : unit =
buy_with_affiliation_inner(count, Call.caller)
payable stateful entrypoint buy_with_affiliation(count : int, affiliation_rewardee: address) =
require(Call.caller == state.factory.address, "ONLY_FACTORY_CAN_CALL")
buy_with_affiliation_inner(count, affiliation_rewardee)buy(count) | buy_with_affiliation(count, rewardee) | |
|---|---|---|
| Who may call | anyone | only the CommunityFactory (ONLY_FACTORY_CAN_CALL) |
| Affiliation rewardee | Call.caller, always | the address the factory passes |
| When it is used | every ordinary buy | the creator's initial buy inside create_community |
An ordinary buy already credits your own referral chain: the rewardee is your address, the
treasury looks up from it, and levels one to four of your inviter chain are paid.
buy_with_affiliation exists because during create_community the sale's Call.caller is
the factory, not the creator, so the real buyer is passed explicitly.
The affiliation fee is sent to the treasury on every buy. The treasury credits the rewardee's inviter chain; a buyer with no inviter credits nobody. See AffiliationTreasury.
The four-level split is [300, 125, 50, 25] against a precision of 10 ** 5, giving 0.3%,
0.125%, 0.05% and 0.025%.
ProtocolDAO tokens
buy also mints ProtocolDAO tokens to the buyer, at 1,000 times the net AE paid:
if (Call.caller != state.factory.address)
state.factory.mint_protocol_dao_token(Call.value - overpaid_refund, Call.caller)
else ()The else () branch avoids reentrancy during the initial buy; in that case create_community
mints the tokens itself. The base for the multiplier is the full net payment including the
affiliation fee, not the curve price.
Selling
sell is not payable. It moves tokens from you with transfer_allowance, so grant an
allowance on the token contract first, to the sale contract's address rewritten from ct_ to
ak_:
const forAccount = saleAddress.replaceAll('ct_', 'ak_');
await token.create_allowance(forAccount, count); // or change_allowance if one exists
const sellReturn = await sale.sell_return(count).then((r) => r.decodedResult);
const minReturn = new BigNumber(sellReturn.toString())
.times(0.97) // 3% slippage tolerance
.toFixed(0);
const result = await sale.sell(count, minReturn, { omitUnknown: true });
console.log('AE received:', result.decodedResult.toString());sell returns the AE paid out, in aettos.
Revert conditions for sell
| Condition | Result |
|---|---|
count below the curve's minimum_count of 10 ** 12 | the curve aborts with MINIMUM_TOKENS_REQUIRED |
Computed return is below minimal_return_required | aborts with MINIMAL_RETURN_NOT_MEET |
No allowance, or an allowance smaller than count | the inner transfer_allowance aborts |
Balance smaller than count | the inner transfer aborts |
sell has no require(count > 0) of its own, unlike price and sell_return. A zero or
negative count reaches the curve and fails there with MINIMUM_TOKENS_REQUIRED rather than
COUNT_NOT_POSITIVE.
The minimum-return check runs before the transfer:
let return = state.bonding_curve.calculate_sell_return(total_supply, count)
require(return >= minimal_return_required, "MINIMAL_RETURN_NOT_MEET")
state.token_contract.transfer_allowance(Call.caller, Contract.address, count)Passing 0 as minimal_return_required disables slippage protection. Quote first and set a
floor on every live trade.
There is no revert for "the reserve is empty". The sell curve sits strictly below the buy curve at every supply level, so the reserve always covers a full buy-back of the circulating supply.
Ownership and beneficiary
owner and beneficiary are both the community's DAO after deployment. set_owner and
set_beneficiary are gated by require(Call.caller == state.owner, "UNAUTHORIZED"), so in
practice only a ChangeDAO vote reaches them.
set_beneficiary also runs verify_dao, which casts the address to ReplaceableDAO and
probes its record_sale_transaction with value = 0 to check that the entrypoint exists.
init refuses a mismatched curve:
require(decimals == bonding_curve.supported_decimals(), "BONDING_CURVE_SUPPORTED_DECIMALS_MISSMATCH").
Related
- BondingCurveExponential: where
priceandsell_returncome from - AffiliationTreasury: where the 0.5% affiliation half goes
- FungibleTokenFull (AEX-9): the allowance model selling depends on
- Buying and selling: the same flow from the app's point of view
- Fees and slippage: how to choose a slippage tolerance