Smart Contracts
CommunityFactory

CommunityFactory

CommunityFactory.aes is the entry point to the whole token system. It holds the registry of collections, deploys the four contracts that make up a community in a single transaction, mints ProtocolDAO tokens on every buy, and is the canonical place to look up the shared bonding curve and affiliation treasury addresses.

It is deployed once per network and reused for every collection and community after that.

NetworkAddress
Mainnetct_25cqTw85wkF5cbcozmHHUCuybnfH9WaRZXSgEcNNXG9LsCJWTN
Testnetct_vLKrYRCthfViqUuWFKGYgz7kxhvrsdAoKhZPXqzxcaEFRkZy1

CommunityFactory.aci.json also carries the inlined interfaces of DAO, DAOVote, CommunityManagement, AffiliationTreasury, VoteSubjects, Frac and FungibleTokenFull.

Four values are fixed, so you never need a node to know them:

CallReturnsNote
factory_type()"AFFILIATION_COMMUNITY_FACTORY"literal
version()1literal
fee_percentage()500passthrough to AffiliationTreasury.get_affiliation_fee_percentage()
fee_precision()100000passthrough to AffiliationTreasury.get_affiliation_fee_precision()

fee_percentage() / fee_precision() is 500 / 100000, or 0.5%. Divide the two rather than hardcoding 0.005; both are passthroughs and a treasury redeployment could move them.

Entrypoints

EntrypointSignatureFlags
init(protocol_token_name: string) => CommunityFactory.statestateful
create_collection(collection_name: string, allowed_name_length: int, allowed_name_chars: list(CommunityFactory.allowedNameChars)) => stringstateful, payable
create_community(collection_name: string, name: string, initial_buy_count: int, is_private: bool, meta_info: CommunityManagement.meta_info) => tuple(DAO, TokenSale, CommunityManagement)stateful, payable
mint_protocol_dao_token(buy_value: int, receiver: address) => unitstateful
has_community(collection_name: string, name: string) => bool
get_token_sale(collection_name: string, name: string) => TokenSale
token_contract(collection_name: string, name: string) => FungibleTokenFull
get_community_management(token_sale: TokenSale) => CommunityManagement
token_sale_registry(collection_name: string) => CommunityFactory.token_sale_registry
community_management() => CommunityFactory.community_management
allowed_name_length(collection_name: string) => int
allowed_name_chars(collection_name: string) => list(CommunityFactory.allowedNameChars)
bonding_curve() => BondingCurve
affiliation_treasury() => AffiliationTreasury
protocol_dao_token() => FungibleTokenFull
fee_percentage() => int
fee_precision() => int
factory_type() => string
version() => int
get_state() => CommunityFactory.state

Types

type token_sale_registry  = map(string, TokenSale)
type known_token_sales    = Set.set(address)
type community_management = map(TokenSale, CommunityManagement)
type collection_registry  = map(string, collection_data)

datatype allowedNameChars =
    SingleChar(int)              // one allowed Unicode code point
  | CharRangeFromTo(int, int)    // an inclusive code point range

record collection_data =
  { allowed_name_chars  : list(allowedNameChars)
  , allowed_name_length : int
  , token_sale_registry : token_sale_registry }

record state =
  { collection_registry  : collection_registry
  , known_token_sales    : known_token_sales
  , community_management : community_management
  , affiliation_treasury : AffiliationTreasury
  , bonding_curve        : BondingCurve
  , protocol_dao_token   : FungibleTokenFull }

allowedNameChars values are Unicode code points as integers, not characters. CharRangeFromTo(65, 90) is A-Z; CharRangeFromTo(48, 57) is 0-9; SingleChar(45) is a single dash. That is how the global WORDS collection expresses "uppercase A-Z, digits, and dash". Decoded in JavaScript, each entry is a one-key object:

const chars = await factory.allowed_name_chars(collectionId).then((r) => r.decodedResult);
// [ { CharRangeFromTo: [ 65n, 90n ] }, { CharRangeFromTo: [ 48n, 57n ] }, { SingleChar: [ 45n ] } ]

CommunityManagement.meta_info is map(string, string): free-form key/value metadata with no schema enforced on chain. The Superhero app writes four keys when it creates a token:

KeyValue
collectionthe collection slug, e.g. word
descriptionfree text, rendered on the token page
websitea URL
twitteran X handle or URL

All four are optional and the contract validates none of them. Pass a Map, not an object. An empty new Map() is valid.

Events

datatype event =
    CreateCommunity(string, DAO, TokenSale, CommunityManagement)
                 // name,  dao, sale,      community management
  | CreateCollection(string, address)
                 // collection id, creator

CreateCommunity is the single best source for the three per-community addresses. Capture it instead of making three follow-up reads.

Creating a collection

create_collection registers a naming namespace.

It costs exactly 555 AE, and the AE is burned. The contract requires Call.value == 555 * (10 ^ 18), aborts with COLLECTION_CREATION_DONATION_MISMATCH otherwise, then spends the full amount to ak_11111111111111111111111111111111273Yts, the æternity burn address.

The stored collection id is not the name you passed. The contract concatenates the name, a dash, and the caller's address:

create_collection("WORDS", ...) called by ak_alice...
  → stored as "WORDS-ak_alice..."

That returned string is the id every later call must use, and it is why collection ids in the Superhero API are typed as ${string}-${Encoded.AccountAddress}. Creating a collection whose name already exists aborts with collection_ALREADY_EXISTS (note the lowercase first letter).

Any account may register a collection, with any naming rules. The collections the Superhero app lists on mainnet (WORDS, CHINESE, ARABIC and RUSSIAN) are a curated set maintained in the API.

Creating a community

create_community is the transaction that brings a token into existence. In one call it:

  1. Looks up the collection (COLLECTION_NOT_FOUND), checks the name is unused (NAME_ALREADY_REGISTERED), and validates it against the collection's length (NAME_TOO_LONG) and character rules (NAME_NOT_ALLOWED_CHAR).
  2. Deploys a CommunityManagement contract with the caller as the initial moderator, a minimum token threshold of 10 ^ 18 (one whole token), and your is_private and meta_info.
  3. Deploys a DAO with no token sale set, pointing back at the factory.
  4. Deploys an AffiliationBondingCurveTokenSale, which in turn deploys the community's AEX-9 token, pointing at the shared bonding curve and affiliation treasury. The name argument is used as both the token's name and its symbol.
  5. Calls dao.set_token_sale(sale) and community_management.set_owner(dao), closing the circle that neither constructor could close alone.
  6. Optionally executes an initial buy of initial_buy_count tokens with the attached AE.
  7. Registers the sale in the collection's token_sale_registry, in known_token_sales, and in the community_management map.
  8. Emits CreateCommunity.

It returns the three addresses as a tuple, decoded in JavaScript as an array:

const result = await factory.create_community(
  collectionId,
  'MYTOKEN',
  initialBuyTokenDecimals,   // string, in token base units
  false,                     // is_private
  new Map(),                 // meta_info
  { amount: initialBuyPriceAetto },
);
 
const [daoAddress, tokenSaleAddress, communityManagementAddress] = result.decodedResult;

What initial_buy_count is denominated in

initial_buy_count is a token count in base units, not an AE amount. Community tokens use 18 decimals, so buying 5 whole tokens means passing 5 * 10^18.

To go the other way ("I want to spend 0.1 AE"), invert the bonding curve in two steps:

// 1. Estimate a token count from an AE amount with client-side curve math.
const tokenCount = calculateTokensFromAE(buyAe);            // numeric integration
const decimals = await bondingCurve.supported_decimals().then((r) => r.decodedResult);
const initialBuyTokenDecimals = tokenCount
  .multipliedBy(new BigNumber(10).pow(Number(decimals)))
  .toFixed(0);
 
// 2. Ask the chain for the exact price of that count, then add a buffer.
const onChainPrice = await bondingCurve
  .calculate_buy_price(0n, BigInt(initialBuyTokenDecimals))
  .then((r) => r.decodedResult);
 
const amount = new BigNumber(onChainPrice.toString())
  .multipliedBy(1.05)                       // 5% buffer
  .toFixed(0, BigNumber.ROUND_CEIL);

The client-side estimate only picks a count; the chain always sets the price. Pass 0n as the supply to calculate_buy_price because a brand-new community starts at zero supply. Overpayment is refunded by the sale contract, so the buffer is safe.

Pass initial_buy_count as 0 and attach no AE to create a token with zero supply.

Minting ProtocolDAO tokens

mint_protocol_dao_token(buy_value, receiver) is called by a sale contract during every buy, not by users. The factory checks the caller:

require(Set.member(Call.caller, state.known_token_sales), "NOT_KNOWN_TOKEN_SALE")
state.protocol_dao_token.mint(receiver, buy_value * protocol_dao_token_multiplier)

Only sales the factory itself deployed are in known_token_sales, so no other contract can mint protocol tokens.

The multiplier is a literal in the contract:

// mints protocol DAO tokens at a rate of 1000 times the buy value.
let protocol_dao_token_multiplier = 1000

A 1 AE buy mints 1,000 ProtocolDAO tokens to the buyer. The base is the net AE the buyer paid (Call.value minus any overpayment refund), which includes the affiliation fee as well as the curve price.

An initial buy at creation also mints them. The sale skips its callback when the caller is the factory, and create_community performs the mint itself instead:

bonding_curve_token_sale.buy_with_affiliation(value = Call.value - overpaid_refund,
                                              initial_buy_count, Call.caller)
bonding_curve_token_sale.token_contract().transfer(Call.caller, initial_buy_count)
state.protocol_dao_token.mint(Call.caller, (Call.value - overpaid_refund) * protocol_dao_token_multiplier)

The transfer on the middle line exists for the same reason: the sale mints community tokens to its Call.caller, which during creation is the factory, so the factory forwards them to you. A creator doing an initial buy receives the tokens and the ProtocolDAO tokens exactly as an ordinary buyer would.

The factory refunds overpayment before the buy, against bonding_curve_token_sale.price(initial_buy_count), and aborts with VALUE_HAS_TO_BE_GREATER_THAN_BUY_PRICE if you attach less than that.

Related