Skip to content

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 and threshold ≤ 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 once threshold positive votes are reached.
  • Outbound: send(...) fans out to every adapter in the set, paying each its own estimate; estimate sums across all adapters.
  • Recovery: the first recoveryIndex adapters in the set are reserved "recovery" voters. Together with the RecoveryAdapter (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, and RecoverTokens are accepted only from mainnet; RegisterAsset and SetPoolAdapters are 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 via MessageLib and handed to gateway.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.value covering the adapter estimates; excess refunds to the designated refund address. Underpaid batches (with unpaidMode) park until anyone repays them.
  • Pool subsidies: each pool can maintain a native-token gas subsidy.
    • SubsidyManager is 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/withdrawAll are ward-gated, and a trustedCall path 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).