Skip to content

Vault Contracts

Vaults are the depositor-facing entry point: ERC-7540/ERC-4626 contracts through which users deposit assets and redeem shares. Behind them sit two request engines — one on the spoke (per-user state and escrow mechanics) and one on the hub (epoch batching) — plus pricing infrastructure. All contracts on this page are unmodified Centrifuge V3 (see the fork framing).

This page covers the contract-level fund flows. For the off-chain orchestration of these flows (who approves, issues, and fulfills in practice), see the backend lifecycle; for the integrator-facing request/claim API, see the SDK concepts.

Vault kinds

  • AsyncVault (ERC-7540): asynchronous deposits and asynchronous redemptions. The ERC-4626 preview* functions revert by design — outcomes depend on epoch pricing that is not known synchronously.
  • SyncDepositVault: synchronous ERC-4626 deposits with asynchronous ERC-7540 redemptions.

Both extend a shared BaseVault:

  • Shared views: poolId, scId, asset, share, totalAssets, convertTo*, pricePerShare, priceLastUpdated, isPermissioned.
  • Operator model: setOperator, signature-based authorizeOperator (EIP-712 / ERC-7741), and setEndorsedOperator for Root-endorsed callers — i.e. the VaultRouter.
  • Requests are non-fungible: requestId is always 0.

AsyncRequestManager — spoke-side engine

The AsyncRequestManager holds all per-user request state on a spoke chain: for each (vault, depositor) it tracks maxMint, maxWithdraw, weighted-average deposit and redeem prices, pending request amounts, cancellation flags, and claimable-cancel amounts. The vaults themselves are its wards, cross-relied at factory deploy time.

Outbound (user → hub). requestDeposit, requestRedeem, and the cancel functions validate hook restrictions, mutate pending state, then dispatch a Request message via the Spoke. Deposit assets are pulled into the pool escrow and reserved under a deposit reason; redeem shares move to the escrow and are reserved under a redeem reason. Cross-chain message gas can be paid from the pool's ETH subsidy via the SubsidyManager.

Inbound fulfillment (callbacks from the hub, decoded by callback type):

  • ApprovedDeposits → unreserve the assets, pin the price, noteDeposit (queuing the hub-side holding increase), reset the price override.
  • IssuedShares → pin the share price, balanceSheet.issue to the pool escrow, reserve the shares.
  • RevokedShares → reserve the payout assets, unreserve the shares, noteWithdraw queued atomically with the burn (avoiding NAV desync), balanceSheet.revoke.
  • FulfilledDepositRequest / FulfilledRedeemRequest → update the weighted-average deposit/redeem price, bump maxMint/maxWithdraw, decrement pending, and notify the vault so claims become available.

Claims consume maxMint/maxWithdraw and withdraw from the escrow with the appropriate withdraw mode — claims use the modes that skip re-queuing, because hub accounting was already queued at fulfillment time.

BatchRequestManager — hub-side epoch engine

The hub-side counterpart batches requests into epochs per (pool, shareClass, asset):

  • State: four monotone epoch counters (deposit, issue, redeem, revoke); pending deposit/redeem totals; per-depositor orders (pending, lastUpdate); queued orders for requests arriving mid-epoch (folded in after the user's claims catch up); and allowForceCancel safeguards — a manager cannot force-cancel a user's request without the user's prior explicit permission.
  • Flow: inbound request() (ward = Hub) mutates pending/queued state → the pool manager calls approveDeposits/approveRedeems (snapshotting amount and price and advancing the epoch), then issueShares/revokeShares (computing outcomes via PricingLib and firing the request callback cross-chain to the spoke's AsyncRequestManager) → notifyDeposit/notifyRedeem (permissionless, reentrancy-guarded) aggregate a user's claims across epochs into a single fulfillment callback.
  • Rounding invariant: pending reductions round up and payouts round down, so the sum of claims never exceeds the sum issued. A user may lose at most 1 wei per claim; the invariant prevents cumulative drift.

SyncManager

Backs synchronous deposits:

  • An optional per-share-class valuation source; if unset, the Spoke share price is used.
  • A maxReserve cap per (pool, shareClass, asset): maxDeposit = maxReserve − escrowAvailable, bounding synchronous inflows.
  • Sync deposit flow: the vault calls into the SyncManager, which notes the deposit, pins the price, and issues shares directly to the receiver via the BalanceSheet; the vault transfers the assets straight into the pool escrow.
  • Configured via the trusted-call channel from the Hub (through the ContractUpdater) or by wards.

VaultRouter

The EOA entry point: enable/disable (as an endorsed operator), request/claim/cancel wrappers, ERC-2612 permit, and cross-chain share transfers — all reentrancy-protected, payable, and multicall-batchable so a user can combine steps into one transaction with one gas payment.

Invariant: no funds may remain in the router after a transaction — anything left over would be claimable by others.

Vault factories

AsyncVaultFactory and SyncDepositVaultFactory (wards of the VaultRegistry) deploy vaults at deterministic CREATE2 addresses (salt derived from pool, share class, and asset; ERC-20 assets only), then wire atomically: rely Root on the vault, rely the request manager on the vault, rely the vault on its request manager (making the vault a ward of its manager), and deny the factory itself.

Fund flow: async deposit lifecycle

SPOKE CHAIN                                  HUB CHAIN
───────────                                  ─────────
depositor
  │ requestDeposit(assets)
  ▼
AsyncVault → AsyncRequestManager
  │  pull assets → PoolEscrow (reserved)
  │  Request ────────────────────────────►  HubHandler → BatchRequestManager
  │                                            pending += assets
  │                                          pool manager:
  │                                            approveDeposits (epoch, price)
  │  ApprovedDeposits ◄────────────────────    │
  │    unreserve, pin price, noteDeposit       │
  │                                            issueShares (PricingLib)
  │  IssuedShares ◄─────────────────────────   │
  │    issue shares → PoolEscrow (reserved)    │
  │                                          notifyDeposit (permissionless)
  │  FulfilledDepositRequest ◄─────────────    aggregates epochs
  │    maxMint ↑, pending ↓
  ▼
depositor claims: mint/deposit
  │  shares released from escrow to depositor
  ▼
BalanceSheet.submitQueuedAssets/Shares ────►  Holdings + Accounting update
                                              (isSnapshot when queues drain)

Redemptions mirror this flow: shares are escrowed and reserved on request, revoked at fulfillment (with the payout assets reserved), and the depositor's claim withdraws assets from the escrow.

Valuations and pricing

D18 and PricingLib

All protocol prices are D18 — 18-decimal fixed point. PricingLib provides the conversion primitives: convertWithPrice(amount, baseDecimals, quoteDecimals, priceQuotePerBase) at the core, with asset↔share helpers (raw-price overloads reserved for AsyncRequestManager fulfillments; pool-price variants elsewhere), priceAssetPerShare, and reciprocal-price conversion.

Valuation contracts

Both implement a common IValuation interface (getPrice, getQuote) and convert via PricingLib with registry decimals:

  • OracleValuation — a trusted-feeder price feed, prices in pool currency, quorum of one. Feeder management (updateFeeder) is hub-manager-only; setPrice is feeder-only and also triggers a hub holding re-mark so the books track the new price immediately. getPrice reverts PriceNotSet until a price has been fed.
  • IdentityValuation — a constant 1.0 price with decimal conversion only, for stablecoins and pegged assets.