Skip to content

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 Auth itself — 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 Root and then denies itself, so Root (and whatever Root relies) 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 Hub and the per-pool isManager check on BalanceSheet. 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:

file(bytes32 what, address/uint data)   // auth; emits File

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() (not msg.sender) and the paid value via msgValue(), 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 → wait delay → executeScheduledRely (which anyone may call once due).
  • Pause and revocations are instant. Defensive actions never wait.
  • The timelock delay is 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)