Someone is farming the referral programme

What the affiliate engine refuses on its own, what it never will, and the order to investigate a reward spike in — spot it, trace the downline, freeze the condition, then decide the referrals.

12 min readUpdated 6 August 2026affiliate, mlm, fraud, referrals, rewards

The affiliate programme pays real money on conditions you configure. The engine refuses exactly two things by itself — a user referring themselves, and a loop in the tree. Everything else is your judgement: rings of sockpuppet accounts, referrals that deposit the minimum and never trade, and conditions that quietly pay twice for the same event.

This page is what to do when the payout figure jumps.

The screens

All of these are under Extensions → Business Tools → Affiliate Program. The extension is named mlm internally and its record is titled Multi Level Marketing, so those words turn up in the extension list and in support threads — but the menu entry an operator clicks is Affiliate Program.

Screen What it is
/admin/affiliate The dashboard — tiles, monthly earnings, the referral status ring, and the ranked list of affiliates by share of total earnings
/admin/affiliate/referral The referral registry. Create, edit, delete. Status lives here
/admin/affiliate/referral/{id} One affiliate in full: their downline network, their reward history, their last six months of earnings
/admin/affiliate/condition The reward conditions — what pays, how much, in what currency, over what period
/admin/affiliate/reward The reward ledger — one row per reward accrued
/admin/affiliate/settings Program settings, including the approval switch

Permissions: access.affiliate.referral + view.affiliate.referral for the registry, access.affiliate.reward + view.affiliate.reward for the ledger, access.affiliate.settings for settings.

/admin/affiliate/condition is not in the route permission map, so opening it needs only the catch-all access.admin. The API behind it still enforces view.affiliate.condition and edit.affiliate.condition, so an under-privileged operator sees an empty editor rather than your commission rates — but this is one more reason not to hand out access.admin casually.

The three statuses, and which of them pays

A referral row is PENDING, ACTIVE or REJECTED.

Only ACTIVE earns, on both reward paths. The live path (processDirectRewards and its binary/unilevel siblings) looks up the referral with status: "ACTIVE" and returns without paying if there is none. The periodic evaluator loads referrals with status: "ACTIVE" and evaluates nothing else. So REJECTED — or PENDING — is the lever that stops a referrer earning another penny, and it takes effect on the next event or the next cron tick with no restart and no other configuration.

Status is changed one row at a time, from the row's Edit form on /admin/affiliate/referral (the Settings group carries the status select). The bulk menu on that table offers Delete and nothing else — there is no bulk approve or bulk reject in the panel. Editing a row writes an ADMIN_AFFILIATE row to the audit trail titled Update affiliate referral.

It stops future ones. Rewards that already exist stay in the ledger, claimable, until you deal with them separately — see Dealing with rewards that already exist below.

What the code already refuses

Two guards, both automatic, both worth knowing so you do not go looking for the abuse they already cover.

Self-referral is ignored outright. A user registering with their own referral code produces no referral row at all — handleReferralRegister returns early when the referrer and the referred user are the same id, and logs it. The admin create form enforces the same rule from the other side: creating a referral where referrer and referred match is refused with Referrer and referred user cannot be the same.

An ancestor loop is refused. On BINARY and UNILEVEL installs, placing a referral whose referred user is already an ancestor of the referrer fails with a 409 and the message Referral loop detected: the referred user is already an ancestor. On a binary tree, a referrer whose two child slots are full has the new member placed by breadth-first search into the next free slot below; when the whole subtree is full, the placement is refused with No available position in the binary tree.

What it does not refuse

None of these are detected, blocked or even flagged. They are yours to find.

  • Many accounts from one IP. Nothing correlates registrations by address. There is no shared-IP report anywhere in the panel.
  • Accounts that deposit the minimum and never trade. A condition with a minAmount of 50 pays on a 50-unit deposit. Ten accounts depositing 50 each and withdrawing it again is a working attack against any deposit-based condition.
  • A binary position bought by shuffling accounts. Placement is by referral order and breadth-first spillover; there is no cost and no lock-in to sitting in a good position.
  • One person holding both sides of a two-sided condition. NFT_PURCHASE / NFT_SALE and P2P_TRADE / P2P_TRADE_COMPLETION deliberately pay both sides of one trade, because normally those are two different people.

Turn the programme from automatic to reviewed

This is the single control that changes the shape of the problem.

Admin → Extensions → Business Tools → Affiliate Program → Settings → General → Program Settings → Require Approval (affiliateRequireApproval).

With it on, a new referral is created PENDING and earns nothing until you edit it to ACTIVE. On BINARY and UNILEVEL installs it is also held out of the tree while it waits, so a pending — or later rejected — referral never occupies a position, and in binary never eats one of only two child slots.

With it off, referrals are created ACTIVE at registration and start earning immediately.

The engine reads affiliateRequireApproval — the key the settings screen writes — and falls back to the older referralApprovalRequired only when the first has never been set. Neither key is seeded, so on an install where nobody has opened that screen, approval is off and every referral is ACTIVE from the moment it is created. If you intend to review referrals, open the screen and set the switch explicitly rather than assuming the default.

Approving a held referral through the row's Edit form sets the status and nothing else — the form rebuilds tree nodes only when you change which users the referral is between. The tree placement that normally happens at registration was skipped while the referral was PENDING.

So on a BINARY or UNILEVEL install running with approval on, open /admin/affiliate/referral/{id} after approving and confirm the member actually appears in the downline. DIRECT installs are unaffected — they have no tree, and the direct and cron reward paths read the referral row itself.

Conditions: where a programme accidentally pays twice

/admin/affiliate/condition is a sidebar of conditions and an editor. Each one has a type, a reward, a reward type (FIXED or PERCENTAGE), a reward currency, a minimum amount and a period (DAILY, WEEKLY, MONTHLY).

Two ACTIVE conditions overlap when they share a type or when their types resolve to a common ledger transaction type. The second test is the one that catches the case a group-by-type report cannot see:

Condition type Ledger transaction type it is evaluated against
TRADE EXCHANGE_ORDER
SPOT_TRADE EXCHANGE_ORDER
COPY_TRADING EXCHANGE_ORDER
TOKEN_PURCHASE EXCHANGE_ORDER

Four differently-named conditions, one spot fill. Enable all four at 5% and a referrer earns 20% of that order.

The platform refuses to let that happen silently. Enabling a condition that competes with one already ACTIVE comes back as a 409 naming the competitors and the combined rate — for example "Trade Commission also pays on EXCHANGE_ORDER; Spot Trade Bonus is already active there. Enabling this as well makes the combined rate 8%." The editor turns that into a confirmation rather than an error, and re-sends the request with the acknowledgement if you say yes. Disabling is never guarded, because turning payouts off cannot cost anyone money.

Three pairs are exempt because they are two-sided by design and are not double payment: NFT_PURCHASE / NFT_SALE, P2P_TRADE / P2P_TRADE_COMPLETION, and FX_TRADE_COMMISSION / FX_TRADE_VOLUME.

Two engines pay these, and only one is a cron

This matters when you want to stop the money.

  • Event-driven conditions are paid the moment the transaction happens, by the code path that created it. WELCOME_BONUS, ECOMMERCE_PURCHASE, ICO_CONTRIBUTION, STAKING, AI_INVESTMENT, INVESTMENT, NFT_PURCHASE, NFT_SALE, P2P_TRADE, COPY_TRADING, FUTURES_TRADE, BINARY_WIN, FX_TRADE_COMMISSION and their siblings are on that list.
  • Everything else is paid by processMlmReferralConditions, which runs hourly and appears on /admin/system/cron as Process MLM Referral Conditions.

The evaluator loads conditions with status: true and skips everything else, so setting a condition inactive stops it — but that is the only thing that stops it. Three reasons waiting for the cron not to run is not a plan:

  • Event-driven conditions do not touch the cron at all. They pay on the next transaction, whatever the scheduler is doing.
  • The evaluator re-checks the previous calendar period as well as the current one on every run, so a qualifying transaction is not lost because the scheduler was down when the period ended.
  • Rewards are idempotent per condition, referrer, referred user and calendar period, so a run you missed is simply performed later.

Dealing with rewards that already exist

/admin/affiliate/reward is the ledger. A row carries the referrer, the condition that granted it, the amount, and one lifecycle flag: Claimed.

isClaimed is the only flag on a reward. There is no approved / paid / failed transition and no payout row behind it. The money actually moves when the referrer presses Claim on their own affiliate page — that is what credits their wallet, and the claim endpoint only accepts a reward that is still unclaimed and belongs to the caller.

That gives you two levers on an unearned reward, and they trade off against each other:

  • Edit the row and tick Claimed. The claim endpoint will never match it again, so it can never pay — but the ledger now reads as though it was paid, which is misleading to whoever reads it next.
  • Delete the row (single or bulk, needs delete.affiliate.reward). The reward is gone and the ledger is honest about the total, but the evidence of what the engine calculated goes with it. Either path lands on the audit trail, but under two different titles: deleting one row is Delete affiliate reward, and the table's bulk delete is Bulk delete affiliate rewards. Who did it and when survives either way — just filter on the right one, and remember that clearing a ring's rewards from a filtered ledger is the bulk title.

Prefer delete when you are confident, and do it before the referrer claims — once claimed, the wallet has been credited and you are into a balance adjustment rather than a ledger edit. See Customer wallets and balance adjustments.

The investigation, in order

  1. Spot it on /admin/affiliate. The tile row gives total earnings and earning affiliates; the monthly earnings chart gives the shape. The thing to look at is the ranked affiliate list at the bottom — its bar is share of total programme earnings, which is bounded by construction and answers the only question that matters at this stage: is one account taking most of the payout.

    Two of the summary lines have exact meanings worth knowing before you read anything into them. Awaiting approval counts referral rows still PENDING. Awaiting payout means "reward accrued, not yet marked claimed", because there is no approval state to count instead. And when your conditions do not all pay in the same currency, the total prints without a symbol and the page says mixed — it is a cross-currency sum and it is not money.

  2. Open the referrer. Expand their row on /admin/affiliate/referral and press View details, or go to /admin/affiliate/referral/{id} directly. That page renders their downline network, their reward history and their last six months of earnings. A ring shows up here as a level that is suspiciously wide and was filled suspiciously fast — twenty members who all registered inside an hour is not organic growth.

  3. Cross-check the referred accounts. Take the referred users from the network view into /admin/crm/user. There is no shared-IP report, so this is done one account at a time: open the account and read the Activity tab, which lists recent sign-ins with the IP address and User-Agent recorded for each. Same address and same device string across a level is the finding. Registration dates clustered in a few minutes, and identical or near-identical email patterns, are the corroboration.

  4. Freeze the money before you touch the accounts. Set the paying condition inactive on /admin/affiliate/condition. This is step four and not step one only because you want to know which condition is paying first — but do it before you start editing referrals, because until it is off, every event that arrives is still paying.

  5. Decide the referrals. Edit each referral row and set it REJECTED (or PENDING, if you want to hold rather than refuse). Both stop future earning on both paths.

  6. Deal with the accrued rewards. /admin/affiliate/reward, filtered to that referrer. Delete the unclaimed rewards that came from the ring, or mark them claimed to freeze them — see the trade-off above.

  7. Turn approval on. If it was off, /admin/affiliate/settingsRequire Approval. It will not undo anything, but the next ring lands PENDING and earns nothing while you look at it.

  8. Check the condition before you re-enable it. If the ring exploited a minAmount that was too low, or an overlap that paid four times on one fill, fix the condition rather than re-enabling it as it was.

Everything here lands on the audit trail

Every mutation on these screens declares module ADMIN_AFFILIATE and is recorded at /admin/system/audit: Update affiliate referral, Create affiliate referral, Delete affiliate reward, Bulk delete affiliate rewards, Update affiliate condition, Update affiliate condition status. Titles are per-endpoint, so a bulk action never carries the single-row title. Filter Module to ADMIN_AFFILIATE — rather than filtering by title — to read back exactly what you did during an investigation, which is the record you will want if the affiliate disputes it.