Token Loyalty Program System Development
Token loyalty program is user retention mechanism through economic incentives embedded in smart contracts. Key difference from traditional programs (cashback, app points) — transparency of accrual and users' ability to trade or transfer accumulated assets. User sees balance on-chain, not in company database.
For business this is retention tool and community building: users accumulating governance tokens become stakeholders, not just customers.
Types of Loyalty Tokens
Token type choice determines entire system mechanics.
Non-transferable (soulbound) tokens — cannot be sold or transferred. Ideal for: programs where value is status ("Gold member"), not asset. Implemented via overriding _beforeTokenTransfer in ERC-721 with revert for any transfer except mint/burn. Examples: Otterspace, Disco.
Transferable ERC-20 points — accumulated balls with market price. Risk: users can buy status without earning. Plus: liquid rewards attract more participants. Suitable for protocols with developed secondary market.
Tiered NFT (ERC-1155) — different loyalty levels as tokens. Tier 1 → Tier 2 → Tier 3 on threshold achievement. Level can be soulbound, associated privileges — separate transferable tokens.
Hybrid: soulbound points-token for counting + separate claim-able reward token. Points can't sell but can claim valuable rewards. Most common production model (Blur uses similar scheme).
Contract Architecture
Points-Token with Limited Transfer
contract LoyaltyPoints is ERC20 {
address public immutable minter; // only authorized contract
mapping(address => bool) public transferWhitelist;
modifier onlyMinter() {
require(msg.sender == minter, "Not minter");
_;
}
function _beforeTokenTransfer(
address from,
address to,
uint256 amount
) internal override {
// Allow: mint (from == 0), burn (to == 0),
// transfers to whitelist (reward contract, staking)
if (from != address(0) && to != address(0)) {
require(transferWhitelist[to] || transferWhitelist[from], "Non-transferable");
}
}
function mint(address user, uint256 amount) external onlyMinter {
_mint(user, amount);
}
}
Whitelist includes reward contract address (for exchange points → rewards) and staking contract. User cannot send points directly to another address.
Reward Contract
contract LoyaltyRewards {
LoyaltyPoints public immutable points;
IERC20 public immutable rewardToken;
// Exchange schedule: minimum threshold → reward amount
struct RewardTier {
uint256 pointsRequired;
uint256 rewardAmount;
uint256 cooldown; // between claims
}
mapping(uint256 => RewardTier) public tiers;
mapping(address => uint256) public lastClaim;
function claimReward(uint256 tierId) external {
RewardTier memory tier = tiers[tierId];
require(points.balanceOf(msg.sender) >= tier.pointsRequired, "Insufficient points");
require(block.timestamp >= lastClaim[msg.sender] + tier.cooldown, "Cooldown active");
lastClaim[msg.sender] = block.timestamp;
// Burn points (or not — depends on model)
points.burnFrom(msg.sender, tier.pointsRequired);
rewardToken.safeTransfer(msg.sender, tier.rewardAmount);
}
}
Choice: burn points on claim or not — important design decision. Burning creates deflationary mechanics and incentivizes accumulation for larger rewards. Without burning — users can receive rewards infinitely at stable accrual.
Points Accrual Mechanics
On-Chain Triggers
Most transparent variant: protocol contract directly calls points.mint() on user action.
// Inside DEX contract
function swap(...) external {
// ... swap logic ...
// Accrue points proportional to volume
uint256 pointsToMint = (amountIn * POINTS_PER_DOLLAR) / tokenPrice;
loyaltyPoints.mint(msg.sender, pointsToMint);
}
Approach simplicity: complete transparency, no off-chain components. Limitation: changing accrual rules requires main contract upgrade.
Off-Chain Calculation + Merkle Claim
More flexible approach: calculation happens off-chain (daily or weekly), user claims accumulated points via Merkle proof. Allows changing rules without upgrade, add complex calculations (cross-protocol activity, streak bonuses).
Streak and Multiplier Mechanics
Daily streak: user interacting N consecutive days gets multiplier. Implementation: on-chain storage of lastActivityDay (block.timestamp / 86400), currentStreak counter. Gap > 1 day — streak resets.
Volume tiers: accumulated volume per period determines next period's multiplier. Like airline Silver/Gold/Platinum.
Governance Integration
If loyalty points should grant voting power, standard is ERC-20Votes (OpenZeppelin). Adds checkpoint mechanism: votes balance fixed at each change block, preventing flash loan voting attacks.
contract LoyaltyPoints is ERC20Votes {
// ERC20Votes adds delegate(), getPastVotes(), getPastTotalSupply()
// User must explicitly call delegate(address) or delegate to another, otherwise voting power is zero
}
Important nuance: users must explicitly call delegate(address(this)) or delegate to someone, otherwise voting power equals zero (even with large balance). Need explaining in UI.
Anti-Fraud
Rate limiting: maximum points per transaction and per period (day/week). Protects from single accumulation via large transaction.
Activity verification: if accrual via off-chain calculation, add minimum thresholds: minimum transaction volume, minimum time between transactions (so bot script can't generate thousands of small ones).
Sybil resistance: points should accrue only to verified wallets (Gitcoin Passport, World ID) if program is open. For closed systems (KYC users) — whitelist suffices.
Stack and Integration
Solidity 0.8.x + OpenZeppelin 5.x (ERC20, ERC20Votes, AccessControl). Frontend: wagmi + viem + React for balance display and claim UI. For off-chain calculations: TypeScript + The Graph subgraph + PostgreSQL.
| Component | Development Timeline |
|---|---|
| Points contract (ERC-20 + SBT logic) | 1 week |
| Reward contract with tiers | 1–2 weeks |
| Off-chain calculation service | 2–3 weeks |
| Merkle claim system | 1 week |
| Frontend integration | 1–2 weeks |
MVP total: 4–6 weeks. Production system with antifraud, analytics and governance integration — 2–3 months.
Cost depends on token type, accrual mechanics and existing protocol integration.







