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:
addShareClassdeploys the share token through the token factory and links it;addPooldeploys 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 = 0for 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 dispatchesInitiateTransferShares. The inbound side mints and then transfers, so the hook runs normally. This pseudo-address encoding is whyFullRestrictionsrequires 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;maxAgedefaults 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/revokemint or burn share tokens immediately and locally, but only accumulate a net signed delta in the share queue (per pool and share class).noteDeposit/noteWithdrawaccumulate per-asset deposit/withdrawal totals in the asset queues.submitQueuedAssets(pool, scId, assetId, ...)sends the net asset delta to the Hub (as anUpdateHoldingAmountmessage) together with the current price and anisSnapshotflag — true when the submission fully drains all queues, marking the chain as in sync.submitQueuedSharesmirrors this for the share delta. The hub's Holdings snapshots consume these flags.- Nonces order the submissions.
Custody mechanics¶
depositpulls assets from the caller into the pool escrow;withdrawsupports three modes —TransferOnly,EscrowAndTransfer, andFull— controlling whether escrow accounting and the Hub queue are engaged alongside the transfer.reserve/unreservelock escrow funds per reason, so committed funds cannot be double-spent.transferSharesFromrefuses 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, andburncalls 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/messageForTransferRestrictiondelegate to the hook (ERC-1404).setHookDatais callable by a ward or the hook itself;mintcaps total supply attype(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
validUntiltimestamp; the lowest bit is the frozen flag. Membership meansvalidUntil >= block.timestamp. Root-endorsed addresses and pool escrows always pass as transfer targets and can never be frozen. - Update flow: the Hub's
updateRestrictiontravels cross-chain to the Spoke, which forwards to the hook; the payload decodes toMember,Freeze, orUnfreezeupdates. Per-token managers (granted via the trusted-call channel) may also callupdateMember/freeze/unfreezedirectly. - 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.