Smart Contracts
AffiliationTreasury

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.

NetworkAddress
Mainnetct_2GG42rs2FDPTXuUCWHMn98bu5Ab6mgNxY7KdGAKUNsrLqutNxZ
Testnetct_2QmAcPxY4TBbFmkSUhxU4UTwoRot8SMmZzaAKL6oyHmQqRL1rK

Or resolve it at runtime with factory.affiliation_treasury(). Its interface is inlined in CommunityFactory.aci.json.

Entrypoints

EntrypointSignatureFlags
init() => AffiliationTreasury.state
register_invitation_code(invitation_addresses: list(address), redemption_fee_cover: int, invitation_amount: int) => unitstateful, payable
revoke_invitation_code(invitation_address: address) => unitstateful
redeem_invitation_code(invitee_address: address) => unitstateful
register_invitation(inviter_address: address) => unitstateful
record_sale_transaction(buyer: address, transaction_value: int) => unitstateful, payable
withdraw() => unitstateful
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
LevelConstantShare of the transaction
1 (the direct inviter)3000.300%
21250.125%
3500.050%
4250.025%
Total5000.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")
RuleValue
Minimum redemption_fee_cover10 ^ 15 aettos = 0.001 AE per invite
Required Call.valueexactly (redemption_fee_cover + invitation_amount) * number of addresses
invitation_amount semanticsper 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:

ConstantValueMeaning
unique_invitee_count_threshold4how many qualifying invitees are needed
unique_invitee_buy_amount_threshold10 * 10 ^ 1810 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_interval

The 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_REACHED is 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_rewards is payout_periods[current][account] + payout_periods[current - 1][account], and withdraw zeroes 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, amount

Notes for indexers:

  • InvitationRedeemed and InvitationRevoked order their addresses differently. Redeemed is invitee-then-inviter; revoked is invite-address-then-inviter. The caller on InvitationRedeemed is the invite address, since redemption is signed with the invite key.
  • UserInvited fires only from register_invitation. A code-based invite emits InvitationRedeemed instead, even though both write the same affiliation_tree entry. Watch both events to capture every invite relationship.
  • RewardAdded fires once per credited level, so a single buy under a four-deep chain emits four of them plus one RecordSaleTransaction.

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