This document is the canonical reference for PayFlow's referral tracking architecture, fee/payout mechanics for integrators, referral link and code workflows, on-chain APIs, and frontend TypeScript integration.
A shorter usage overview also lives in REFERRAL.md. For storage TTL details see architecture/storage_and_ttl.md. For the full function catalog see API.md.
- Design Summary
- Architecture Overview
- On-Chain Data Model
- Lifecycle and State Transitions
- Integration Points
- Fee Distribution and Payout Mechanics
- Generating and Tracking Referral Links and Codes
- API Reference
- CLI Examples
- Frontend Integration (TypeScript)
- Indexer and Analytics Patterns
- Error Handling
- Security Considerations
- Related Source Files
PayFlow's referral feature is an on-chain attribution layer, not an on-chain reward vault.
| Concern | On-chain? | Where |
|---|---|---|
| Record who referred a subscriber | Yes | DataKey::Referral(user) + Subscription.referrer |
| Emit attribution event | Yes | referred event |
| Reject self-referral | Yes | ContractError::SelfReferral (code 11) |
| Split protocol fees to a fee collector | Yes | fee.rs (FeeCollector / FeeBps) — not referrer-aware |
| Pay referrer bonuses / commissions | No | Off-chain (or a separate reward contract) using events + get_referrer |
The contract stores a single optional referrer address per subscriber and emits a referred event when that address is set. Integrators use that attribution signal to run signup bonuses, recurring commissions, or tiered rewards outside the core FlowPay transfer path.
┌─────────────────┐ referral code / link ┌──────────────────┐
│ Referrer │ ─────────────────────────────▶│ Referred user │
│ (Stellar addr) │ │ (wallet / dApp) │
└────────┬────────┘ └────────┬─────────┘
│ │
│ off-chain tracking │ subscribe(..., referrer)
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ Analytics / │◀──── referred + charged ──────│ FlowPay contract │
│ rewards worker │ events (RPC / indexer) │ referral.rs │
└────────┬────────┘ └────────┬─────────┘
│ │
│ optional payout │ persistent
▼ ▼
┌─────────────────┐ DataKey::Referral(user)
│ Token transfer │ Subscription.referrer
│ to referrer │
└─────────────────┘
| Layer | Responsibility |
|---|---|
contract/src/referral.rs |
Store, read, and clear DataKey::Referral(user); reject self-referral; emit referred. |
contract/src/lib.rs (subscribe_inner, cancel_inner) |
Pass referrer into storage on subscribe; remove referral on cancel; expose get_referrer. |
contract/src/events.rs |
publish_referred(env, user, referrer). |
| Frontend / integrator | Resolve a referral code or link to a Stellar address; pass it as referrer on subscribe. |
| Off-chain rewards service | Index referred / charged events; compute commissions; pay referrers. |
// DataKey variant in contract/src/lib.rs
Referral(Address), // keyed by subscriber (referred user)| Property | Value |
|---|---|
| Storage type | Persistent |
| Value type | Address (the referrer) |
| Written by | referral::store_referral during subscribe / subscribe_with_metadata |
| Removed by | referral::remove_referral during cancel / cancel_and_refund_prorated, or store_referral(..., None) on resubscribe |
| Read by | get_referrer(user) and off-chain indexers |
The same optional address is also mirrored on the subscription record:
pub struct Subscription {
// ...
pub referrer: Option<Address>,
// ...
}get_subscription(user) therefore returns the referrer snapshot that was written at the last successful subscribe. Prefer get_referrer(user) when you only need attribution — it reads the dedicated referral key and returns None after cancellation clears it.
subscribe(user, ..., referrer: Some(R))
│
├─▶ Subscription { referrer: Some(R), ... } // persistent Subscription(user)
├─▶ DataKey::Referral(user) = R // persistent Referral(user)
└─▶ event referred(user) → R
subscribe(referrer=None)
[no referral] ─────────────────────────────▶ [no referral]
│
│ subscribe(referrer=R)
▼
[Referral(user)=R]
│
├─ subscribe(referrer=R2) ──────────▶ [Referral(user)=R2] // replaced
├─ subscribe(referrer=None) ────────▶ [cleared]
└─ cancel / cancel_and_refund_* ───▶ [cleared]
Rules enforced by the running contract (contract/src/referral.rs + subscribe_inner / cancel_inner):
- Optional. Omitting a referrer (
None/ CLInull) stores nothing. - No self-referral. If
referrer == user, the call panics withSelfReferral(error11). - Resubscribe updates. A later
subscribewith a different referrer overwrites the stored address and emits a newreferredevent. - Resubscribe with
Noneclears. Passingreferrer: NoneremovesDataKey::Referral(user). - Cancel clears referral storage.
cancel_innercallsreferral::remove_referral. The cancelledSubscriptionrecord may still show a historicalreferrerfield until the user resubscribes;get_referrerreturnsNoneafter cancel.
Note: Older lifecycle notes that describe referrals as “immutable after first write” or “surviving cancel” do not match the current implementation. Treat this document and
referral.rsas authoritative.
Entry points that accept referrer: Option<Address>:
subscribe(env, user, merchant, amount, interval, token, trial_period, referrer)subscribe_with_metadata(..., referrer, label)
Both funnel into subscribe_inner, which calls referral::store_referral.
cancel and cancel_and_refund_prorated remove the referral key so a cancelled subscriber is no longer attributed for new commission calculations that key off get_referrer.
get_referrer(user) -> Option<Address> — no auth required.
| Event | Topics | Payload | When |
|---|---|---|---|
referred |
("referred", user) |
referrer: Address |
Referrer successfully stored on subscribe |
subscribed |
("subscribed", user) |
subscription fields | Every successful subscribe |
charged |
("charged", user) |
(merchant, amount, timestamp) |
Successful recurring charge — used for recurring commissions |
cancelled |
("cancelled", user) |
() |
Subscription cancelled |
On each successful charge() / eligible transfer, FlowPay may split the gross amount using the protocol fee configured by admin:
gross = subscription.amount
fee = gross * FeeBps / 10_000 // if FeeCollector set and bps > 0
net = gross - fee
user ──transfer_from──▶ FeeCollector (fee)
user ──transfer_from──▶ Merchant (net)
This split is independent of referrals. The referrer address is never an argument to fee::transfer_subscription_charge. Referrers do not automatically receive a share of protocol fees or merchant revenue inside FlowPay.
Use on-chain attribution + off-chain (or companion-contract) settlement.
- Index
referredevents. - Optionally wait until the first
chargedevent for thatuser(anti-sybil / proof of payment). - Transfer a fixed bonus (or credit) to the referrer from a rewards treasury.
referred(user → referrer)
│
▼
optional: wait for charged(user)
│
▼
treasury ──token──▶ referrer (fixed bonus)
- Index
chargedevents(user, merchant, amount, timestamp). - Call
get_referrer(user)(or use a cached map built fromreferred). - If a referrer exists, pay
commission = amount * commission_bps / 10_000from merchant or treasury funds.
charged(user, amount)
│
▼
get_referrer(user) → Some(R)
│
▼
commission = amount * bps / 10_000
treasury / merchant ──token──▶ R
Example numbers (off-chain, not enforced by FlowPay):
| Charge amount | Commission bps | Payout to referrer |
|---|---|---|
| 50_0000000 stroops (50 XLM) | 500 (5%) | 2_5000000 stroops |
| 10_0000000 stroops (10 XLM) | 250 (2.5%) | 2500000 stroops |
- Maintain an off-chain count of successful referrals per referrer (from
referred, optionally filtered by first charge). - Map count → commission bps (e.g. 1–5 referrals → 3%, 6+ → 5%).
- Apply Model B with the tiered rate.
Integrators sometimes fund referral commissions from the protocol fee collector wallet: admin sets FeeBps, collector receives fees on every charge, and an off-chain job redistributes a portion to referrers. That redistribution is an operational choice — FlowPay does not automate it.
Referral codes and links are off-chain. The contract only understands Stellar addresses.
referrer address: GABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOP
referral code: alice-pro (or short hash / UUID)
Store the mapping in your backend:
-- illustrative
referrers(code TEXT PRIMARY KEY, stellar_address TEXT NOT NULL UNIQUE);https://app.example.com/subscribe?ref=alice-pro
Deep-link variants:
https://app.example.com/r/alice-pro
payflow://subscribe?ref=alice-pro
On landing, persist the code for the subscribe flow:
// Capture ?ref= from the URL and keep it until subscribe succeeds
const params = new URLSearchParams(window.location.search);
const referralCode = params.get("ref");
if (referralCode) {
sessionStorage.setItem("payflow_ref", referralCode);
}async function resolveReferrer(code: string | null): Promise<string | null> {
if (!code) return null;
const res = await fetch(`/api/referrals/${encodeURIComponent(code)}`);
if (!res.ok) return null;
const body = (await res.json()) as { address: string };
return body.address; // G... or C... address string
}const code = sessionStorage.getItem("payflow_ref");
const referrer = await resolveReferrer(code);
// pass `referrer` into the contract call (see TypeScript section below)| Signal | Use |
|---|---|
Landing with ?ref= |
Click / visit analytics |
Successful subscribe + referred event |
Attribution confirmed on-chain |
First charged event |
Paid conversion (recommended for rewards) |
get_referrer(user) |
Ad-hoc lookup / support tooling |
- Code resolves to exactly one referrer address.
- Resolved address ≠ subscriber address (contract also enforces this).
- Clear or ignore the stored code after a successful subscribe to avoid accidental re-attribution on later plan changes (unless product intent is to update referrer on resubscribe).
subscribe(
env: Env,
user: Address,
merchant: Address,
amount: i128,
interval: u64,
token: Address,
trial_period: Option<u64>,
referrer: Option<Address>
)
| Parameter | Type | Referral role |
|---|---|---|
user |
Address |
Subscriber (must sign). Cannot equal referrer. |
referrer |
Option<Address> |
Optional referrer to store. None clears any prior referral on overwrite. |
Auth: user.require_auth().
Side effects when referrer is Some(R):
- Writes
DataKey::Referral(user) = R - Sets
Subscription.referrer = Some(R) - Emits
referred
Errors: SelfReferral if referrer == user; plus standard subscribe errors (MerchantNotWhitelisted, IntervalTooShort, etc.).
Same referral semantics as subscribe, with an additional label: String (max 64 bytes).
get_referrer(env: Env, user: Address) -> Option<Address>
| Parameter | Type | Description |
|---|---|---|
user |
Address |
Subscriber whose referrer to look up |
Auth: none.
Returns: Some(referrer) if DataKey::Referral(user) exists; None otherwise (never set, cleared on resubscribe, or removed on cancel).
| Function | Module | Behavior |
|---|---|---|
store_referral |
referral.rs |
Set or clear referral; emit referred when set |
get_referrer |
referral.rs |
Persistent read |
remove_referral |
referral.rs |
Delete key (used by cancel) |
Replace placeholders with your Testnet values.
soroban contract invoke \
--id <CONTRACT_ID> \
--source-account <USER_SECRET> \
--network testnet \
-- \
subscribe \
--user <USER_ADDRESS> \
--merchant <MERCHANT_ADDRESS> \
--amount 50000000 \
--interval 2592000 \
--token <TOKEN_ADDRESS> \
--trial_period null \
--referrer <REFERRER_ADDRESS>soroban contract invoke \
--id <CONTRACT_ID> \
--source-account <USER_SECRET> \
--network testnet \
-- \
subscribe \
--user <USER_ADDRESS> \
--merchant <MERCHANT_ADDRESS> \
--amount 50000000 \
--interval 2592000 \
--token <TOKEN_ADDRESS> \
--trial_period null \
--referrer nullsoroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- \
get_referrer \
--user <USER_ADDRESS>soroban contract invoke \
--id <CONTRACT_ID> \
--source-account <USER_SECRET> \
--network testnet \
-- \
subscribe \
--user <USER_ADDRESS> \
--merchant <MERCHANT_ADDRESS> \
--amount 50000000 \
--interval 2592000 \
--token <TOKEN_ADDRESS> \
--trial_period null \
--referrer nullsoroban contract invoke \
--id <CONTRACT_ID> \
--source-account <USER_SECRET> \
--network testnet \
-- \
subscribe_with_metadata \
--user <USER_ADDRESS> \
--merchant <MERCHANT_ADDRESS> \
--amount 50000000 \
--interval 2592000 \
--token <TOKEN_ADDRESS> \
--trial_period null \
--referrer <REFERRER_ADDRESS> \
--label "pro-plan"The in-repo helper buildSubscribeTx in frontend/src/stellar.ts already accepts referrer: string | null. The default subscribe form currently passes null; the snippets below show how to wire referral links end-to-end.
import {
Contract,
TransactionBuilder,
rpc,
nativeToScVal,
Address,
Networks,
BASE_FEE,
xdr,
} from "@stellar/stellar-sdk";
const RPC_URL = "https://soroban-testnet.stellar.org";
const NETWORK_PASSPHRASE = Networks.TESTNET;
const CONTRACT_ID = import.meta.env.VITE_CONTRACT_ID as string;
const server = new rpc.Server(RPC_URL);
function addressVal(addr: string): xdr.ScVal {
return Address.fromString(addr).toScVal();
}
function optionAddress(addr: string | null): xdr.ScVal {
if (!addr) {
return nativeToScVal(null, { type: "option" });
}
return nativeToScVal(
{ tag: "Some", val: addressVal(addr) },
{ type: "option" },
);
}
/** Read ?ref= and map to a Stellar address via your backend. */
export async function referrerFromUrl(
lookup: (code: string) => Promise<string | null>,
): Promise<string | null> {
const code =
new URLSearchParams(window.location.search).get("ref") ??
sessionStorage.getItem("payflow_ref");
if (!code) return null;
sessionStorage.setItem("payflow_ref", code);
return lookup(code);
}
/** Build a subscribe transaction that includes referral attribution. */
export async function buildSubscribeWithReferrer(params: {
user: string;
merchant: string;
amountStroops: bigint;
intervalSec: bigint;
token: string;
referrer: string | null;
trialPeriodSec?: bigint | null;
}): Promise<string> {
const account = await server.getAccount(params.user);
const contract = new Contract(CONTRACT_ID);
const trial =
params.trialPeriodSec == null
? nativeToScVal(null, { type: "option" })
: nativeToScVal(
{
tag: "Some",
val: nativeToScVal(params.trialPeriodSec, { type: "u64" }),
},
{ type: "option" },
);
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: NETWORK_PASSPHRASE,
})
.addOperation(
contract.call(
"subscribe",
addressVal(params.user),
addressVal(params.merchant),
nativeToScVal(params.amountStroops, { type: "i128" }),
nativeToScVal(params.intervalSec, { type: "u64" }),
addressVal(params.token),
trial,
optionAddress(params.referrer),
),
)
.setTimeout(30)
.build();
const simulated = await server.simulateTransaction(tx);
if (rpc.Api.isSimulationError(simulated)) {
throw new Error(simulated.error);
}
return rpc.assembleTransaction(tx, simulated).build().toXDR();
}import { buildSubscribeTx, DEFAULT_TOKEN } from "../stellar";
async function subscribeWithReferral(
userKey: string,
merchant: string,
amountXlm: number,
intervalSec: number,
referrerAddress: string | null,
onSign: (xdr: string) => Promise<string>,
) {
const stroops = BigInt(Math.round(amountXlm * 10_000_000));
const xdr = await buildSubscribeTx(
userKey,
merchant,
stroops,
BigInt(intervalSec),
DEFAULT_TOKEN,
referrerAddress, // null = no referral
"", // label / symbol placeholder used by current helper
);
return onSign(xdr);
}export async function getReferrer(user: string): Promise<string | null> {
const account = await server.getAccount(user);
const contract = new Contract(CONTRACT_ID);
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: NETWORK_PASSPHRASE,
})
.addOperation(contract.call("get_referrer", addressVal(user)))
.setTimeout(30)
.build();
const result = await server.simulateTransaction(tx);
if (rpc.Api.isSimulationError(result)) {
throw new Error(result.error);
}
const retval = result.result?.retval;
if (!retval || retval.switch().name === "scvVoid") return null;
// Decode Option<Address> — adjust to your project's ScVal helpers
const opt = nativeToScVal; // placeholder: use your ScValDecoder.decodeOption
void opt;
return Address.fromScVal(retval).toString();
}export async function pollReferredEvents(startLedger: number) {
const response = await server.getEvents({
startLedger,
filters: [
{
type: "contract",
contractIds: [CONTRACT_ID],
topics: [["AAAADwAAAAhyZWZlcnJlZA=="]], // scvSymbol("referred") XDR base64
},
],
limit: 100,
});
for (const event of response.events) {
// topic[1] = referred user, value = referrer address
console.log("referred event", event.id, event.topic, event.value);
}
}Prefer constructing topic filters with xdr.ScVal.scvSymbol("referred").toXDR("base64") in production rather than hard-coding XDR strings.
Recommended off-chain tables:
referral_attributions
subscriber_address PK
referrer_address
subscribed_at
first_charged_at NULL
status active | cancelled | superseded
referral_commissions
id
subscriber_address
referrer_address
charge_amount
commission_amount
charge_tx
paid_tx NULL until settled
Pipeline:
- On
referred→ upsertreferral_attributions. - On
charged→ if attribution exists andget_referrerstill matches, enqueue commission. - On
cancelled→ mark attribution cancelled; stop new commissions (historical payouts stay). - Periodically reconcile with
get_referrerfor support disputes.
| Code | Name | Cause | Integrator action |
|---|---|---|---|
11 |
SelfReferral |
referrer == user in subscribe |
Drop self codes; show “You cannot refer yourself.” |
See ERROR-CODES.md for the full catalog.
- Attribution ≠ payment. Anyone who can persuade a user to sign
subscribewith their address asreferrerclaims attribution. Validate codes server-side; rate-limit rewards; prefer paying after first successful charge. - Self-referral is blocked on-chain, but circular rings (A refers B, B refers A via two accounts) are not. Detect graphs off-chain if needed.
- Resubscribe can change the referrer. Product policy should decide whether plan changes keep the original referrer (always pass the same address) or allow updates.
- Cancel removes
DataKey::Referral. Do not assumeget_referrerremains set after cancellation. - No auth on
get_referrer. Referral relationships are public on-chain data.
| Path | Role |
|---|---|
contract/src/referral.rs |
Store / get / remove referral; self-referral check; event |
contract/src/lib.rs |
subscribe, get_referrer, DataKey::Referral, Subscription.referrer |
contract/src/events.rs |
publish_referred |
contract/src/errors.rs |
SelfReferral = 11 |
contract/src/fee.rs |
Protocol fee split (not referrer-aware) |
frontend/src/stellar.ts |
buildSubscribeTx(..., referrer, ...) |
docs/REFERRAL.md |
Short usage guide |
docs/EVENTS.md |
referred event schema |
docs/API.md |
Full API reference |
- Architecture — module map and storage strategy
- Subscriber lifecycle — cancel / resubscribe behavior
- Integration guide — general SDK patterns
- Event-driven guide — indexing
referredfor analytics