Send Enygma Transactions
Enygma payments move an Enygma token from one Privacy Node (Rayls Sovereign chain) to another. The tokens are burned on the sender's chain and cross the Private Network Hub (PNH, "the Hub"), the Hyperledger Besu chain at the centre of the Rayls Private Network, as a shielded transfer, checked by a zero-knowledge (ZK) proof. They are then minted on the recipient's chain. This page shows how to send transfers with crossTransfer, and what happens after the call. For the full contract interface, see RaylsEnygmaHandler. For the protocol itself, see How Enygma works.
Enygma suits institutions that need confidential token transfers between their chains, such as central bank digital currency (CBDC) distribution, interbank settlement or transfers between group entities.
What a transfer hides, and from whom
| Data | Who can see it |
|---|---|
| Amount of each transfer | On the Hub it appears only as Pedersen commitments, which hide amounts. The sending and receiving chains see it in clear. |
| Sender and recipient addresses | In clear on the sender's chain, and on the recipient's chain when it mints. On the Hub they travel only inside messages encrypted for the destination chain. |
| Which chain sent the transfer | Visible on the Hub. The proof and commitments don't reveal it, but each chain's relayer submits its own Hub transactions from that participant's account. |
| Which chain received it | Hidden among the other chains in the transaction's anonymity set. |
| Which chains took part | Public on the Hub: every Hub transaction lists the k chains in its anonymity set. |
| Mints and burns | Public on the Hub: the amount and the chain are recorded in clear. |
| Transfers between two addresses on the same chain | Not Enygma transfers. They are ordinary ERC-20 transfers on that chain. |
The anonymity set size k is the number of Privacy Nodes registered for Enygma in the network, capped at six. So k is always between 2 and 6. With two participants the set hides nothing between them. See Who sees what.
Oversight. When a Privacy Node registers, its view private key is encrypted to the Private Network operator. The operator's governance services decrypt every Enygma transfer: addresses, chains and amounts. The operator can also freeze a token for a participant, which blocks transfers to and from that chain. See Who sees what.
Where the state lives
On the Hub, each Enygma token has its own Enygma contract (EnygmaV1), which holds the token's canonical state:
- one balance commitment per participating Privacy Node;
- the total supply;
- the nullifiers that stop a transfer being replayed;
- which Hub transactions are pending and which are finalised.
The contract verifies the ZK proof of every batch of transfers.
On each Privacy Node, the token is a contract based on RaylsEnygmaHandler that holds ordinary ERC-20 balances per address. That is what users see, and what the examples below show moving. The relayers keep each chain's total in step with its commitment on the Hub: every mint, burn and cross-chain transfer on the chain has a matching update on the Hub.
Hub onlyEnygma transfers settle only through the Private Network Hub. There is no settlement path for Enygma on the Rayls Public Chain.
Before you start
- The token is deployed on your chain, authorised on your Privacy Node and activated on the Hub. A token that isn't activated reverts with
RaylsApp__ResourceNotApprovedorRaylsApp__HubNotActive. - Every destination chain is a registered, active participant, and the token isn't frozen on the source chain or any destination chain.
- The sender holds enough tokens on the source chain.
- You don't need to deploy the token on destination chains. The first time a token reaches a chain, that chain's relayer deploys the standard Enygma token contract there.
The examples use ethers v6 with TypeChain bindings. tokenOnA is the token contract on chain A, connected to the sender's wallet; chainIdB to chainIdF are the destination chain IDs.
Rules for a transfer
crossTransfer(to[], value[], toChainId[], programData[][]) takes one entry per recipient, called a leg. The call reverts unless all of the following hold:
| Rule | Revert if broken |
|---|---|
| The four arrays have the same length | RaylsEnygmaHandler__ArrayLengthMismatch |
| There is at least one leg | RaylsEnygmaHandler__EmptyArray |
| Each leg has a non-zero recipient, value and chain ID | RaylsEnygmaHandler__ZeroValueArg |
| No leg targets the sender's own chain | RaylsEnygmaHandler__WrongFunctionForSameChainId |
| The legs use at most 5 different destination chains | RaylsEnygmaHandler__TooManyUniqueChainIds |
| The token is active on the Hub and not frozen on your Privacy Node | RaylsApp__HubNotActive, RaylsApp__PrivacyNodeFrozen or RaylsApp__ResourceNotApproved |
| The sender's chain and every destination chain are active participants, and the token isn't frozen for any of them | Reverts in the Privacy Node's Enygma events contract, for example TokenFreezeManagerV1__TokenFrozenForParticipant |
| The sender holds the total of all legs | ERC20InsufficientBalance |
programData holds one array of program steps per leg. Pass [] for a leg with no steps.
Examples
Alice holds 1,000 tokens on chain A. Bob, Carol, Dan, Emma and Frank each start with 0 tokens, on chains B, C, D, E and F.
1. Single transfer, A → B
Alice sends 10 tokens to Bob.
await tokenOnA.crossTransfer([signerB.address], [10], [chainIdB], [[]]);For a single recipient you can also use the shortcut:
await tokenOnA.linearCrossTransfer(signerB.address, 10, chainIdB, []);Outcome:
- Alice's balance on chain A drops from 1,000 to 990 as soon as the call succeeds.
- Bob's balance on chain B rises to 10 once the transfer has settled. See After the call.
2. Several recipients, A → B, C, D, E and F
Alice sends 5 tokens each to Bob, Carol, Dan, Emma and Frank.
await tokenOnA.crossTransfer(
[signerB.address, signerC.address, signerD.address, signerE.address, signerF.address],
[5, 5, 5, 5, 5],
[chainIdB, chainIdC, chainIdD, chainIdE, chainIdF],
[[], [], [], [], []]
);Outcome:
- The call burns the total, 25 tokens, from Alice in one transaction: 990 → 965.
- The call emits one reference ID for all five legs (see Track a transfer).
- Each recipient receives 5 tokens on their own chain. A chain that has never held the token gets the token contract deployed first.
- Five destination chains is the maximum for one call.
- Each leg settles separately. If one leg fails, Alice is refunded for that leg and the other legs still complete. See What is and isn't atomic.
3. Transfers in both directions
In the same period, Alice pays Bob 100 tokens and the others 2 each, and Bob pays everyone else 1 each.
const txFromA = await tokenOnA.crossTransfer(
[signerB.address, signerC.address, signerD.address, signerE.address, signerF.address],
[100, 2, 2, 2, 2],
[chainIdB, chainIdC, chainIdD, chainIdE, chainIdF],
[[], [], [], [], []]
);
const txFromB = await tokenOnB.crossTransfer(
[signerA.address, signerC.address, signerD.address, signerE.address, signerF.address],
[1, 1, 1, 1, 1],
[chainIdA, chainIdC, chainIdD, chainIdE, chainIdF],
[[], [], [], [], []]
);
await txFromA.wait();
await txFromB.wait();tokenOnB is the token contract on chain B, connected to Bob's wallet. It exists because Bob has already received the token there.
Outcome, once both transfers have settled:
| Holder | Before | Change | After |
|---|---|---|---|
| Alice (chain A) | 965 | −108 sent, +1 received | 858 |
| Bob (chain B) | 15 | +100 received, −5 sent | 110 |
| Carol, Dan, Emma, Frank | 5 each | +2 from Alice, +1 from Bob | 8 each |
The two transfers are independent. Each sender's relayer proves and submits its own transactions to the Hub, and nothing nets the two directions against each other. Each sender's tokens are burned when its call succeeds, before the counterparty's payment arrives.
4. Transfer with a contract call
A leg can carry program steps: calls that run on the destination chain straight after the tokens are minted there, in the same transaction. Here Alice pays Bob 10 tokens and records an invoice reference in a contract on chain B.
import { ethers } from "ethers";
const invoiceBook = new ethers.Interface(["function setMessage(string message)"]);
const step = {
resourceId: ethers.ZeroHash, // target by address, so no resource ID
contractAddress: invoiceBookOnB, // contract address on chain B
selector: invoiceBook.getFunction("setMessage")!.selector,
args: ethers.AbiCoder.defaultAbiCoder().encode(["string"], ["invoice-42"]), // arguments only, no selector
};
await tokenOnA.crossTransfer([signerB.address], [10], [chainIdB], [[step]]);How program steps work:
- The mint comes first. The token adds the settlement mint as the first step of every leg. Your steps run after it, in order.
- Address each target one way. Give either
resourceIdorcontractAddress, not both. A resource ID is resolved to the contract registered for it on the destination chain. - Only approved calls run. The target's code hash and the function selector must match a template approved in the Private Network's template registry. Standard Rayls templates are approved in advance. For your own contracts, anyone can propose a template on the Hub, and the Private Network operator approves it. A step that doesn't match reverts.
- The sender's address is passed to the call. It is appended to each step's calldata as the attested origin. Owner-only targets, such as
crossMinton a Rayls token, check that the origin holds the token owner role on the destination chain. - Limits. A leg can carry at most 256 steps, including the settlement mint. The steps can't mint more or less of the token than the leg's value.
- All or nothing per leg. If any step reverts, the leg's whole transaction reverts, including the mint, and the relayer refunds the sender on the source chain.
5. Sending on behalf of another holder
crossTransferFrom and linearCrossTransferFrom send from another address. That address must first approve the caller for the total of all legs, with approve on the source chain. Here operator is a wallet that Alice has approved:
await tokenOnA.connect(operator).crossTransferFrom(
signerA.address,
[signerB.address],
[10],
[chainIdB],
[[]]
);Track a transfer
Every call returns a reference ID and emits it in the crossTransferReferenceId and transactionReferenceId events. Read the status of a reference ID with referenceIdStatus on the token contract of the chain you're interested in:
const tx = await tokenOnA.crossTransfer([signerB.address], [10], [chainIdB], [[]]);
const receipt = await tx.wait();
let referenceId: string | undefined;
for (const log of receipt!.logs) {
const parsed = tokenOnA.interface.parseLog(log);
if (parsed?.name === "crossTransferReferenceId") referenceId = parsed.args._referenceId;
}
// Later, on chain B: 2 = RECEIVED
const status = await tokenOnB.referenceIdStatus(referenceId!);Status is recorded per chain:
| Chain | Status | Meaning |
|---|---|---|
| Source | SENT (1) | The call succeeded and the tokens were burned. |
| Source | REVERTED (6) | The Hub didn't accept the batch, and the relayer refunded the sender. |
| Source | RECEIVED (2) | A leg failed on its destination chain, and the refund arrived as a transfer under the same reference ID. |
| Destination | RECEIVED (2) | The leg was minted to the recipient. |
| Destination | REVERTED (6) | The leg failed on this chain and was sent back to the sender. |
Reference IDs for Delivery-versus-Payment (DvP) deposits and withdrawals use the other values. See RaylsEnygmaHandler.
After the call
- Burn. The call burns the total from the sender and records the transfer request, in one transaction on the source chain.
- Batch. The source chain's relayer collects pending requests for the token, from any number of calls, and groups them into batches. A batch reaches at most k − 1 destination chains, because the sender's chain takes one place in the anonymity set. It also holds at most 1,000 transfers by default. The relayer fills the rest of the set with other participants, who receive zero.
- Prove and submit. For each batch, the relayer generates a ZK proof and submits it, with messages encrypted for each destination chain, to the token's Enygma contract on the Hub. The Hub checks the proof, the nullifier and the block number, and records the batch as pending. Each batch is a separate Hub transaction.
- Mint. Each destination chain's relayer recognises the messages meant for it and decrypts them. It then mints each leg to its recipient, one transaction per leg, running any program steps in the same transaction.
- Finalise. The Hub finalises a pending batch when it accepts a later transaction for the same token. The sender's relayer submits a zero-value transaction for this if needed.
Settlement is asynchronous. Batching, proof generation and the Hub transactions add delay, so plan for transfers to take longer than a transaction within one chain, and confirm arrival with the destination's reference ID status rather than assuming it.
What is and isn't atomic
| Scope | Guarantee |
|---|---|
| The source call | Atomic. The burn and the transfer request succeed or fail together. |
| One leg on its destination chain | Atomic. The mint and the leg's program steps succeed or fail together. |
| One Hub batch | Atomic. The Hub accepts or rejects the whole batch. |
| All legs of one call | Not atomic. The legs of one call can be split across Hub batches, and each leg is minted in its own transaction. |
Failures are handled by compensating transactions, not by rolling back:
- If the relayer can't get a batch accepted by the Hub after its retries, it re-credits every sender in the batch on the source chain (
REVERTEDon the source). - If a leg fails on its destination chain, that chain's relayer sends the value back through the Hub, and the sender is re-credited on the source chain. The leg is
REVERTEDon the destination, and the refund shows asRECEIVEDon the source.
Until a refund lands, the sender's balance on the source chain stays reduced by the failed amount.
Related pages
Updated 12 days ago
