Pilot pack · Tokenisation

Your own asset, issued and redeemed from your back office.

A tokenised asset on a network you share with partners, often without gas fees. The issuer issues against the orders it settles, only holders that compliance has allowed may hold it, holders redeem against a request your back office pays out, and large issuances wait for the board.

Demo

The console, in three minutes.

Recorded from the real console on a sandbox, a private Besu network without gas fees where nothing has value: holders allowed, an issuance confirmed live, one refused by the contract, a large one approved by the board, and a redemption the back office settles by its reference.

How it works

From your contract's source to your back office.

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 under your policy with your signer, registers it under a name, and from then on every function is a call your systems make by that name. Everything the contract does comes back to your back office as a signed notification.

See it in code ↓

flowchart TD
    R[Your repository: the contract on OpenZeppelin] --> B[Compiled with pinned settings]
    B --> D[Deployed through CryptoRail, signed by the administrator]
    D --> N[Registered under a name: every function is a call]
    N --> I[Issuer issues; large issuances wait for the board]
    N --> H[Holders transfer and redeem]
    N --> C[Compliance keeps the allowlist; the administrator pauses]
    I --> E[Every event to your back office, signed]
    H --> E
    C --> E
What the pack brings

Four parts, ready for a pilot.

Unlike the stablecoin pack, the contract here is ours: its whole source is on this page.

Contract

On OpenZeppelin

Issuance, transfers between allowed holders, redemption against a reference, pause, roles. See it in code ↓

Deployment

Through CryptoRail

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

Policy

Issuance under control

A daily cap in the asset's own units, and the board's approval from a threshold. See it in code ↓

Events

Your books follow

Issuances, transfers, redemptions, allowlist changes and pauses reach your back office as signed webhooks. See it in code ↓

The controls

What the issuer can rely on.

The contract refuses what its rules forbid, the policy refuses what yours forbid, a refusal always says why, and every approval is recorded with who signed what and when.

  • Permissioned consortium networks with free gas (Hyperledger Besu with QBFT): no fee market, no gas balance to keep topped up.
  • 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.
  • Operations above a threshold wait for a quorum of approvers, who sign an EIP-712 digest that names neither nonce nor fee.
  • Every approval, signature and decision is recorded with who, when and what, and every change made through the API lands in an audit log.
  • 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 tokenisation pilot pack: an asset contract of our own on OpenZeppelin Contracts (issuance, a holder allowlist, redemption against a reference, pause, roles), compiled with pinned settings and checked on every change, an example issuance policy and a one-command setup that deploys it and registers it by name.
Evidence

Proven on every change, on a consortium network.

On every change, an end-to-end scenario starts a four-validator Hyperledger Besu network without fees, deploys the contract through CryptoRail and calls every one of its functions by name. The scenario and the requirements behind it 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 issuance policy, 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, transfers and allowances of the standard token
  • ERC20Pausable: Every movement stops while paused
  • AccessControl: Roles: who may issue, keep the allowlist, 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 A tokenised asset for a consortium network (CryptoRail tokenisation pack, FR-PLT-11)
/// @notice An ERC-20 whose units only move between holders the compliance role has allowed.
/// The issuer role issues, a holder redeems by burning with a reference the issuer's back office
/// reconciles, and the administrator pauses every movement. Every operation emits an event.
contract TokenisedAsset is ERC20, ERC20Pausable, AccessControl {
    /// @notice May issue new units to an allowed holder.
    bytes32 public constant ISSUER_ROLE = keccak256("ISSUER_ROLE");
    /// @notice May add holders to the allowlist and remove them from it.
    bytes32 public constant COMPLIANCE_ROLE = keccak256("COMPLIANCE_ROLE");

    uint8 private immutable _DECIMALS;

    /// @notice Whether an address may hold, send and receive units.
    mapping(address holder => bool) public allowed;

    /// @notice Units issued to `to`; `ref` is the issuer's own reference (an order, a subscription).
    event Issued(address indexed to, uint256 amount, bytes32 indexed ref);
    /// @notice Units burnt by `holder`; `ref` is what the issuer's back office pays out against.
    event Redeemed(address indexed holder, uint256 amount, bytes32 indexed ref);
    event HolderAllowed(address indexed holder);
    event HolderDisallowed(address indexed holder);

    /// @notice A transfer, an issuance or a redemption involved an address that is not allowed.
    error HolderNotAllowed(address holder);

    constructor(
        string memory name_,
        string memory symbol_,
        uint8 decimals_,
        address admin,
        address issuer,
        address compliance
    ) ERC20(name_, symbol_) {
        _DECIMALS = decimals_;
        _grantRole(DEFAULT_ADMIN_ROLE, admin);
        _grantRole(ISSUER_ROLE, issuer);
        _grantRole(COMPLIANCE_ROLE, compliance);
    }

    function decimals() public view override returns (uint8) {
        return _DECIMALS;
    }

    /// @notice Issues `amount` new units to the allowed holder `to`.
    function issue(address to, uint256 amount, bytes32 ref) external onlyRole(ISSUER_ROLE) {
        _mint(to, amount);
        emit Issued(to, amount, ref);
    }

    /// @notice Burns `amount` of the caller's units; the issuer pays out against `ref` off chain.
    function redeem(uint256 amount, bytes32 ref) external {
        _burn(msg.sender, amount);
        emit Redeemed(msg.sender, amount, ref);
    }

    function allowHolder(address holder) external onlyRole(COMPLIANCE_ROLE) {
        allowed[holder] = true;
        emit HolderAllowed(holder);
    }

    /// @notice A disallowed holder keeps its balance but can neither send, receive nor redeem.
    function disallowHolder(address holder) external onlyRole(COMPLIANCE_ROLE) {
        allowed[holder] = false;
        emit HolderDisallowed(holder);
    }

    function pause() external onlyRole(DEFAULT_ADMIN_ROLE) {
        _pause();
    }

    function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) {
        _unpause();
    }

    /// @dev Every movement passes here: issuance (from zero), redemption (to zero) and transfers.
    function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Pausable) {
        if (from != address(0) && !allowed[from]) revert HolderNotAllowed(from);
        if (to != address(0) && !allowed[to]) revert HolderNotAllowed(to);
        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/TokenisedAsset.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 asset from your administrator's identity, registers its ABI and its address under a name, and subscribes your back office. On the consortium network nobody needs ether: the deployment costs nothing.

# Where the asset will live: known before it exists,
# so the policy can name it
node packages/pack-tokenisation/dist/bin.js predict \
  --deployer <administrator address> --nonce 0

# Deploy it, register its ABI and its address under a name,
# subscribe the back office to every event
CRYPTORAIL_API_KEY=<admin key> \
node packages/pack-tokenisation/dist/bin.js setup \
  --url <service URL> --chain 1338 \
  --deployer-identity <administrator identity id> \
  --name "Pilot Bond 2027" --symbol PB27 --decimals 2 \
  --admin <address> --issuer <address> --compliance <address> \
  --webhook <back office URL>
# It prints the address, the contract id (asset-pb27)
# and the webhook's secret once
// What the setup does, call by call
import { deployData, TOKENISED_ASSET } from '@cryptorail/pack-tokenisation'

// 1. The ABI, straight from the build
const abi = await cryptorail.POST('/v1/abis', {
  body: { fragments: TOKENISED_ASSET.abi },
})

// 2. The deployment: the bytecode with its constructor arguments,
//    signed by the administrator's identity, under your policy
const deploy = await cryptorail.POST('/v1/transactions', {
  params: { header: { 'idempotency-key': 'deploy-asset-pb27' } },
  body: {
    chainId: 1338,
    actor: { kind: 'identity', identityId: administratorId },
    target: {
      kind: 'deploy',
      bytecode: deployData({ name: 'Pilot Bond 2027', symbol: 'PB27',
        decimals: 2, admin, issuer, compliance }),
      abiId: abi.data!.id,
    },
  },
})
// Once CONFIRMED, the transaction carries deployedAddress

// 3. The address, under a name of yours
const asset = await cryptorail.POST('/v1/contracts', {
  body: { chainId: 1338, address: deployedAddress,
    label: 'asset-pb27', abiId: abi.data!.id },
})
Step 4

The issuance policy

The rules read the issuance itself, in the asset's units. They name the asset by the address it will have, so the service is configured before anything is deployed.

import { issuancePolicy } from '@cryptorail/pack-tokenisation'

const policy = issuancePolicy({
  version: 'issuance-2026-10',      // recorded with every decision
  asset: predictedAddress,          // from the predict command
  decimals: 2,
  approvalThreshold: '10000',       // from 10,000 units the board approves
  dailyCap: '30000',                // all issuances of a UTC day
  approverIds: [boardMemberId],
})

// Service configuration:
// adapters: { policy: { kind: 'rules', options: policy } }
Step 5

The endpoints: every function by name

Issue, transfer, redeem, allow a holder, pause: each is the same call with the function's name and its arguments, checked against the ABI before anything is signed. A call the contract would refuse is caught before it is signed, and comes back as a failure report naming the contract's reason.

// Every function of the contract is now a call by name
const call = (identityId, fn, args, key) =>
  cryptorail.POST('/v1/transactions', {
    params: { header: { 'idempotency-key': key } },
    body: {
      chainId: 1338,
      actor: { kind: 'identity', identityId },
      target: { kind: 'contract', contractId: assetId,
        function: fn, args },
    },
  })

// Compliance allows a holder
await call(complianceId, 'allowHolder', [alice], 'kyc-alice')

// The issuer issues against the order it settles: 5,000.00 PB27
await call(issuerId, 'issue',
  [alice, '500000', toBytes32('ORDER-1001')], 'order-1001')

// A holder redeems against a redemption request
await call(aliceId, 'redeem',
  ['200000', toBytes32('REDEMPTION-42')], 'redemption-42')

// Reads, by name too
await cryptorail.POST('/v1/contracts/{id}/call', {
  params: { path: { id: assetId } },
  body: { function: 'balanceOf', args: [alice] },
})
// An issuance to an address compliance has not allowed:
// the contract would refuse it, so nothing is signed or sent
{
  "state": "PREPARATION_FAILED",
  "failure": {
    "stage": "preparation",
    "cause": "preparation.simulation-reverted",
    "message": "the call would revert with HolderNotAllowed(address)",
    "suggestedAction": "check the arguments and the contract state; …",
    …
  }
}
// While paused the contract answers EnforcedPause();
// over the day's cap the policy answers policy.limit-exceeded
Step 6

The board's approval

An issuance at or above the threshold waits, holding no nonce. The board member reads the decoded call and signs typed data any wallet can sign; the signature is checked before it counts.

// 20,000 PB27 waits: PENDING_APPROVAL. The board member reads
// the decoded call (to, amount, ref) and signs exactly that
const approval = await cryptorail.GET('/v1/approvals/{transactionId}', {
  params: { path: { transactionId } },
})
const signature = await wallet.request({
  method: 'eth_signTypedData_v4',
  params: [boardMemberAddress, JSON.stringify(approval.data!.typedData)],
})
await cryptorail.POST('/v1/approvals/{transactionId}/signatures', {
  params: { path: { transactionId } },
  body: { approverId: boardMemberId, signature },
})
// Quorum reached: the issuance is signed and confirmed
Step 7

What your back office receives

Every event of the asset arrives decoded and signed. A redemption carries the reference your back office pays out against.

// In the back office's webhook endpoint, after verifyWebhook(...)
for (const event of JSON.parse(rawBody).events) {
  // event.payload: { contractLabel: 'asset-pb27',
  //   eventName: 'Redeemed',
  //   params: { holder, amount, ref }, … }
  // Issued, Transfer, Redeemed, HolderAllowed, HolderDisallowed,
  // Paused, Unpaused, RoleGranted, RoleRevoked: all of them
  // event.id is stable: apply each one exactly once
}
Demo

See it run, then try it on yours.

We show the pack on a consortium 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.