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:
| Level | Relationship to the buyer | Share of the transaction |
|---|---|---|
| 1 | The buyer's direct inviter | 0.300% |
| 2 | That person's inviter | 0.125% |
| 3 | One level further up | 0.050% |
| 4 | One level further up again | 0.025% |
| Total | 0.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 levelsEach 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_amountThe 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 buyer | Credited to inviters |
|---|---|
| 4 or more | 0.500% |
| 3 | 0.475% |
| 2 | 0.425% |
| 1 | 0.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 rejectsINVITER_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)returnsSome(address)orNone.get_affiliation_tree(address)returns the inviter address directly, andaffiliation_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 = 4The 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.
ThresholdReachedis 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_intervalThe 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
| Entrypoint | Returns |
|---|---|
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
| Entrypoint | Returns |
|---|---|
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
| Entrypoint | Notes |
|---|---|
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 callsaffiliationTreasury()on it. - The agent skill's
superhero-invite.mjsinitialises the factory atct_25cqTw85wkF5cbcozmHHUCuybnfH9WaRZXSgEcNNXG9LsCJWTNagainsthttps://mainnet.aeternity.ioand readsaffiliation_treasury()from it.
| Network | AffiliationTreasury |
|---|---|
| Mainnet | ct_2GG42rs2FDPTXuUCWHMn98bu5Ab6mgNxY7KdGAKUNsrLqutNxZ |
| Testnet | ct_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
- Invite Links: creating, funding, sharing and revoking the codes this contract tracks.
- Claiming Rewards: the
withdraw()flow and its error messages. - AffiliationTreasury contract: the contract reference page.
- Bonding curve fees and slippage: where the 1% spread that feeds this system originates.
- Value flow: the whole-protocol view of the same money.
- Refer & Earn on superhero.com (opens in a new tab): where you generate
codes, watch the 4 × 10 AE gate and call
withdraw().