Pilot pack · Customer programmes

Your customers redeem, you pay the gas.

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.

Demo

The console, in three minutes.

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.

How it works

From your contract's source to your customer's reward.

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.

See it in code ↓

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]
What the pack brings

Four parts, ready for a pilot.

As with tokenisation, the contract here is ours: its whole source is on this page.

Contract

On OpenZeppelin

Points awarded with a reference, redeemed for a reward, never passed between customers, pause, roles. See it in code ↓

Deployment

Through CryptoRail

The committed build, deployed by your programme's identity and registered by name in one command. See it in code ↓

Gas

Paid by you, within limits

Your paymaster pays for redemptions only, a number of times per customer and day, up to a cost each. See it in code ↓

Events

Your books follow

Awards and redemptions reach your back office as signed webhooks, and each redemption's cost is reported. See it in code ↓

The controls

What the programme can rely on.

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.

  • Your end customers act without holding gas: CryptoRail sponsors their operations through ERC-4337 paymasters, and their keys stay in their own accounts.
  • Sponsorship budgets that are never exceeded, even under concurrent requests, with the cost attributed to each account and policy.
  • Only the canonical EntryPoint and public account and paymaster contracts from audited lineages, pinned per network; no custom contract.
  • Deploy your own contracts from CryptoRail, on a test network or a real one, under your policies, your approvals and your signer; the confirmed transaction returns the contract's address.
  • Once a contract is registered, every one of its functions can be called through the API by the contract's name, arguments checked against its ABI.
  • Every failure comes with a report: the stage, a stable cause, whether a retry can work and on what condition, and the suggested action.
  • Signed webhooks, retried for about three days, then held in a dead-letter queue an operator can redeliver from.
  • A ready-made customer programmes pilot pack: a loyalty points contract of our own on OpenZeppelin Contracts (points awarded with a reference, redeemed for a reward, never transferred between customers, pause, roles), compiled with pinned settings and checked on every change, an example sponsorship policy and a one-command setup that deploys it and registers it by name.
Evidence

Proven on every change, with a customer who holds no ether.

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.

For engineers

How it is done, in code.

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.

Step 1

The contract, in full

Everything that is ours is below. The rest is OpenZeppelin Contracts 5.7.0, used as published:

  • ERC20: Balances of the standard token; its transfers are switched off
  • ERC20Pausable: Awards and redemptions stop while paused
  • AccessControl: Roles: who may award points, who may pause
  • Pausable: The paused state, inherited through ERC20Pausable
  • ERC165: Interface detection, inherited through AccessControl
  • Context: Who is calling, inherited by all of the above
// 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);
    }
}
Step 2

Build it

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
Step 3

Deploy it and register its ABI

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 },
})
Step 4

The sponsorship policy

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, ... }]
Step 5

The endpoints: award and redeem by name

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
Step 6

The customer signs, in your application

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()
Step 7

What your back office receives

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
Demo

See it run, then try it on yours.

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.