Smart Contracts
AffiliationBondingCurveTokenSale

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

EntrypointSignatureFlags
init(beneficiary: address, name: string, decimals: int, symbol: string, bonding_curve: BondingCurve, overwrite_owner: option(address), affiliation_treasury: AffiliationTreasury) => statestateful
buy(count: int) => unitstateful, payable
buy_with_affiliation(count: int, affiliation_rewardee: address) => unitstateful, payable
sell(count: int, minimal_return_required: int) => intstateful
price(count: int) => int
sell_return(count: int) => int
token_contract() => FungibleTokenFull
bonding_curve() => BondingCurve
beneficiary() => address
set_beneficiary(beneficiary: address) => unitstateful
owner() => address
set_owner(owner: address) => unitstateful
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 removed

price(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_price

The 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:

EventEmitted byCarries
Mintthe community AEX-9 tokenbuyer, tokens minted
Mintthe ProtocolDAO AEX-9 tokenbuyer, ProtocolDAO tokens minted
PriceChangethe saleprevious unit price, next unit price
Buythe saletotal 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 tokens

The 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:

SliceDestinationAmount
Affiliation feeAffiliationTreasury.record_sale_transactionbonding_curve_price * 0.5%
Refundback to the callerCall.value - total_price
Reservestays in this contractcalculate_sell_return(new_supply, count)
SpreadDAO.record_sale_transactionwhatever 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 callanyoneonly the CommunityFactory (ONLY_FACTORY_CAN_CALL)
Affiliation rewardeeCall.caller, alwaysthe address the factory passes
When it is usedevery ordinary buythe 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

ConditionResult
count below the curve's minimum_count of 10 ** 12the curve aborts with MINIMUM_TOKENS_REQUIRED
Computed return is below minimal_return_requiredaborts with MINIMAL_RETURN_NOT_MEET
No allowance, or an allowance smaller than countthe inner transfer_allowance aborts
Balance smaller than countthe 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