github centrifuge/protocol v3.3.0

3 hours ago

v3.3.0 hardens the pool-management and cross-chain surface introduced across v3.1–v3.2 and makes the protocol's core primitives more flexible. The main additions are a per-pool policy layer that constrains manager actions, fully sovereign per-pool adapter sets, and per-chain risk controls that bound the damage a single compromised chain can do. This release also ships pluggable custom share tokens, a redesigned balance sheet now folded into Spoke, and flexible accounting.

1. Policy layer

Hub and Spoke actions now run through a per-pool policy: an installable contract that classifies each call before it executes. A manager key is operationally necessary but a single point of failure, and the policy puts a structural check between a manager action and its execution.

Routine, bounded actions (notifications, metadata changes, price updates within a configured deviation threshold) execute immediately. Actions that carry material risk (price moves outside that threshold, allocation and configuration changes) enter a timelocked queue, where any single sentinel can cancel them; one sentinel is enough to block, with no coordination required. Replacing the policy itself sits behind a longer delay. The classification is configurable per pool, and because accounting, pricing, and configuration all flow through a single hub, a constraint defined once applies on every chain the pool operates on.

The Hub already ships a full standard implementation (StdHubPolicy) covering the basic checks: it bounds share-price drift and single jumps, bounds request-manager prices against the committed hub price, and confines a leaked keeper key to an allowlisted selector set. A pool-scoped Supervisor registers the sentinels, each with veto power only and no positive authority.

Spoke-side policy is governed from the Hub as well, so a pool operator only needs a secure cold wallet on the Hub chain. The spoke policy is installed via a hub message, and an out-of-policy spoke call is authorized on the Hub (Hub.authorizeSpokeCall), passing the same hub timelock and sentinel veto, then delivered to the spoke via an Authorize message for a manager to execute later. There is no separate spoke-side timelock or Supervisor. The layer is purely restrictive and opt-in: a pool with no policy installed is unaffected.

2. Sovereign per-pool adapters

In v3.2, a pool could choose its own bridge adapters but still shared the protocol's global security boundary, so a weakness in the shared set affected every pool regardless of its own configuration. v3.3 gives each pool a fully independent adapter set, isolated from every other pool.

Once a pool sets its own adapters via Hub.setAdapters (policy-guarded, sent to the remote chain before applying locally), its cross-chain messages route exclusively through those adapters, and the protocol's global set drops out of its trust surface entirely. The global set remains only as a convenience default for pools that haven't configured their own. A pool can pick adapters that meet its counterparty requirements and set a higher quorum at low cost, so growth to new chains and a higher security threshold no longer depend on protocol-level infrastructure choices or affect any other pool.

Adapter sets are scoped by a wire-visible session id per (centrifugeId, poolId): changing a set bumps the id and invalidates any in-flight votes, and a set can be frozen or restored with blockSession / unblockSession. If a pool's adapters go dark, a designated steward can propose a replacement set behind a timelock through the new AdapterFailover; the hub can veto while the real adapters still work, so a replacement takes effect automatically only when the existing adapters are genuinely unreachable. The old RecoveryAdapter is replaced by in-quorum recovery: a pool manager may submit messages on behalf of a configured adapter, but the quorum threshold must still be reached, so one manager cannot forge a message alone.

3. Per-chain risk controls

A chain with weaker security assumptions, a sequencer exploit, or a compromised validator set can forge cross-chain messages, inflate reported asset values, and manipulate the share price used for redemptions. v3.3 bounds the blast radius of a compromised chain along three axes.

The new BridgeCircuitBreaker hook applies a configurable per-origin-chain rate limit to outbound share transfers, so a compromised chain cannot mint unlimited shares elsewhere; transfers above the limit require a pre-authorized, amount-exact, single-use authorization, and the hook also carries a per-pool pause switch. The policy's price-deviation guards cap how far any single update can move the share price used for redemptions. And message-source validation now restricts holding and configuration updates to a pool's home chain: UpdateHoldingAmount verifies the source chain matches the asset's home chain (#215), and every hub→spoke message must arrive from the pool's home chain (#222). Together these close the main cross-chain forgery vectors.

4. Custom share tokens

Share-token operations become pluggable through a new ShareTokenRegistrar. Each share class selects one IRegistrar (one per token standard per chain), named in the NotifyShareClass message, and that registrar becomes the sole ward of its tokens, with core contracts routing mint, burn, and force-move through it. This is the extension point for custom share-token standards such as B20/TIP-20 without touching core. There is no allowlist and no default: any non-zero registrar chosen for a share class only affects that share class. The ERC-7575 vault pointer (shareToken.vault(asset)) is now set explicitly via the registrar rather than as a side effect of linking, so integrators should tolerate an unset pointer.

5. Balance-sheet redesign

The balance sheet is rebuilt around a reserve-as-accounting model, and holdings are now valued entirely hub-side. The standalone BalanceSheet contract is gone: its operations (deposit/withdraw, issue/revoke, reserve, force-transfer shares) are now Spoke operations, and the queued share/asset delta logic is extracted into a small SnapshotQueue (flushShares/flushAssets return the Hub update payload). Reservations are the accounting primitive: reserve and unreserve move funds out of or into the hub-accounted holding (accounted = total − reserved) and queue the matching holding change, withdraw is the single withdrawal primitive, withdrawReserved claims earmarked funds, and noteDeposit credits assets already in escrow without a token pull.

Holdings are valued only at the hub valuation, with decreases removing carrying value pro-rata, so the journaled value always mirrors the holding mutation and can never over-journal. UpdateHoldingAmount no longer carries a price (107 → 91 bytes). Pending deposits are now net-zero (fixes #477): they are credited via noteDeposit + reserve so the two queued updates cancel, meaning a pending deposit no longer enters the hub-accounted holding or blocks funded redemption claims. Deployed vaults are unchanged and run against the new managers with no migration; requestRedeem's transfer flag and globalEscrow() are retained as deprecated stubs for ABI compatibility.

6. Cross-chain share transfers

TokenBridge wraps cross-chain share movement behind a single send(token, amount, receiver, destinationChainId, refund), resolving the pool and share class from the token itself rather than making callers thread them through. Spoke-to-spoke transfers route via the hub chain, and a relayer pays for the second spoke out of the overpayment on the first, so a user sends one transaction and one payment for a two-hop route.

ShareManager covers the opposite direction: hub-driven issuance and revocation for holdings tracked outside the protocol, such as investments on chains where the protocol is not deployed. Every operation arrives as a manager call from the hub and matures through the pool's policy, so no local wallet holds any permission.

Other changes

The Spoke monolith is split into SpokeRegistry (state and auth-gated mutators), SpokeHandler (inbound hub→spoke messages), and Spoke (outbound ops). The standalone BalanceSheet is folded into Spoke (its queue logic extracted to SnapshotQueue), and the pool manager role lives only in SpokeRegistry, so Spoke and balance-sheet paths can no longer disagree about who is a manager. Per-pool manager and bridger roles gate initializeHolding and crosschainTransferShares, and price expiry is removed so prices stay valid until overwritten.

All hub-originated privileged calls now ride a single generic ManagerCall message dispatched through a stateless Envoy, the stable CREATE3 anchor that targets bind to across releases via fromHub(PoolId, bytes payload). This folds in TrustedContractUpdate and the Hub's updateContract, and a single UpdateManager message replaces the per-role manager messages.

The Hub's double-entry engine is no longer opinionated about a chart of accounts: the six hardcoded AccountType roles are replaced by four generic event-role slots (AmountDebit, AmountCredit, ValueIncrease, ValueDecrease) a pool assigns accounts to. isLiability is removed, and initializeHolding and initializeLiability merge into one call taking a 4-slot HoldingAccount[].

Decimals are relaxed from [2, 18] to [0, 18] across assets, share classes, and pool currencies, with precision unchanged in form; a new invariant enforces pool decimals equal to share-token decimals, so updateCurrency (now exposed on the Hub) requires a new currency to match the incumbent's decimals. Two new adapters ship: HyperlaneAdapter (Mailbox dispatch, replay protection delegated to the Mailbox) and StandbyAdapter (a cheap, credit-bounded Nth adapter for M-of-N quorums). Finally, the failed-message gas reserve is now chain-specific and read from GasService (moved to src/admin/, swappable via OpsGuardian.setGasService), and clearFailedMessage lets a gateway pool manager discard a failed message that should never execute.

VaultRouter no longer batches through the gateway. It is a plain IMulticall, investments are subsidized, and the mandatory prepayment is gone: callers no longer need estimate or attached native value to invest.

Vaults are resolved declaratively through the ERC-7575 pointer rather than a registry lookup, and requests carry a RequestId (uint256) instead of being keyed by controller alone.

Lifetime issuance and holding counters widen from uint128 to uint256, so issuance, revocations and the holding amount getters return uint256. Total share issuance may now go negative across networks, which a bridged-out share class can legitimately reach on one chain.

Two adapter changes reach integrators indirectly: ChainlinkAdapter supports CCIP v2 extra args, so a lane can request faster-than-finality delivery where the lane allows it, and gas limits are now chain-aware (including Monad's repriced cold accesses) with the adapter's own receive path priced by GasService.

The principal ISO-4217 currencies (USD_ID, EUR_ID, and the rest of the G10) are registered at deployment, so a pool can pick one as its currency without a separate registration step.

New Contracts

Contract Purpose
StdHubPolicy Default per-pool policy classifying each Hub call as in-policy / delayed / escalated
Supervisor Pool-scoped sentinel registry with veto-only power
BridgeCircuitBreaker Pause + rolling rate limit on outbound cross-chain share transfers
ShareTokenRegistrar Per-share-class IRegistrar, sole ward of its tokens, pluggable token standards
SpokeRegistry / SpokeHandler Spoke state and inbound message handling, split out of Spoke
SnapshotQueue Queued per-share-class-netted / per-asset-gross deltas pending submission to the Hub, extracted from the merged BalanceSheet
Envoy Stateless CREATE3 anchor dispatching ManagerCall to fromHub targets
AdapterFailover Timelocked, hub-vetoable recovery when a pool's adapters go dark
TokenBridge One-call cross-chain share transfers, relayer-funded on the second leg
ShareManager Hub-driven issuance and revocation for holdings tracked outside the protocol
HyperlaneAdapter / StandbyAdapter New cross-chain adapters

Breaking Changes

Change Migration
Spoke split into Spoke / SpokeHandler / SpokeRegistry Read state through the new SpokeRegistry
Standalone BalanceSheet removed Balance-sheet ops now live on Spoke; callers (spoke managers, transfer hooks, messaging) rewired to Spoke; queue logic in SnapshotQueue
Share-token ops route through ShareTokenRegistrar Existing tokens' wards are re-wired to the registrar during migration
initializeHolding + initializeLiability merged Assemble the 4-slot HoldingAccount[]; initializeLiability removed
AccountType / isLiability removed Liabilities are wired via account slots + NAVManager
UpdateHoldingAmount drops its price field (107 → 91 bytes) Deployed v3.1.0 BalanceSheets keep working; hub ignores message-supplied prices
Hub updateContract / TrustedContractUpdate folded into ManagerCall Route privileged hub→spoke calls through Envoy.fromHub targets
Manifest renamed to Policy StdManifest → StdHubPolicy, IManifest → IHubPolicy; the mechanism is unchanged
PoolEscrow renamed to Escrow Also PoolEscrowFactory → EscrowFactory, IPoolEscrow → IEscrow
VaultRouter is a plain IMulticall Drop estimate and attached native value; investments are subsidized
Issuance and holding counters widened to uint256 Update ABI decoding for issuance, revocations and holding amount getters
Requests keyed by RequestId Pass the RequestId returned at request time
Reservation reason is bytes32 Was a narrower type; update reserve / unreserve callers
WormholeAdapter removed Use another configured adapter in the quorum
Price expiry removed on the spoke Prices stay valid until overwritten

Fixes

  • UpdateHoldingAmount now verifies the source chain matches the asset's home chain, so a hostile spoke cannot forge holding updates for assets it doesn't own.
  • Every hub→spoke message must now arrive from the pool's home chain, closing a forged-message path against local spokes.
  • BatchRequestManager.notifyDeposit/notifyRedeem refund attached ETH on the no-op path instead of stranding it.
  • Stale-price over-journaling / NAV understatement fixed via pro-rata holding decreases.
  • Unrelated pending deposits can no longer block funded redemption claims.
  • BatchedMulticall no longer strands ETH from reentrant external calls during an active batch.
  • SubsidyManager.withdrawAll returns zero funds instead of reverting when a pool's refund escrow isn't deployed yet, so the outbound message goes into the underpaid slot rather than bricking live investments (e.g. ACRDX on Plume).
  • SyncDepositVault deposit/mint now emit Deposit(sender, owner, …) in the correct ERC-4626 order (was inverting caller and share recipient).
  • Fulfillment callbacks are bound to the pool they were authenticated for, so a callback cannot be replayed against another pool.
  • Hub.unauthorizeSpokeCall now runs through the pool policy, so revoking a spoke authorization is classified like granting one.
  • notifyShareClass is classified out of policy, so deploying a share class on a new chain passes the timelock rather than executing instantly.
  • A share price of 0 no longer leaves the deviation guard unarmed on the first update.
  • Share issuance may go negative across networks, which a share class bridged out of one chain can legitimately reach.
  • The pool escrow is exempt from member-update restrictions, so freezing a member cannot block escrow flows.
  • SyncDepositVault reports an unchanged price-per-share correctly instead of skipping the update.
  • NAVManager exposes the remaining accounting methods, so on-chain accounting pools are not missing hub calls.

Removed

Component Replacement / Notes
TokenRecoverer + RecoverTokens message + ProtocolGuardian.recoverTokens Direct Recoverable.recoverTokens (auth-gated) or one-off governance spells
RecoveryAdapter + recoveryIndex In-quorum recovery by a pool manager (threshold still enforced)
WormholeAdapter Use another configured adapter
VaultRegistry, TokenFactory State folded into registries; tokens deployed via ShareTokenRegistrar
OnOfframpManagerFactory Superseded by OnOfframpFactory
BalanceSheet / IBalanceSheet Merged into Spoke; queue logic in SnapshotQueue
AccountType, isLiability, InvalidAccountCombination Event-role account slots
MIN_DECIMALS / TooFewDecimals Decimals bound relaxed to [0, 18]

Audits

v3.3.0's audit reports are published under docs/audits/ on the main branch, covering the BurraSec, yAudit, Sherlock and formal-verification engagements run across v3.3.

Don't miss a new protocol release

NewReleases is sending notifications on new releases.