AffiliationTreasury
AffiliationTreasury.aes is shared by every community. It holds the referral tree, the funded
invite codes, and the accumulated AE rewards each account can withdraw. Every token sale calls
into it on every buy.
| Network | Address |
|---|---|
| Mainnet | ct_2GG42rs2FDPTXuUCWHMn98bu5Ab6mgNxY7KdGAKUNsrLqutNxZ |
| Testnet | ct_2QmAcPxY4TBbFmkSUhxU4UTwoRot8SMmZzaAKL6oyHmQqRL1rK |
Or resolve it at runtime with factory.affiliation_treasury(). Its interface is inlined in
CommunityFactory.aci.json.
Entrypoints
| Entrypoint | Signature | Flags |
|---|---|---|
init | () => AffiliationTreasury.state | |
register_invitation_code | (invitation_addresses: list(address), redemption_fee_cover: int, invitation_amount: int) => unit | stateful, payable |
revoke_invitation_code | (invitation_address: address) => unit | stateful |
redeem_invitation_code | (invitee_address: address) => unit | stateful |
register_invitation | (inviter_address: address) => unit | stateful |
record_sale_transaction | (buyer: address, transaction_value: int) => unit | stateful, payable |
withdraw | () => unit | stateful |
get_accumulated_rewards | (account: address) => int | |
get_direct_inviter | (account: address) => option(address) | |
get_affiliation_tree | (address: address) => address | |
affiliation_tree | () => map(address, address) | |
get_affiliation_fee_percentage | () => int | |
get_affiliation_fee_percentages | () => list(int) | |
get_affiliation_fee_precision | () => int | |
get_invitation_code | (address: address) => tuple(address, int, bool) | |
invitation_codes | () => map(address, tuple(address, int, bool)) | |
get_unique_invitee | (address: address) => AffiliationTreasury.unique_invitees_state | |
unique_invitees | () => map(address, AffiliationTreasury.unique_invitees_state) | |
get_payout_period | () => int | |
payout_periods | () => map(int, map(address, int)) | |
get_state | () => AffiliationTreasury.state |
Types
datatype unique_invitees_state =
ThresholdReached
| WaitingForInvitations(Set.set(address), map(address, int))
// invitees seen, per-invitee buy volume
record state =
{ payout_periods : map(int, map(address, int))
, affiliation_tree : map(address, address) // invitee -> direct inviter
, invitation_codes : map(address, (address * int * bool)) // inviter, amount, redeemed?
, unique_invitees : map(address, unique_invitees_state) }invitation_codes is keyed by the invite address: the public key of a throwaway keypair
whose secret key is the invite link. Each entry is a three-element tuple, decoded in
JavaScript as an array:
const [inviter, amount, redeemed] = await treasury
.get_invitation_code('ak_invite...')
.then((r) => r.decodedResult);The fee split
let affiliation_fee_precision = 1 * 10 ^ 5 // 100000
let affiliation_fee_percentages = [300, 125, 50, 25]
entrypoint get_affiliation_fee_percentage(): int = 500 // 0.5%, the sum of the four levels| Level | Constant | Share of the transaction |
|---|---|---|
| 1 (the direct inviter) | 300 | 0.300% |
| 2 | 125 | 0.125% |
| 3 | 50 | 0.050% |
| 4 | 25 | 0.025% |
| Total | 500 | 0.500% |
get_affiliation_fee_percentage() returns the pre-summed 500;
get_affiliation_fee_percentages() returns the four-level list. Divide by
get_affiliation_fee_precision() rather than a hardcoded 100000.
How a buy pays out
record_sale_transaction(buyer, transaction_value) is payable. The token sale calls it on
every buy with the affiliation share attached. The treasury checks that the attached AE covers
the fee, then walks up the buyer's inviter chain crediting each level:
let required_fee = (get_affiliation_fee_percentage() * transaction_value) / affiliation_fee_precision
require(Call.value >= required_fee, "AMOUNT_IS_NOT_EQUAL_TO_THE_AFFILIATION_PERCENTAGE")
update_inviters(buyer, buyer, transaction_value, affiliation_fee_percentages)update_inviters walks affiliation_tree from the buyer up to four levels, crediting each
inviter its share. A level with no inviter is not credited: a buyer with no inviter credits
nobody, and a buyer whose chain is two deep credits levels 1 and 2 only. AE for uncredited
levels remains in the treasury contract and is not attributed to any account.
Rewards accumulate in the contract until the recipient calls withdraw().
Invite codes
An invite is a funded keypair. Generate a throwaway account, register its public key with the treasury along with a gift amount, and hand the secret key to the invitee as a link.
import { MemoryAccount } from '@aeternity/aepp-sdk';
const REDEMPTION_FEE_COVER = 10n ** 15n; // 0.001 AE, the contract minimum
const inviteAmount = BigInt(new BigNumber(1).shiftedBy(18).toFixed(0)); // 1 AE gift
const keyPairs = Array.from({ length: 5 }, () => MemoryAccount.generate());
const totalCost = (inviteAmount + REDEMPTION_FEE_COVER) * BigInt(keyPairs.length);
await treasury.register_invitation_code(
keyPairs.map((kp) => kp.address),
REDEMPTION_FEE_COVER,
inviteAmount,
{ amount: totalCost.toString() },
);
// The link carries the secret key:
const links = keyPairs.map((kp) => `https://superhero.com#invite_code=${kp.secretKey}`);The contract enforces both numbers exactly:
let minimum_redemption_fee_cover = 10 ^ 15
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")| Rule | Value |
|---|---|
Minimum redemption_fee_cover | 10 ^ 15 aettos = 0.001 AE per invite |
Required Call.value | exactly (redemption_fee_cover + invitation_amount) * number of addresses |
invitation_amount semantics | per invite, not a total |
The redemption fee cover exists because a brand-new invitee has no AE and cannot pay for their
own redemption transaction. The real cost is around 2.01e14 aettos; the minimum is set
several times higher. At registration the contract spends the fee cover straight to each
invite address, so the throwaway account can pay for its own redemption call.
Revoking
revoke_invitation_code(invitation_address) refunds the invitation_amount to the inviter and
deletes the entry from invitation_codes:
require(!is_redeemed, "ALREADY_REDEEMED")
require(Call.caller == inviter_address, "CALLER_NOT_INVITER")
Chain.spend(Call.caller, invitation_amount)Only the original inviter may revoke, and only before redemption. The redemption_fee_cover
is not part of the refund, because it was spent to the invite address at registration. If you kept
the keypair, sweep it from the invite account with a plain spend.
Redeeming
redeem_invitation_code(invitee_address) is called by the invite address itself. The
invitee's client signs with the secret key from the link, and the contract looks the code up by
Call.caller. It marks the code redeemed, spends the gift to invitee_address, and writes
affiliation_tree[invitee_address] = inviter_address.
It aborts with INVITER_EQUAL_INVITEE if the inviter and invitee are the same account, and
with INVITEE_ALREADY_REGISTERED if the invitee already has an inviter. An account is adopted
into the tree once and is never re-parented.
Registering an inviter without a code
register_invitation(inviter_address) records inviter_address as the caller's direct
inviter, with no invite code and no payment. It requires that the caller has no inviter yet
(INVITEE_ALREADY_REGISTERED) and emits UserInvited(invitee, inviter).
Unique invitees and the payout threshold
An inviter becomes eligible to withdraw once their invitees have traded. That is what
unique_invitees_state tracks:
datatype unique_invitees_state =
ThresholdReached
| WaitingForInvitations(Set.set(address), map(address, int))While an account is WaitingForInvitations, the contract holds the set of invitees seen so
far and how much each of them has bought. Two constants govern the transition:
| Constant | Value | Meaning |
|---|---|---|
unique_invitee_count_threshold | 4 | how many qualifying invitees are needed |
unique_invitee_buy_amount_threshold | 10 * 10 ^ 18 | 10 AE of buys before an invitee counts |
An invitee counts toward your threshold once they have bought at least 10 AE worth, and you
need four such invitees before your state flips to ThresholdReached.
const state = await treasury.get_unique_invitee('ak_me...').then((r) => r.decodedResult);
if ('ThresholdReached' in state) {
// eligible
} else {
const [invitees, volumes] = state.WaitingForInvitations;
console.log('qualifying invitees so far:', invitees.size);
}Payout periods and withdraw
let payout_period_interval = 2592000000 // 1000ms * 60s * 60m * 24h * 30d
entrypoint get_payout_period() : int = Chain.timestamp / payout_period_intervalThe period is a 30-day bucket derived from the chain clock, not a per-account timer.
payout_period_interval is in milliseconds, matching Chain.timestamp, and the bucket index
is a plain integer division: every account rolls over to the next bucket at the same instant,
each time the clock passes the next multiple of 2,592,000,000 ms since the Unix epoch.
payout_periods maps each bucket index to a map of account to amount.
What withdraw() checks and pays
stateful entrypoint withdraw() =
require(state.unique_invitees[Call.caller = WaitingForInvitations(Set.new(), {})]
== ThresholdReached, "MINIMUM_ACCOUNTS_THRESHOLD_NOT_REACHED")
let reward = get_accumulated_rewards(Call.caller)
Chain.spend(Call.caller, reward)
put(state { payout_periods[get_payout_period() = {}] @ p = p{ [Call.caller] = 0 }})
put(state { payout_periods[get_payout_period() - 1 = {}] @ p = p{ [Call.caller] = 0 }})
Chain.event(Withdrawn(Call.caller, reward))- The gate is the four-invitee threshold.
MINIMUM_ACCOUNTS_THRESHOLD_NOT_REACHEDis the only abort. There is no cooldown, no minimum amount, and no limit on how often you may call it; a second call in a row pays zero. - The formula is a rolling two-bucket window.
get_accumulated_rewardsispayout_periods[current][account] + payout_periods[current - 1][account], andwithdrawzeroes exactly those two buckets.
Rewards expire
Anything credited three or more buckets ago is no longer withdrawable. Because buckets are absolute rather than relative to when you earned, the grace period is between 30 and 60 days: a reward credited on the first day of a bucket survives that bucket plus the whole of the next one; a reward credited on the last day of a bucket has a few hours plus 30 days.
Withdraw at least once a month, and treat get_accumulated_rewards() as a window rather than
a running total.
Events
datatype event =
UserInvited(address, address) // invitee, inviter
| RewardAdded(address, int) // user, added amount
| Withdrawn(address, int) // user, reward amount
| InvitationRegistered(address, address, int) // caller, invitation_address, amount
| InvitationRedeemed(address, address, address) // invitee_address, inviter_address, caller
| InvitationRevoked(address, address) // invitation_address, inviter_address
| RecordSaleTransaction(address, int) // buyer, amountNotes for indexers:
InvitationRedeemedandInvitationRevokedorder their addresses differently. Redeemed is invitee-then-inviter; revoked is invite-address-then-inviter. ThecalleronInvitationRedeemedis the invite address, since redemption is signed with the invite key.UserInvitedfires only fromregister_invitation. A code-based invite emitsInvitationRedeemedinstead, even though both write the sameaffiliation_treeentry. Watch both events to capture every invite relationship.RewardAddedfires once per credited level, so a single buy under a four-deep chain emits four of them plus oneRecordSaleTransaction.
Tree shape
affiliation_tree is map(address, address): one direct inviter per account, written once
and never re-parented. The tree itself has no depth limit; the payout walk visits at most four
ancestors, one per entry in affiliation_fee_percentages. Level five and beyond exist in the
data and earn nothing.
Related
- AffiliationBondingCurveTokenSale: the contract that funds this one on every buy
- Affiliation: the reward programme explained for participants
- Invite links: creating and sharing invites from the app
- Claiming rewards: the withdrawal flow