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.
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.
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.
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 --> EUnlike the stablecoin pack, the contract here is ours: its whole source is on this page.
Issuance, transfers between allowed holders, redemption against a reference, pause, roles. See it in code ↓
The committed build, deployed by your administrator's identity and registered by name in one command. See it in code ↓
A daily cap in the asset's own units, and the board's approval from a threshold. See it in code ↓
Issuances, transfers, redemptions, allowlist changes and pauses reach your back office as signed webhooks. See it in code ↓
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.
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.
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.
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 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); } }
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
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 }, })
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 } }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
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
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 }
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.