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

DataWho can see it
Amount of each transferOn the Hub it appears only as Pedersen commitments, which hide amounts. The sending and receiving chains see it in clear.
Sender and recipient addressesIn 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 transferVisible 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 itHidden among the other chains in the transaction's anonymity set.
Which chains took partPublic on the Hub: every Hub transaction lists the k chains in its anonymity set.
Mints and burnsPublic on the Hub: the amount and the chain are recorded in clear.
Transfers between two addresses on the same chainNot 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 only

Enygma 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__ResourceNotApproved or RaylsApp__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:

RuleRevert if broken
The four arrays have the same lengthRaylsEnygmaHandler__ArrayLengthMismatch
There is at least one legRaylsEnygmaHandler__EmptyArray
Each leg has a non-zero recipient, value and chain IDRaylsEnygmaHandler__ZeroValueArg
No leg targets the sender's own chainRaylsEnygmaHandler__WrongFunctionForSameChainId
The legs use at most 5 different destination chainsRaylsEnygmaHandler__TooManyUniqueChainIds
The token is active on the Hub and not frozen on your Privacy NodeRaylsApp__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 themReverts in the Privacy Node's Enygma events contract, for example TokenFreezeManagerV1__TokenFrozenForParticipant
The sender holds the total of all legsERC20InsufficientBalance

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:

HolderBeforeChangeAfter
Alice (chain A)965−108 sent, +1 received858
Bob (chain B)15+100 received, −5 sent110
Carol, Dan, Emma, Frank5 each+2 from Alice, +1 from Bob8 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 resourceId or contractAddress, 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 crossMint on 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:

ChainStatusMeaning
SourceSENT (1)The call succeeded and the tokens were burned.
SourceREVERTED (6)The Hub didn't accept the batch, and the relayer refunded the sender.
SourceRECEIVED (2)A leg failed on its destination chain, and the refund arrived as a transfer under the same reference ID.
DestinationRECEIVED (2)The leg was minted to the recipient.
DestinationREVERTED (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

  1. Burn. The call burns the total from the sender and records the transfer request, in one transaction on the source chain.
  2. 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.
  3. 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.
  4. 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.
  5. 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

ScopeGuarantee
The source callAtomic. The burn and the transfer request succeed or fail together.
One leg on its destination chainAtomic. The mint and the leg's program steps succeed or fail together.
One Hub batchAtomic. The Hub accepts or rejects the whole batch.
All legs of one callNot 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 (REVERTED on 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 REVERTED on the destination, and the refund shows as RECEIVED on the source.

Until a refund lands, the sender's balance on the source chain stays reduced by the failed amount.

Related pages


Did this page help you?