Skip to content

Spoke Contracts

The spoke module is the per-chain side of the protocol: the local registry of pools and share tokens, asset custody, the balance-sheet queue that reports back to the hub, and the transfer hooks that enforce compliance. All spoke contracts are unmodified Centrifuge V3 (see the fork framing).

Contract Role
Spoke Local registry of pools, share classes, share tokens, assets, prices
VaultRegistry Deploys, links, and unlinks vaults
BalanceSheet Asset/share movement engine with cross-chain reporting queues
ShareToken Restricted ERC-20 share token (ERC-1404)
Transfer hooks Per-token compliance logic (four variants)
PoolEscrow (+ factory) Per-pool custody with reservations

Spoke

Spoke is the local per-chain registry of pools, share classes, share tokens, assets, and prices.

Access model. Inbound gateway-handler methods (addPool, addShareClass, setRequestManager, share metadata/hook updates, updateRestriction, price updates, executeTransferShares) are ward-gated — the MessageProcessor is the ward, so these fire only on verified Hub messages. User-facing methods (crosschainTransferShares, registerAsset, the untrusted updateContract variant) are reentrancy-guarded and permissionless, subject to hook checks.

Key mechanics:

  • addShareClass deploys the share token through the token factory and links it; addPool deploys the pool escrow through the escrow factory.
  • Asset registration: decimals are fetched via staticcall and bounded 2–18; bidirectional AssetId ↔ (asset, tokenId) maps are maintained (tokenId = 0 for plain ERC-20s).
  • Cross-chain share transfers: the outbound path checks the share token's transfer restriction with the destination chain encoded as a pseudo-address — address(uint160(destCentrifugeId)) — then burns locally and dispatches InitiateTransferShares. The inbound side mints and then transfers, so the hook runs normally. This pseudo-address encoding is why FullRestrictions requires whitelisting the destination chain's pseudo-address to open a transfer corridor (see hooks below).
  • Price staleness: each price stores {computedAt, maxAge, validUntil}. Older prices are rejected on update; maxAge defaults to unlimited until configured; price views can enforce validity checks.

VaultRegistry

Ward = MessageProcessor: vault deployment and linking is driven entirely by verified Hub messages (UpdateVault with a VaultUpdateKind):

  • DeployAndLink: look up the asset and share token from the Spoke → deploy via the vault factory → assert that async vaults have a request manager configured (preventing misconfigured deployments) → register the vault (recording asset details and verifying the vault's own pool/share-class IDs) → link it.
  • Link: requires the vault to be registered and not yet linked; records the vault per (pool, shareClass, asset) with its request manager; for ERC-20 assets the vault is also registered on the share token.
  • Unlink: the reverse.

Views: vaultDetails(vault), isLinked(vault).

BalanceSheet — the queue

BalanceSheet is where pool assets and shares actually move on a spoke chain, and it is the component that makes multi-chain operation affordable: instead of sending a cross-chain message per movement, it accumulates net deltas in local queues and submits them to the hub in aggregate.

The balance-sheet manager role

Operational methods (deposit, withdraw, issue, revoke, reserve/unreserve, noteDeposit/noteWithdraw, price overrides, submitQueued*, transferSharesFrom) require the caller to be a balance-sheet manager for the pool. This role is granted only through the ward-gated updateManager — i.e. by the Hub's UpdateBalanceSheetManager message. The Hub authorizes a spoke-side manager cross-chain; that manager then drives balance-sheet operations locally.

This is the role the Arkonix proof managers hold — it is what scopes their execution authority over pool funds. See Arkonix Extensions.

Queue design

  • issue/revoke mint or burn share tokens immediately and locally, but only accumulate a net signed delta in the share queue (per pool and share class).
  • noteDeposit/noteWithdraw accumulate per-asset deposit/withdrawal totals in the asset queues.
  • submitQueuedAssets(pool, scId, assetId, ...) sends the net asset delta to the Hub (as an UpdateHoldingAmount message) together with the current price and an isSnapshot flag — true when the submission fully drains all queues, marking the chain as in sync. submitQueuedShares mirrors this for the share delta. The hub's Holdings snapshots consume these flags.
  • Nonces order the submissions.

Custody mechanics

  • deposit pulls assets from the caller into the pool escrow; withdraw supports three modes — TransferOnly, EscrowAndTransfer, and Full — controlling whether escrow accounting and the Hub queue are engaged alongside the transfer.
  • reserve/unreserve lock escrow funds per reason, so committed funds cannot be double-spent.
  • transferSharesFrom refuses to move shares out of endorsed contracts (protecting, for example, escrowed shares).
  • Transient price overrides let the AsyncRequestManager pin exact fulfillment prices atomically for a fulfillment, falling back to validity-checked Spoke prices otherwise.

ShareToken

A restricted ERC-20 (with ERC-1404 introspection). Each user's balance slot packs a uint128 amount with 16 bytes of per-user hook data used by the transfer hooks.

  • Every transfer, transferFrom, mint, and burn calls the token's hook (onERC20Transfer) after the state change and reverts unless the hook returns its exact selector — compliance is enforced on every path.
  • authTransferFrom (ward-only) bypasses approvals via a non-reverting hook variant; it is used by system contracts.
  • checkTransferRestriction / detectTransferRestriction / messageForTransferRestriction delegate to the hook (ERC-1404).
  • setHookData is callable by a ward or the hook itself; mint caps total supply at type(uint128).max.

Transfer hooks

Transfer hooks are the compliance layer: per-share-token contracts that approve or reject every transfer based on membership (whitelisting) and freeze state.

BaseTransferHook

All four hooks share a base:

  • Hook data layout (16 bytes per user, stored on the ShareToken): the high 8 bytes are a memberlist validUntil timestamp; the lowest bit is the frozen flag. Membership means validUntil >= block.timestamp. Root-endorsed addresses and pool escrows always pass as transfer targets and can never be frozen.
  • Update flow: the Hub's updateRestriction travels cross-chain to the Spoke, which forwards to the hook; the payload decodes to Member, Freeze, or Unfreeze updates. Per-token managers (granted via the trusted-call channel) may also call updateMember/freeze/unfreeze directly.
  • Transfer-context classification: the hook classifies each transfer by address pattern (mint-to-escrow = deposit fulfillment, burn-from-balance-sheet = redeem fulfillment, transfer-to-escrow-marker = redeem request, and so on), so different rules can apply per lifecycle stage.

The four hooks

All hooks reject transfers when the source or target is frozen. Beyond that:

Hook Restriction logic Plain transfers
FreezeOnly Freeze only; no memberlist Free
RedemptionRestrictions Redeem requests require source membership Free
FreelyTransferable Deposit request/claim require target membership; redeem request/claim require source membership Free
FullRestrictions As FreelyTransferable, plus every ordinary transfer requires the recipient to be a member; cross-chain execution requires target membership Recipient must be whitelisted

Cross-chain corridors under FullRestrictions

Because outbound cross-chain transfers encode the destination chain as a pseudo-address, a FullRestrictions token must whitelist address(uint160(destCentrifugeId)) as a member on the source chain to permit transfers to that chain.

PoolEscrow and factory

PoolEscrow is the per-pool custody contract, segmented by share class, with a reservation system: for each (shareClass, asset) it tracks {total, reserved} plus per-reserver/per-reason bookkeeping. Withdrawals are only allowed against available = total − reserved, so reserved funds cannot be double-spent. All mutators are ward-gated.

The availableBalanceOf(scId, asset, tokenId) view exposes live available balances per share class — this is the composition source that Arkonix index-strategy managers read when computing rebalances.

PoolEscrowFactory deploys escrows deterministically (salt = pool ID), relies Root and the BalanceSheet on the new escrow, then denies itself — following the factories-retain-no-power convention.