Smart Contracts
FungibleTokenFull (AEX-9)

FungibleTokenFull (AEX-9)

FungibleTokenFull.aes is the AEX-9 implementation every Superhero token uses, for every community's token and for the ProtocolDAO token. It is the æternity reference fungible token (aeternity-fungible-token v2.2.0) with the allowance, mintable, burnable and swappable extensions.

There is no single address. Resolve a community token with factory.token_contract(collection_name, name) or sale.token_contract(). The ProtocolDAO token is ct_dsa6octVEHPcm7wRszK6VAjPp1FTqMWa7sBFdxQ9jBT35j6VW on mainnet and ct_2cyV58CrBwi2k4kvP3mN517C1NH21zfxxYmaAyb41GXdXSsRvN on testnet, or call factory.protocol_dao_token().

Entrypoints

EntrypointSignatureFlags
init(name: string, decimals: int, symbol: string, initial_owner_balance: option(int)) => state
transfer(to_account: address, value: int) => unitstateful
transfer_allowance(from_account: address, to_account: address, value: int) => unitstateful
create_allowance(for_account: address, value: int) => unitstateful
change_allowance(for_account: address, value_change: int) => unitstateful
reset_allowance(for_account: address) => unitstateful
mint(account: address, value: int) => unitstateful
burn(value: int) => unitstateful
balance(account: address) => option(int)
balances() => FungibleTokenFull.balances
allowance(allowance_accounts: FungibleTokenFull.allowance_accounts) => option(int)
allowance_for_caller(from_account: address) => option(int)
allowances() => FungibleTokenFull.allowances
meta_info() => FungibleTokenFull.meta_info
total_supply() => int
owner() => address
aex9_extensions() => list(string)
swap() => unitstateful
swapped() => map(address, int)
check_swap(account: address) => intstateful

Types

record meta_info =
  { name     : string
  , symbol   : string
  , decimals : int }

record allowance_accounts =
  { from_account : address     // the owner of the tokens
  , for_account  : address }   // the spender

type balances   = map(address, int)
type allowances = map(allowance_accounts, int)

record state =
  { owner        : address
  , total_supply : int
  , balances     : balances
  , meta_info    : meta_info
  , allowances   : allowances
  , swapped      : map(address, int) }

allowance takes the allowance_accounts record as a single argument, not as two addresses. Pass an object with exactly those two field names:

const current = await token
  .allowance({ from_account: 'ak_owner...', for_account: 'ak_spender...' })
  .then((r) => r.decodedResult);

Events

datatype event =
    Transfer(address, address, int)     // from, to, value
  | Allowance(address, address, int)    // owner, spender, value
  | Burn(address, int)                  // account, value
  | Mint(address, int)                  // account, value
  | Swap(address, int)                  // account, value

These are the standard AEX-9 events. A single token buy produces a Mint on the community token and a Mint on the ProtocolDAO token in the same transaction. See Events for how to tell them apart.

Decimals

Community tokens have 18 decimals: one whole token is 1000000000000000000 base units, the same scale as AE itself. Every value, count and amount in these contracts is in base units.

The factory passes 18 to every token it creates, and the sale contract requires the bonding curve to be calibrated for the same value:

require(decimals == bonding_curve.supported_decimals(),
        "BONDING_CURVE_SUPPORTED_DECIMALS_MISSMATCH")

Read meta_info() and use its decimals field rather than hardcoding 18. The ProtocolDAO token or a future collection could differ:

const meta = await token.meta_info().then((r) => r.decodedResult);
const whole = new BigNumber(rawBalance.toString()).shiftedBy(-Number(meta.decimals));

How a community token is created

The sale contract creates the token in its own init:

token_contract = Chain.create(name, decimals, symbol, None) : FungibleTokenFull

Because Chain.create runs inside the sale contract, the sale contract is the token's owner, and that is what authorises mint on a buy and burn on a sell. initial_owner_balance is None, so a new community token starts at zero total supply: every token in existence was minted by a buy. The name and the symbol are the same string, because CommunityFactory.create_community passes its name argument to both positions.

Allowances

Two Superhero flows require an allowance before they work:

FlowGrant the allowance to
Selling tokens back to the bonding curvethe sale contract address, ct_ rewritten to ak_
Voting in a community DAOthe vote contract address, ct_ rewritten to ak_

The ct_ to ak_ rewrite is required. A contract's account identity uses the ak_ prefix over the same key bytes, and that is the address that appears as for_account:

const forAccount = saleAddress.replaceAll('ct_', 'ak_');

Three entrypoints manage the value:

EntrypointSemanticsWhen to use it
create_allowance(for_account, value)Creates a new allowance entry.First time granting to this spender
change_allowance(for_account, value_change)Adds a delta to an existing allowance. The delta may be negative.Adjusting an existing allowance
reset_allowance(for_account)Sets it to zero.Revoking

The pattern:

const existing = await token
  .allowance({ from_account: aeSdk.address, for_account: forAccount })
  .then((r) => r.decodedResult)
  .catch(() => undefined);
 
if (existing === undefined) {
  await token.create_allowance(forAccount, amount);
} else {
  const delta = new BigNumber(amount).minus(existing.toString()).toFixed();
  await token.change_allowance(forAccount, delta);
}

allowance returns option(int), so a never-granted allowance decodes to undefined. The .catch(() => undefined) covers a call that aborts on a missing entry rather than returning None.

allowance_for_caller(from_account) is the same read from the spender's side.

Mint and burn

mint and burn are how supply tracks the bonding curve:

  • On a buy, the sale contract calls mint(buyer, count) and supply goes up.
  • On a sell, the sale contract pulls your tokens with transfer_allowance and calls burn, and supply goes down.

Both are owner-gated. For a community token the owner is the sale contract, so the only way to increase supply is to pay the curve. That is what makes total_supply() a reliable input to a price calculation.

The swap extension

swap, swapped and check_swap come from the AEX-9 swappable extension, which migrates balances from an older token contract into a newer one: swap() credits the caller based on their balance in the predecessor and records the amount in the swapped map. Superhero does not use this path; for a factory-created token swapped() is empty.

aex9_extensions() returns the list of extension names the contract implements. It is the standard's own discovery mechanism, so call it instead of assuming.

Reading balances

ReadReturnsNote
balance(account)option(int)undefined when the account has never held the token, not zero
balances()map(address, int)Every holder. Large on popular tokens; prefer the middleware
total_supply()intThe input to every bonding curve price call

For holder lists and balances at scale, query the middleware's AEX-9 endpoints (/v3/aex9/<contract address>) rather than calling balances(), which returns the entire map in one response.

Related