Batch cross-chain transactions
Many operations on Centrifuge send a cross-chain message. If you run several of them in one transaction, for example adding an investor to the whitelist on five chains, you can have the protocol group those messages and pay for the delivery once. This guide explains how batching works, then shows the two ways to use it, on the Hub side and the Spoke side, and when to pick each.
How batching works
Every message the Hub or a Spoke sends goes through the Gateway. Normally the Gateway pays for and dispatches each message right away. When batching is on, it queues them instead:
- Every message sent while the batch is open is queued, grouped by destination chain and pool.
- When the batch closes, the
Gatewaysends one payload per(chain, pool)group and pays the adapters. - You pay the fuel (native token) once for the whole batch, and any unspent fuel is refunded.
- The batch is atomic on the sending chain. If any call inside it reverts, nothing is queued or sent.
In the diagram, three messages target two chains, so the Gateway sends two payloads and the two messages for chain A travel together.
A batch lasts for one Gateway.withBatch call. You reach it either through multicall on the Hub or Spoke (option 1) or by calling Gateway.withBatch yourself from a contract (option 2).
Option 1: use multicall on the Hub or Spoke
Hub and Spoke inherit BatchedMulticall. Its multicall runs your calls inside Gateway.withBatch and refunds unspent fuel to the caller. Use it from a wallet or multisig. Permission checks, such as the Hub's manager check, apply to the account that called multicall. Pass the fuel only on the outer call, and use address(0) as the inner refund.
Hub side
The Hub sends each message to a single Spoke. To add an investor to the whitelist on several chains, batch one updateRestriction call per chain:
bytes memory member = UpdateRestrictionMessageLib.UpdateRestrictionMember({
user: investor.toBytes32(),
validUntil: validUntil
}).serialize();
hub.multicall{value: gas}([
abi.encodeCall(hub.updateRestriction, (poolId, scId, chainA, member, extraGas, address(0))),
abi.encodeCall(hub.updateRestriction, (poolId, scId, chainB, member, extraGas, address(0))),
abi.encodeCall(hub.updateRestriction, (poolId, scId, chainC, member, extraGas, address(0)))
]);
Calls for the same chain travel together in one payload.
Spoke side
Messages from a Spoke always travel to the pool's Hub chain, so a batch collapses into one payload per pool. For example, bridge shares to two chains in one transaction:
spoke.multicall{value: gas}([
abi.encodeCall(spoke.crosschainTransferShares, (chainA, poolId, scId, receiverA, sender, owner, amountA, 0, 0, address(0))),
abi.encodeCall(spoke.crosschainTransferShares, (chainB, poolId, scId, receiverB, sender, owner, amountB, 0, 0, address(0)))
]);
Option 2: call withBatch from your own contract
When a contract of yours drives the Hub or Spoke, for example a custom manager that holds the pool manager role, call Gateway.withBatch with a callback on your contract. The Gateway refunds unspent fuel straight to the address you pass, typically the operator, so your contract never holds native tokens.
The operator calls your contract with the fuel as msg.value. Your contract calls gateway.withBatch(data, refund), and the Gateway calls data back on your contract with batching on. The callback calls lockCallback(), makes its Hub or Spoke calls, and returns. The Gateway then sends the payloads and refunds the rest.
contract MyManager {
IHub public immutable hub;
constructor(IHub hub_) {
hub = hub_;
}
function updateMemberOnChains(
PoolId poolId,
ShareClassId scId,
uint16[] calldata chains,
bytes calldata member
) external payable onlyOperator {
gateway().withBatch{value: msg.value}(
abi.encodeCall(this.batchCallback, (poolId, scId, chains, member)),
msg.sender // refund
);
}
function batchCallback(
PoolId poolId,
ShareClassId scId,
uint16[] calldata chains,
bytes calldata member
) external {
IGateway gateway_ = gateway();
require(msg.sender == address(gateway_), NotGateway());
gateway_.lockCallback();
for (uint256 i; i < chains.length; ++i) {
hub.updateRestriction(poolId, scId, chains[i], member, 0, address(0));
}
}
/// @dev The Gateway the Hub's message sender uses, read at every call so a rotation needs no redeploy
function gateway() public view returns (IGateway) {
return IGateway(IMessageSender(hub.sender()).gateway());
}
}
Keep the callback safe:
- Call
lockCallback()first and check thatmsg.senderis theGateway. This makes the callback reachable only from your ownwithBatchcall, and skipping it reverts withCallbackWasNotLocked. Give theGatewayno other privileges. - Keep the callback narrow. Pass only the data it needs, and don't let callers pick arbitrary targets or selectors.
- The Hub sees your contract, not the operator, as the caller. Grant your contract the pool's manager role and gate your own entry points.
The pattern is the same on the Spoke side. Nested withBatch calls join the outer batch, and the outermost call sends and refunds.
Benefits
- One payload per
(chain, pool)instead of one per message, which lowers relaying cost and the number of proofs to verify. - One fuel payment for the whole batch, with unspent fuel refunded in the same transaction.
- Atomicity on the sending chain. If one call fails, none of the batch is sent.
- Messages to the same chain are processed in order in a single transaction on the destination.
- With option 2, your contract holds no native tokens and needs no
receive()function.
Limits
- Each payload has a maximum gas limit per destination chain. Exceeding it reverts with
BatchTooExpensive, so split very large batches. - If the fuel doesn't cover every payload, the transaction reverts with
NotEnoughGas. Hub.setAdapterscan't run inside a batch.- Delivery is per chain and per message. A message that fails on its destination can be retried without affecting the others, and chains can receive their payloads at different times.
To push updates to your own contracts instead of the share token hook, see Sync parameters across chains.