A loyalty programme on a contract of your own. Your back office awards points against purchases; your customers redeem them for rewards from accounts they control, without ever buying gas. You decide what you pay for and how often, and you see what each redemption cost.
Recorded from the real console on a sandbox, a private test network where nothing has value: points awarded, a coffee redeemed by a customer who holds no ether while the programme pays the gas, the back office receiving it, a request the programme does not pay for, and what each redemption cost.
The contract is ours to show and yours to read: a short layer over OpenZeppelin Contracts, the audited, public library most tokens are built on. CryptoRail deploys it, registers it under a name, and your systems award points by that name. When a customer redeems, CryptoRail prepares the operation for their own account, the customer signs it in your application, and your paymaster pays. Their key never reaches CryptoRail.
flowchart TD
R[Your repository: the points contract on OpenZeppelin] --> B[Compiled with pinned settings]
B --> D[Deployed through CryptoRail, registered under a name]
D --> A[The programme awards points by name]
A --> C[The customer redeems from their own account]
C --> S[The customer signs in your application]
S --> P[Your paymaster pays the gas, within your policy]
P --> E[Every event and every cost to your back office]As with tokenisation, the contract here is ours: its whole source is on this page.
Points awarded with a reference, redeemed for a reward, never passed between customers, pause, roles. See it in code ↓
The committed build, deployed by your programme's identity and registered by name in one command. See it in code ↓
Your paymaster pays for redemptions only, a number of times per customer and day, up to a cost each. See it in code ↓
Awards and redemptions reach your back office as signed webhooks, and each redemption's cost is reported. See it in code ↓
Your customers never hold gas and never hand over their keys. The contract refuses what its rules forbid, your sponsorship policy refuses what you will not pay for, and a refusal always says why.
On every change, an end-to-end scenario starts a network that charges gas, with its own bundler and paymaster, deploys the contract through CryptoRail and follows a customer, whose key never held ether, from their first points to their rewards. A wallet asking for sponsorship by itself, through the standard interface, has a scenario of its own. Both, and the requirements behind them, are listed on the evidence page.
The contract in full, how it is built, how CryptoRail deploys it and turns its ABI into calls by name, the sponsorship policy, a sponsored redemption from the customer's own account, and what your back office receives. The calls below are the ones the scenario makes on every change.
Everything that is ours is below. The rest is OpenZeppelin Contracts 5.7.0, used as published:
// SPDX-License-Identifier: MIT pragma solidity 0.8.30; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC20Pausable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol"; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; /// @title Loyalty points of a customer programme (CryptoRail customer programmes pack, FR-PLT-16) /// @notice Whole points the programme awards to its customers and the customers redeem for rewards. /// Points never move between customers: they are awarded, then redeemed by burning them with the /// reward's reference the programme's back office reconciles. The administrator pauses every /// movement. Every operation emits an event. contract LoyaltyPoints is ERC20, ERC20Pausable, AccessControl { /// @notice May award points to customers. bytes32 public constant PROGRAMME_ROLE = keccak256("PROGRAMME_ROLE"); /// @notice Points awarded to `customer`; `ref` is the programme's own reference (a purchase, a visit). event Awarded(address indexed customer, uint256 points, bytes32 indexed ref); /// @notice Points burnt by `customer` for the reward `reward`, which the back office delivers. event Redeemed(address indexed customer, uint256 points, bytes32 indexed reward); /// @notice Points are not transferable between customers. error TransfersDisabled(); constructor(string memory name_, string memory symbol_, address admin, address programme) ERC20(name_, symbol_) { _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(PROGRAMME_ROLE, programme); } /// @notice Points are whole: no fractions of a point. function decimals() public pure override returns (uint8) { return 0; } /// @notice Awards `points` new points to `customer`. function award(address customer, uint256 points, bytes32 ref) external onlyRole(PROGRAMME_ROLE) { _mint(customer, points); emit Awarded(customer, points, ref); } /// @notice Burns `points` of the caller's points for the reward `reward`. function redeem(uint256 points, bytes32 reward) external { _burn(msg.sender, points); emit Redeemed(msg.sender, points, reward); } function pause() external onlyRole(DEFAULT_ADMIN_ROLE) { _pause(); } function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { _unpause(); } /// @dev Every movement passes here: an award (from zero) and a redemption (to zero) are the only /// ones allowed; a transfer between two customers is refused. function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Pausable) { if (from != address(0) && to != address(0)) revert TransfersDisabled(); super._update(from, to, value); } }
Compiled with Foundry with every setting pinned, so the same source always gives the same bytecode. The artifact is committed, and every change checks that it is still what the source compiles to.
# Compiler settings are pinned so the committed artifact is reproducible (FR-PLT-10). # The Besu consortium network of the journeys runs the London rules, hence the EVM version; # no metadata hash, so the bytecode does not depend on the machine's paths. [profile.default] src = "src" out = "out" cache_path = "cache" libs = ["node_modules"] remappings = ["@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/"] solc_version = "0.8.30" evm_version = "london" optimizer = true optimizer_runs = 200 bytecode_hash = "none" cbor_metadata = false auto_detect_solc = false offline = false
# contracts/: OpenZeppelin pinned through npm, compiled with Foundry pnpm install --frozen-lockfile pnpm --filter @cryptorail/contracts compile # -> contracts/artifacts/LoyaltyPoints.json: ABI, bytecode, # compiler settings, OpenZeppelin version (committed) # What CI runs on every change: the committed artifact # must be exactly what the source compiles to pnpm --filter @cryptorail/contracts compile:check
One command deploys the points from your programme's identity, registers their ABI and their address under a name, and subscribes your back office. The programme pays the gas of its own operations, like any company account.
# Where the points will live: known before they exist, # so the policy can name them node packages/pack-loyalty/dist/bin.js predict \ --deployer <programme address> --nonce 0 # Deploy them, register their ABI and their address under a name, # subscribe the back office to every event CRYPTORAIL_API_KEY=<admin key> \ node packages/pack-loyalty/dist/bin.js setup \ --url <service URL> --chain <chain id> \ --deployer-identity <programme identity id> \ --name "Pilot Rewards" --symbol PRW \ --admin <address> --programme <address> \ --webhook <back office URL> # It prints the address, the contract id (points-prw) # and the webhook's secret once
// What the setup does, call by call import { deployData, LOYALTY_POINTS } from '@cryptorail/pack-loyalty' // 1. The ABI, straight from the build const abi = await cryptorail.POST('/v1/abis', { body: { fragments: LOYALTY_POINTS.abi }, }) // 2. The deployment: the bytecode with its constructor arguments, // signed by the programme's identity, under your policy const deploy = await cryptorail.POST('/v1/transactions', { params: { header: { 'idempotency-key': 'deploy-points-prw' } }, body: { chainId, actor: { kind: 'identity', identityId: programmeId }, target: { kind: 'deploy', bytecode: deployData({ name: 'Pilot Rewards', symbol: 'PRW', admin, programme }), abiId: abi.data!.id, }, }, }) // Once CONFIRMED, the transaction carries deployedAddress // 3. The address, under a name of yours const points = await cryptorail.POST('/v1/contracts', { body: { chainId, address: deployedAddress, label: 'points-prw', abiId: abi.data!.id }, })
What your paymaster pays for: a redemption on these points, and nothing else, a number of times per customer and day, up to a cost each. It names the points by the address they will have, so the service is configured before anything is deployed.
import { sponsorshipPolicy } from '@cryptorail/pack-loyalty'
const policy = sponsorshipPolicy({
version: 'rewards-2026-10', // recorded with every decision
chainId,
points: predictedAddress, // from the predict command
redemptionsPerCustomerPerDay: 2, // what the programme pays for
maxCostWei: 50_000_000_000_000_000n, // the most one redemption may cost
})
// The paymaster pays for redeem() on these points and nothing else
// Service configuration:
// adapters: { policy: { kind: 'rules', options: policy } }
// paymasters: [{ address, entryPoint, signerIdentityId, ... }]Awarding is an ordinary call by name from your programme's identity. Redeeming is the same call by name, made for the customer's own smart account and paid by your paymaster. The account is created by its first redemption, at your expense, so a new customer needs nothing to start.
// The programme awards points by name, against a purchase: // an ordinary transaction its own identity signs and pays for await cryptorail.POST('/v1/transactions', { params: { header: { 'idempotency-key': 'purchase-1001' } }, body: { chainId, actor: { kind: 'identity', identityId: programmeId }, target: { kind: 'contract', contractId: pointsId, function: 'award', args: [customerAccount, '100', toBytes32('PURCHASE-1001')] }, }, }) // The customer's account may not exist yet: points wait for it
// The customer redeems, by name, from their own smart account. // Your application asks; the paymaster pays const { data } = await cryptorail.POST('/v1/transactions', { params: { header: { 'idempotency-key': 'reward-coffee-7' } }, body: { chainId, executionMode: 'userOperation', actor: { kind: 'smart-account', address: customerAccount, ownerIdentityId: customerId }, payer: { kind: 'paymaster', paymasterId }, target: { kind: 'contract', contractId: pointsId, function: 'redeem', args: ['30', toBytes32('REWARD-COFFEE')] }, }, }) // CryptoRail builds the operation (with the account's creation // on its first redemption) and asks the customer to sign
CryptoRail hands your application the exact hash to sign and what it means. The customer signs it with their own wallet; CryptoRail checks the signature and submits. Who pays the gas: your paymaster, never the customer. What you will not pay for is refused before anything is signed, with its reason.
// In your application, with the customer's own wallet: // the key never reaches CryptoRail const [request] = (await cryptorail.GET('/v1/signing-requests')) .data!.items.filter((r) => r.transactionId === data!.transactionId) // request.decoded: { kind: 'userOperation', sender: customerAccount, ... } const signature = await customerWallet.sign({ hash: request.payloadHash }) await cryptorail.POST('/v1/signing-requests/{id}/signature', { params: { path: { id: request.signingRequestId } }, body: { signedPayload: signature }, }) // Submitted through the bundler, followed to CONFIRMED
// The customer's application asks to pass points to a friend: // the policy pays only for redemptions, so nothing is signed { "state": "REJECTED_BY_POLICY", "failure": { "stage": "sponsorship", "cause": "policy.sponsorship-not-allowed", "message": "a call uses a function that is not sponsored", … } } // A third redemption in a day: policy.limit-exceeded. // The contract itself refuses transfers: TransfersDisabled()
Every event of the points arrives decoded and signed. A redemption carries the reward your back office delivers, and each sponsored redemption reports what it cost.
// In the back office's webhook endpoint, after verifyWebhook(...) for (const event of JSON.parse(rawBody).events) { // event.payload: { contractLabel: 'points-prw', // eventName: 'Redeemed', // params: { customer, points, reward }, … } // Awarded, Redeemed, Transfer, Paused, Unpaused, RoleGranted, // RoleRevoked: all of them // event.id is stable: deliver each reward exactly once } // What each sponsored redemption cost the paymaster await cryptorail.GET('/v1/sponsored-operations', { params: { query: { sender: customerAccount, paymasterId } }, }) // items[].cost.totalWei, per operation; exportable by period
We show the pack on a test network where nothing of value moves. A pilot gives you your own:A pilot environment stood up in minutes with nothing installed on your side.