Rewards & Affiliation
The Affiliation System

The Affiliation System

Every community-token buy on Superhero carries a 1% spread between the buy and sell price. Half of it funds the community DAO; the other half funds a four-level referral system. 0.5% of every buy is collected up front and credited to up to four levels of inviters above the buyer. Rewards accumulate in 30-day periods, and withdraw() pays the current and previous period.

The percentages, the payout period length and the qualification thresholds are constants in the contract; they cannot change without redeploying it.

The split

When an invitee buys tokens, 0.5% of the transaction value is distributed up the chain:

LevelRelationship to the buyerShare of the transaction
1The buyer's direct inviter0.300%
2That person's inviter0.125%
3One level further up0.050%
4One level further up again0.025%
Total0.500%

Worked through a buy whose curve price is 1,000 AE:

Curve price (calculate_buy_price)   1000.00 AE   <- this is transaction_value
Affiliation fee, added on top          5.00 AE   (0.5% of the curve price)
Buyer pays (total_price)            1005.00 AE

The 5.00 AE affiliation fee splits as:
  ├── level 1  (direct inviter)        3.00 AE   (0.300%)
  ├── level 2                          1.25 AE   (0.125%)
  ├── level 3                          0.50 AE   (0.050%)
  └── level 4                          0.25 AE   (0.025%)

Of the 1000.00 AE curve price:
  ├── sale reserve, redeemable by sellers   995.00 AE   (the 99.5% sell return)
  └── community DAO treasury                  5.00 AE   (the remainder)

Buyer also receives ProtocolDAO tokens minted against the AE actually spent.

The DAO's share is arithmetic rather than a separate percentage. The token sale sends the DAO Call.value - overpaid_refund - affiliation_amount - reserve, where reserve is the sell return on the tokens just minted. Because the curve returns 99.5% on a sell, the leftover is the 0.5% spread.

The affiliation constants:

let affiliation_fee_precision   = 1 * 10 ^ 5        // 100000
let affiliation_fee_percentages = [300, 125, 50, 25]
entrypoint get_affiliation_fee_percentage() = 500   // sum of the four levels

Each level credits (transaction_value * percentage) / 100000, so 300 is 0.300%, 125 is 0.125%, 50 is 0.050% and 25 is 0.025%. They sum to 500, the 0.5% aggregate that get_affiliation_fee_percentage() returns. get_affiliation_fee_percentages and get_affiliation_fee_precision return the other two literals.

The 0.5% is charged on top of the curve price, not carved out of it:

affiliation_amount = bonding_curve_price × 500 / 100000
total_price        = bonding_curve_price + affiliation_amount

The sale forwards affiliation_amount as the attached value of record_sale_transaction(buyer, bonding_curve_price), so the transaction_value the treasury splits is the curve price, excluding the fee itself. Selling pays no affiliation fee: sell() never touches the treasury.

Levels with no inviter

The treasury credits levels by walking the referral map upward, one level per entry in [300, 125, 50, 25], and stops at the first account with no inviter. There is no reallocation and no fallback recipient: a level with no inviter is not credited, and that portion of the fee remains in the treasury contract's balance.

Chain depth above the buyerCredited to inviters
4 or more0.500%
30.475%
20.425%
10.300%
0 (uninvited buyer)0.000%

The buyer pays the full 0.5% in every case.

How the referral chain is built

The treasury stores the chain as a single map, affiliation_tree: map(address, address): each address points at its direct inviter. The four levels are found by walking that map upward four times.

Two entrypoints write into it:

  • redeem_invitation_code(invitee_address): the normal path. Someone opens an invite link, the one-time key inside it signs this call, and the caller's wallet address is attached to the inviter who funded the code. See Invite Links. It rejects INVITER_EQUAL_INVITEE, so you cannot redeem your own code into your own wallet.
  • register_invitation(inviter_address): attaches the caller to an inviter directly. Any account can call it once to name its inviter; no funded code and no action by the named inviter are required.

Two read entrypoints get you back out:

  • get_direct_inviter(account) returns Some(address) or None.
  • get_affiliation_tree(address) returns the inviter address directly, and affiliation_tree() returns the whole map.

The attachment is permanent. Both write paths require the caller not to be in the map already (INVITEE_ALREADY_REGISTERED), and no entrypoint deletes from affiliation_tree. Once an address has an inviter it keeps that inviter for the life of the contract.

Unique invitees: the withdraw gate

Accruing rewards and withdrawing them are separate. Rewards accrue from the first buy in your downline. Withdrawing is gated on a qualification threshold, tracked per inviter in unique_invitees: map(address, unique_invitees_state):

unique_invitees_state
  = ThresholdReached
  | WaitingForInvitations(Set.set(address), map(address, int))
  • ThresholdReached: the gate is open permanently.
  • WaitingForInvitations(reached, in_progress): the set holds the invitees who have already crossed the spend threshold; the map holds every other invitee against their cumulative spend in aettos.
let unique_invitee_buy_amount_threshold = 10 * 10 ^ 18   // 10 AE
let unique_invitee_count_threshold      = 4

The gate is 4 unique invitees, each having bought at least 10 AE worth. The Collect Rewards card renders the set as completed entries and the map as per-invitee progress bars toward 10 AE. Calling withdraw() early reverts with MINIMUM_ACCOUNTS_THRESHOLD_NOT_REACHED; the app also renders this as MINIMUM_OF_4_ACCOUNTS_SHALL_BUY.

  • Only direct invitees count. A buy deep in your downline accrues rewards to you at levels 2–4, but only your own level-1 invitees can qualify you.
  • The 10 AE is cumulative. Ten 1 AE buys qualify an invitee exactly as one 10 AE buy does.
  • It is measured on the curve price, so the 0.5% affiliation fee on top does not count toward the invitee's 10 AE.
  • ThresholdReached is terminal. Once the fourth invitee crosses 10 AE the per-invitee accounting is discarded and the withdraw gate never re-closes.

An account that has never invited anyone has no key in the map. Reading get_unique_invitee for such an address raises a Key does not exist map error rather than returning an empty state; the app displays zero progress.

Payout periods

A payout period is a fixed 30-day slice of wall-clock time, indexed off the block timestamp:

let payout_period_interval = 2592000000 // 1000ms * 60s * 60m * 24h * 30d

entrypoint get_payout_period() : int =
  Chain.timestamp / payout_period_interval

The index is floor(unix_time_ms / 2_592_000_000). It advances on its own every 30 days, and the boundary is global (shared by every account) rather than a per-account anniversary. Periods are 30 days, not calendar months, so they drift against the calendar.

Rewards are credited into the bucket for whichever period is current when the buy is mined:

state { payout_periods[get_payout_period() = {}] @ payout_period =
          payout_period{ [inviter = 0] @ current_reward = current_reward + reward }}

Rewards accumulate in 30-day periods. withdraw() pays the current and previous period; earlier periods are no longer claimable. get_accumulated_rewards sums exactly those two buckets:

entrypoint get_accumulated_rewards(account: address): int =
  state.payout_periods[get_payout_period() = {}][account = 0]
   + state.payout_periods[get_payout_period() - 1 = {}][account = 0]

A reward is therefore claimable for between 30 and 60 days, depending where in a period it was earned: a reward credited on the first day of a period survives that period plus the whole of the next one; a reward credited on the last day of a period has 30 days left. The balance shown in the app is the last two buckets, not a lifetime total.

  • If you are accruing rewards but have not yet reached the 4 × 10 AE gate, the clock is running: rewards earned more than two periods before you qualify are no longer claimable by the time you can call withdraw().
  • Once qualified, withdraw at least once every 30 days.

withdraw() requires only the threshold:

require(state.unique_invitees[Call.caller = WaitingForInvitations(Set.new(), {})] == ThresholdReached,
        "MINIMUM_ACCOUNTS_THRESHOLD_NOT_REACHED")

It takes no arguments and pays the full two-bucket sum to Call.caller. There is no partial withdrawal. Calling it with nothing accrued succeeds and spends zero.

redemption_fee_cover

register_invitation_code takes three arguments and is payable:

register_invitation_code(
  invitation_addresses : list(address),   // one one-time address per link
  redemption_fee_cover : int,             // aettos, per link
  invitation_amount    : int              // aettos gifted, per link
)

An invite link carries the secret key of a freshly generated account; the recipient's browser reconstructs that account and uses it to sign redeem_invitation_code(<recipient wallet address>). That one-time account has never held AE, so redemption_fee_cover is the AE the inviter attaches to make that one call affordable, so the recipient claims a gift without needing any AE first.

The web app and the CLI both use 10n ** 15n aettos = 0.001 AE per link, which is the contract's minimum:

let minimum_redemption_fee_cover = 10 ^ 15 // minimal amount to cover redemption tx fees is
                                           // ~2.01e14 (overpaid by few times to be sure)
require(redemption_fee_cover >= minimum_redemption_fee_cover, "MINIMUM_REDEMPTION_AND_FEE_NOT_MEET")
require(Call.value == (redemption_fee_cover + invitation_amount) * List.length(invitation_addresses),
        "INVITATION_AMOUNT_AND_FEE_NOT_MATCHING")

The 0.001 AE floor covers the invitee's redemption gas with room to spare. The value check is exact equality, not a minimum, so attach exactly (cover + amount) × count.

The CLI's help text gives the same arithmetic:

Total cost = (amount_ae + redemption_fee) × count + gas
Redemption fee cover: 0.001 AE per invite.

At registration the contract spends redemption_fee_cover to each one-time invite address. revoke_invitation_code returns the invitation_amount of an unredeemed code to the inviter; it rejects ALREADY_REDEEMED if the code has been claimed and CALLER_NOT_INVITER if anyone other than the registering address calls it. On success the code is deleted from state, so the same one-time address cannot be re-registered.

Contract surface

AffiliationTreasury is a payable contract.

State readers

EntrypointReturns
get_state()The whole record: payout periods, affiliation tree, invitation codes, unique invitees
payout_periods()map(int, map(address, int)): accrual bucketed by period
affiliation_tree()map(address, address): every invitee to its direct inviter
invitation_codes()map(address, (address, int, bool)): one-time address to (inviter, amount, claimed)
unique_invitees()map(address, unique_invitees_state)

Per-account queries

EntrypointReturns
get_direct_inviter(account)option(address)
get_affiliation_tree(address)The inviter address
get_invitation_code(address)(inviter, amount_aettos, claimed)
get_unique_invitee(address)ThresholdReached or WaitingForInvitations(...)
get_accumulated_rewards(account)Aettos in the current period's bucket plus the previous period's
get_payout_period()Chain.timestamp / 2592000000, the current 30-day period index
get_affiliation_fee_percentage()500
get_affiliation_fee_percentages()[300, 125, 50, 25]
get_affiliation_fee_precision()100000

State-changing entrypoints

EntrypointNotes
register_invitation_code(invitation_addresses, redemption_fee_cover, invitation_amount)Payable. Funds one or many invite codes in a single call. Call.value must equal (cover + amount) × count exactly.
revoke_invitation_code(invitation_address)Inviter only, unredeemed codes only. Returns the gift amount.
redeem_invitation_code(invitee_address)Signed by the invite's one-time key; attaches the invitee and pays the gift.
register_invitation(inviter_address)Attaches the caller to an inviter without a funded code, once.
record_sale_transaction(buyer, transaction_value)Payable. Called by the token sale on every buy; credits the affiliation share into the current period's bucket. Requires Call.value >= (500 * transaction_value) / 100000.
withdraw()Pays the caller's two-bucket balance in full. Requires ThresholdReached.

The token sale calls record_sale_transaction with protected = true, so a failure inside the treasury does not abort the buy. The entrypoint accepts calls from any account with AE attached.

get_invitation_code returns field 0 as the inviter, field 1 as the amount in aettos, and field 2 as the already-claimed flag.

Find the contract

The treasury is resolved from the CommunityFactory rather than hardcoded:

  • The web app fetches the factory address from its API, initialises the factory through bctsl-sdk, and calls affiliationTreasury() on it.
  • The agent skill's superhero-invite.mjs initialises the factory at ct_25cqTw85wkF5cbcozmHHUCuybnfH9WaRZXSgEcNNXG9LsCJWTN against https://mainnet.aeternity.io and reads affiliation_treasury() from it.
NetworkAffiliationTreasury
Mainnetct_2GG42rs2FDPTXuUCWHMn98bu5Ab6mgNxY7KdGAKUNsrLqutNxZ
Testnetct_2QmAcPxY4TBbFmkSUhxU4UTwoRot8SMmZzaAKL6oyHmQqRL1rK

The web app uses the mainnet address as INVITATIONS_CONTRACT to filter middleware transaction history (/v3/transactions?contract=<that address>&caller_id=<your address>) when listing the invitation codes you have registered and revoked. On any other network, resolve through the factory or use the backend's invitation index.

Related