Security Model¶
Every contract in the protocol shares a small set of security conventions. Understanding them once makes the rest of the section easy to read: almost every privileged call in the system reduces to "who is a ward here, and how did they become one?"
This page covers the on-chain access-control and governance model. For the off-chain key-management story (how Arkonix operates the keys that hold these roles), see Custody. For spells, deployment, and operational procedures, see Governance & Operations.
The ward pattern — primary security boundary¶
Authorization throughout the protocol is the ward pattern, implemented in a shared Auth base contract:
mapping(address => uint256) public wards; // 0 = no authority, 1 = full authority
modifier auth() { require(wards[msg.sender] == 1, NotAuthorized()); _; }
function rely(address user) external auth; // grant
function deny(address user) external auth; // revoke
Key properties:
- Exactly two authority states. There are no roles or tiers within
Authitself — an address is either a ward with full authority over the contract, or it has none. - Root is the apex. The deployment convention is that the deployer relies
Rootand then denies itself, soRoot(and whateverRootrelies) is the sole ultimate authority on every contract. - Role modifiers layer on top. Some contracts add scoped roles above the ward layer — for example the hub-manager check on
Huband the per-poolisManagercheck onBalanceSheet. These roles are themselves granted through ward-gated paths.
The file configuration pattern¶
Wireable dependencies and parameters are set through a uniform ward-only setter:
This pattern is used consistently across the system (Gateway, Hub, Spoke, the guardians, and others), so configuration changes are always ward-gated and always observable through File events.
One deliberate exception
ArkonixFeeManager does not use the ward pattern for its admin — it uses a two-step admin/pendingAdmin transfer scheme. See Arkonix Extensions.
Custom ID types — cross-pool safety¶
Raw integers are never used for protocol identifiers. Strongly-typed IDs make it impossible to accidentally route funds or messages across pools:
| Type | Underlying | Layout / construction |
|---|---|---|
PoolId |
uint64 |
High 16 bits = centrifugeId (chain identifier), low 48 bits = local counter |
ShareClassId |
bytes16 |
Derived from the pool ID plus a per-pool index counter |
AssetId |
uint128 |
High 16 bits = centrifugeId, low bits = counter (or ISO-4217 code for currencies) |
D18 |
uint128 |
18-decimal fixed point — all protocol prices are D18 |
AccountId |
— | Hub accounting account identifiers |
Addresses crossing chains are carried as bytes32 (left-padded), so the message format is address-width agnostic.
Core patterns¶
- Checks-effects-interactions (CEI) throughout vault operations.
- Custom errors only (e.g.
error NotAuthorized();) — no revert strings. - Deterministic deployment: factories deploy at predictable CREATE2 addresses (salts derived from pool / share-class identifiers); top-level contracts use protocol-wide CREATE3 so addresses are identical across networks.
- Factories retain no power: every factory relies
Root(plus any required managers) on the contract it deploys, then denies itself. A factory can never touch a contract after deployment. - Batched-multicall caution: contracts that support gateway-batched multicalls must read the true caller via
msgSender()(notmsg.sender) and the paid value viamsgValue(), and must never treat the Gateway as an authenticated caller. See Cross-Chain Messaging.
Governance model¶
Root¶
Root is a ward on essentially every contract in the system. Its state: a global paused flag, a timelock delay, an endorsement set, and a schedule of pending rely grants.
file("delay", uint) // auth; capped at MAX_DELAY = 4 weeks
endorse(address) / veto(address) // auth; trusted-address whitelist
pause() / unpause() // auth; instant
scheduleRely(address) / cancelRely(address) // auth
executeScheduledRely(address) // PERMISSIONLESS once the timelock has passed
relyContract(target, user) / denyContract // auth; manage wards on any other contract
endorsed(address) view returns (bool)
The asymmetry is deliberate:
- New permissions are never instant. Granting a ward is two-step and timelocked:
scheduleRely→ waitdelay→executeScheduledRely(which anyone may call once due). - Pause and revocations are instant. Defensive actions never wait.
- The timelock
delayis capped at 4 weeks; operationally it is 48 hours on mainnet and 5 minutes on testnet.
Endorsements¶
endorse(address) maintains a whitelist of protocol-trusted addresses — for example the VaultRouter and escrows — which are exempted from transfer restrictions where the design requires it (transfer hooks always pass endorsed targets; BalanceSheet.transferSharesFrom refuses to move shares out of endorsed contracts). veto removes an endorsement.
Two guardians, separated duties¶
Privileged operations are split across two multisig safes with distinct mandates:
| Guardian | Safe | Responsibilities |
|---|---|---|
| ProtocolGuardian | Protocol (security-critical) multisig | scheduleRely/cancelRely on Root; scheduleUpgrade/cancelUpgrade (cross-chain rely on a remote Root); cross-chain recoverTokens; blockOutgoing on the Gateway |
| OpsGuardian | Operations multisig | One-time adapter initialization and wiring per chain (initAdapters, wire); createPool |
ProtocolGuardian functions are gated onlySafe, with one exception: pause() is onlySafeOrOwner — any individual owner of the protocol safe can emergency-pause the system. Only the full safe can unpause.
Spells and the timelock¶
All privileged changes ship as spells — one-shot governance contracts that pass through the Root timelock before they can act. Guardians cannot bypass the timelock, and there is no direct Root access on testnet or mainnet. The full spell lifecycle is documented in Governance & Operations.
Recovery paths¶
TokenRecoverer¶
TokenRecoverer recovers tokens stuck in protocol contracts without ever holding standing privilege. A recovery is a single atomic sequence:
root.relyContract(target, tokenRecoverer)
→ target.recoverTokens(token, tokenId, to, amount)
→ root.denyContract(target, tokenRecoverer)
The recoverer gains authority over the target only for the duration of the transaction. It is invoked either by governance or by the MessageProcessor on a RecoverTokens message — which is only accepted from mainnet (see message origin restrictions).
Bridge recovery¶
If the normal cross-chain message quorum cannot be met (for example, a bridge outage), a governance-controlled RecoveryAdapter can inject messages into the MultiAdapter voting process. See Cross-Chain Messaging for the recovery-index mechanism.
Summary: the authority chain¶
Multisig safes (ProtocolGuardian / OpsGuardian)
│ scheduleRely → timelock → executeScheduledRely
▼
Root ── ward on every contract; instant pause/deny
│ relyContract / spells
▼
System contracts (Hub, Gateway, Spoke, BalanceSheet, ...)
│ scoped roles granted via ward-gated paths
▼
Operational roles (hub managers, balance-sheet managers,
strategists / proof-manager signers)