Cross-Chain Messaging¶
The hub and its spokes stay consistent through a verified message layer. Every cross-chain interaction — pool creation notices, price updates, investment requests, fulfillments — travels as a typed message through the same pipeline:
Sender contract (Hub / Spoke / BalanceSheet / ...)
│
▼
MessageDispatcher ── serializes (MessageLib)
│
▼
Gateway ── batching, payment, retry
│
▼
MultiAdapter ── fans out to N bridge adapters
│
▼ (destination chain)
Bridge adapters ──► MultiAdapter ── N-of-M vote
│
▼
Gateway ── safe per-message processing
│
▼
MessageProcessor ── decode + route
│
▼
Spoke / HubHandler / BalanceSheet /
VaultRegistry / ContractUpdater / ...
All components are unmodified Centrifuge V3 (see the fork framing).
Gateway¶
The Gateway is the routing hub for outbound (batching and payment) and inbound (safe processing and retry) messages. It talks to a single adapter — the MultiAdapter.
Inbound. handle(centrifugeId, batch) (ward-gated, pauseable) splits a batch into individual messages — a batch may only contain messages for a single pool — and processes each via a safe call with a per-message gas limit. A failing message does not revert the batch: it is recorded in failedMessages and a FailMessage event is emitted. retry(centrifugeId, message) is permissionless, so anyone can re-drive a failed message once the underlying condition is fixed.
Outbound. send(centrifugeId, message, unpaidMode, refund) (ward-gated, payable). Inside a batching context, messages accumulate per (chain, pool) in transient storage, bounded by the GasService's per-chain batch gas limit; outside one, they are sent immediately. If the supplied value is below the adapter estimate and unpaidMode is set, the batch is recorded as underpaid — anyone can later call repay(centrifugeId, batch, refund) (permissionless) to fund and release it. Excess value is refunded to the refund address.
Batching. withBatch(data, refund) opens a batching context, calls back into the caller (which must acknowledge via lockCallback()), then flushes all accumulated batches. This is the mechanism BatchedMulticall.multicall wraps: a multicall's inner cross-chain messages aggregate into one paid batch. Contracts extending BatchedMulticall must use msgSender()/msgValue() rather than msg.sender/msg.value (the true caller is stored transiently while the Gateway is the direct caller), and must never treat the Gateway itself as an authenticated caller.
Controls. blockOutgoing(centrifugeId, poolId, bool) (ward or pool-level gateway manager) halts outbound traffic per destination; updateManager(poolId, addr, bool) (ward) manages pool-level gateway managers; everything is pauseable against the global Root.paused flag.
MultiAdapter — N-of-M quorum¶
The MultiAdapter sits between the Gateway and the bridge adapters and removes any single bridge as a point of trust. Per (chain, pool) it holds an adapter set (maximum 8), a threshold, and a recoveryIndex.
- Configuration:
setAdapters(centrifugeId, poolId, adapters[], threshold, recoveryIndex)(ward-gated). Quorum equals the adapter count andthreshold ≤ quorum. Every reconfiguration increments a session ID, invalidating all pending inbound votes — adapter sets can be rotated safely without stale votes carrying over. - Inbound:
handle(centrifugeId, payload)requires the caller to be a registered adapter. With quorum 1 the message forwards straight to the Gateway; otherwise votes are tallied per payload hash, and the message forwards oncethresholdpositive votes are reached. - Outbound:
send(...)fans out to every adapter in the set, paying each its own estimate;estimatesums across all adapters. - Recovery: the first
recoveryIndexadapters in the set are reserved "recovery" voters. Together with theRecoveryAdapter(below), they allow governance to inject messages when the normal quorum cannot be met — for example during a bridge outage.
MessageProcessor and MessageDispatcher¶
- MessageProcessor (inbound):
handle(centrifugeId, message)(ward-gated) decodes the message type and routes it to the appropriate contract — Spoke, HubHandler, BalanceSheet, VaultRegistry, ContractUpdater, TokenRecoverer, or a scheduled-auth action. It enforces origin restrictions:ScheduleUpgrade,CancelUpgrade, andRecoverTokensare accepted only from mainnet;RegisterAssetandSetPoolAdaptersare accepted only from the chain encoded as their source. - MessageDispatcher (outbound): one ward-gated, payable
send*function per message type. Local destinations are called directly with no bridge hop; remote destinations are serialized viaMessageLiband handed togateway.send.
GasService¶
GasService holds immutable, benchmarked per-message-type gas limits. A fixed base overhead is added per message, and the overall limit accounts for the EIP-150 (63/64) gas-forwarding loss across the call-hop chain. maxBatchGasLimit(centrifugeId) bounds how large a batch may grow for each destination chain.
Message catalog¶
MessageLib defines the full serialized message set:
Pool-independent: ScheduleUpgrade, CancelUpgrade, RecoverTokens, RegisterAsset, SetPoolAdapters.
Pool-dependent: NotifyPool, NotifyShareClass, NotifyPricePoolPerShare, NotifyPricePoolPerAsset, NotifyShareMetadata, UpdateShareHook, InitiateTransferShares, ExecuteTransferShares, UpdateRestriction, UpdateVault, UpdateBalanceSheetManager, UpdateGatewayManager, UpdateHoldingAmount, UpdateShares, SetMaxAssetPriceAge, SetMaxSharePriceAge, Request, RequestCallback, SetRequestManager, TrustedContractUpdate, UntrustedContractUpdate.
UpdateVault additionally carries a VaultUpdateKind — DeployAndLink, Link, or Unlink (see VaultRegistry).
Bridge adapters¶
All adapters are ward-gated, implement a common IAdapter interface (send/estimate) plus wiring (wire/isWired), and hold the MultiAdapter as their immutable entrypoint. Inbound authentication is uniform: the bridge callback verifies (a) that msg.sender is the trusted bridge endpoint/router/relayer, and (b) that the source chain and source address match the wired source entry — only then does it forward to the MultiAdapter. Outbound send requires the caller to be the MultiAdapter.
| Adapter | Bridge | Inbound check specifics |
|---|---|---|
AxelarAdapter |
Axelar Gateway + Gas Service | Source-address hash match plus Axelar gateway contract-call validation |
LayerZeroAdapter |
LayerZero V2 endpoint | Origin-sender match plus endpoint caller check; configurable delegate/DVN |
WormholeAdapter |
Wormhole Relayer | Source-address match plus relayer caller check |
ChainlinkAdapter |
Chainlink CCIP | CCIP router caller check, known chain selector, decoded sender match |
RecoveryAdapter |
None (governance injection) | handle is ward-gated; injects messages directly into the MultiAdapter. send is a no-op and estimate is zero. Paired with the MultiAdapter's recovery index |
Gas payment and subsidies¶
Cross-chain messages cost native-token gas at the source. The protocol supports both caller-paid and pool-subsidized gas:
- Caller-paid: the transaction supplies
msg.valuecovering the adapter estimates; excess refunds to the designatedrefundaddress. Underpaid batches (withunpaidMode) park until anyone repays them. - Pool subsidies: each pool can maintain a native-token gas subsidy.
SubsidyManageris the front door:deposit(poolId)is payable and permissionless — anyone can top up a pool's gas subsidy, and the first deposit lazily deploys the pool's escrow.withdraw/withdrawAllare ward-gated, and atrustedCallpath allows Hub-governed withdrawals through the ContractUpdater.RefundEscrow(one per pool, deployed deterministically by a factory) holds the subsidy funds; deposits and withdrawals are ward-gated, with the SubsidyManager as controller.- The AsyncRequestManager draws on the pool subsidy to pay the cross-chain gas for depositor requests, so end users do not need to price bridge messages themselves.
For completeness, general-purpose custody escrows are covered with the contracts that own them: PoolEscrow (per-pool asset custody with reservations) and the generic Escrow (ward-gated ERC-20/ERC-6909 custody with an authTransferTo mover).