Overcollateralized stablecoin infrastructure on Arbitrum. This document is the primary reviewer navigation map for LOTIQUE LAB and any third-party security auditor.
- System Overview
- Architecture Map
- Key Security Design Decisions
- Protocol Invariants
- Reviewer Guide — Where to Look First
- Known Tradeoffs
- Test Coverage Summary
- Deployed Contracts
- Development Quick Start
NEXUS is an overcollateralized stablecoin protocol. Users deposit WETH as collateral and mint NXUSD, a USD-pegged synthetic debt token. The protocol is non-algorithmic: every NXUSD in circulation is backed by on-chain collateral at a minimum ratio of 150%.
Core properties:
- NXUSD is minted only when the user's vault meets the minimum collateralization ratio (minCR ≥ 150%).
- NXUSD is a pure ERC-20 with role-gated mint/burn. No rebase, no algorithmic supply adjustment.
- Liquidation is enforced externally by the
LiquidationEnginewhich respects a configurable close factor. - Oracle price data is sourced from Chainlink with staleness, completeness, and L2 sequencer checks.
- Emergency bad debt is resolved via a guardian-only path (
resolveBadDebt), not silent accounting.
┌─────────────────────────────────────────────────────────────────────┐
│ EXTERNAL ACTORS │
│ User (depositor/borrower) │ Keeper (liquidation bot) │ Admin │
└──────────────┬──────────────┴────────────┬────────────────┴────┬────┘
│ │ │
▼ ▼ │
┌─────────────────┐ ┌──────────────────┐ │
│ VaultManager │◄───────│ LiquidationEngine │ │
│ (accounting) │ │ (enforcement) │ │
└────────┬────────┘ └──────────────────┘ │
│ │ │
mint/burn vault.liquidate() │
▼ │ │
┌─────────────────┐ │ │
│ NXUSDToken │◄───────────────┘ │
│ (debt token) │ │
└─────────────────┘ │
│ │
price checks │
▼ │
┌─────────────────┐ │
│ OracleModule │◄───────────────────────────────────────┘
│ (price source) │ (setFeed, setMaxDelay, setSequencerFeed)
└─────────────────┘
▲
Chainlink AggregatorV3 (ETH/USD + sequencer uptime feed)
| Caller | Target | Function | Notes |
|---|---|---|---|
| User | VaultManager | deposit, withdraw, mint, burn |
Permissionless, pauseable |
| Keeper (via LiqEngine) | VaultManager | liquidate |
Only liqEngine address may call this |
| Keeper | LiquidationEngine | executeLiquidation |
Requires KEEPER_ROLE on LiqEngine |
| Guardian | VaultManager | resolveBadDebt, pause |
Requires GUARDIAN_ROLE |
| Admin | VaultManager | setOracle, setRatios, setMaxDelay, setLiquidationEngine |
Requires DEFAULT_ADMIN_ROLE |
| Admin | OracleModule | setFeed, setMaxDelay, setSequencerFeed |
Requires DEFAULT_ADMIN_ROLE |
| Admin | LiquidationEngine | setVault, setCloseFactor |
Requires DEFAULT_ADMIN_ROLE |
| Admin | NXUSDToken | setMinter, setBurner, setMaxSupply |
Requires DEFAULT_ADMIN_ROLE |
| Check | Location | Function |
|---|---|---|
| Minimum CR enforcement | VaultManager._isSafe() |
Called by mint() and withdraw() |
| Liquidation eligibility | VaultManager.isLiquidatable() |
CR < liquidationRatioBps |
| Close factor limit | LiquidationEngine.executeLiquidation() |
repayAmount ≤ debt * closeFactorBps / 10000 |
| Oracle staleness (coarse) | OracleModule.getPrice() |
block.timestamp - updatedAt ≤ maxDelay |
| Oracle staleness (fine) | VaultManager._oracleSnapshot() |
Independent second window |
| Oracle round completeness | OracleModule.getPrice() |
answeredInRound >= roundId |
| L2 sequencer uptime | OracleModule.getPrice() |
Chainlink sequencer feed + grace period |
| Bad debt gateway | VaultManager.resolveBadDebt() |
seizeForFullDebt > collateral required |
- LiquidationEngine is not the accounting layer. It only enforces the close factor and dispatches to the vault. It holds no collateral, no debt records.
- NXUSDToken is not the policy layer. It enforces minter/burner roles and an optional supply cap. All collateral logic is in VaultManager.
- OracleModule is not the freshness arbiter alone. VaultManager applies a second independent staleness window. This is defense-in-depth.
VaultManager.liquidate() is gated by require(msg.sender == liqEngine). No role (not even KEEPER_ROLE directly on the vault) can bypass this.
Why: Before this restriction, any address with KEEPER_ROLE on the vault could call liquidate() directly, bypassing LiquidationEngine's close factor limit. A rogue keeper could liquidate 100% of any position in a single transaction regardless of the configured close factor. The restriction routes all liquidations through the engine, which enforces repayAmount ≤ debt * closeFactorBps / 10000.
Configuration: liqEngine defaults to address(0) (liquidation blocked). Admin must call setLiquidationEngine(address(liqEngine)) post-deployment.
When resolveBadDebt() is called, the debtor's vault state is zeroed and collateral is transferred to the guardian. The corresponding NXUSD is not burned. It remains in circulation, tracked by totalBadDebt.
Why: The vault does not hold NXUSD (it holds WETH). Burning NXUSD from circulation requires a separate protocol action (governance vote, recapitalization, or protocol treasury burn). Forcing a burn here would require the vault to hold NXUSD reserves, which is a significant design change. The current model makes the undercollateralization explicit and auditable on-chain via totalBadDebt.
Auditor note: totalBadDebt represents the NXUSD supply that is no longer backed by protocol collateral. Governance must track and address this. This is not a silent write-off.
The oracle safety model has five independent checks:
- Sequencer uptime (
OracleModule): On Arbitrum, validates the sequencer feed is active. Prevents accepting prices during sequencer downtime when positions could not be defended. - Grace period (
OracleModule): After sequencer restart, enforces a 3600s buffer (SEQ_GRACE_PERIOD) before accepting any price. Prevents liquidation raids on positions that could not be defended during downtime. - Staleness — oracle layer (
OracleModule):block.timestamp - updatedAt ≤ maxDelay. - Staleness — vault layer (
VaultManager._oracleSnapshot): Independent second staleness window. Effective limit = min(oracle.maxDelay, vault.maxDelay). - Round completeness (
OracleModule):answeredInRound >= roundId. Prevents consuming an incomplete Chainlink round where the aggregator has not yet written the answer for the current period.
| Role | Holder | Capabilities | Who holds it |
|---|---|---|---|
DEFAULT_ADMIN_ROLE |
VaultManager, LiqEngine, OracleModule | Parameter changes, oracle rotation, liqEngine registration, role management | Safe 4-of-7 multisig |
GUARDIAN_ROLE |
VaultManager | pause(), resolveBadDebt() |
Safe 4-of-7 multisig (same as admin at current stage) |
KEEPER_ROLE |
LiquidationEngine | executeLiquidation() |
Keeper bot EOA/contract |
MINTER_ROLE |
NXUSDToken | mint() |
VaultManager only |
BURNER_ROLE |
NXUSDToken | burn() |
VaultManager only |
The vault's KEEPER_ROLE is granted to LiquidationEngine for operational compatibility (allows legacy role checks), but liquidate() now strictly requires msg.sender == liqEngine. The role alone is no longer sufficient to call liquidate().
These are the hard invariants the codebase is designed to enforce:
collateralOf[user] * price / denom * 10000 ≥ debtOf[user] * minCollateralRatioBps
Enforced by _isSafe() inside mint() and withdraw(). Cannot be bypassed.
debtOf[user] > 0 ∧ collateralOf[user] == 0 → _isSafe() returns false → mint() reverts.
Constructor enforces minCrBps ≥ liqCrBps. The gap creates the liquidatable range.
repayAmount ≤ debtOf[account] * closeFactorBps / 10000 enforced in LiquidationEngine.executeLiquidation(). All vault liquidations must pass through the engine (msg.sender == liqEngine).
Irrecoverable positions are resolved only through resolveBadDebt(), which requires guardian authorization and verifies that normal liquidation is impossible (seizeForFull > col). The uncovered amount is recorded in totalBadDebt.
NXUSDToken.maxSupply provides an optional hard cap. If maxSupply == 0, the cap is disabled. Protocol governance must set this. Default at deployment is uncapped.
All price-consuming paths (mint, withdraw, liquidate, resolveBadDebt, isLiquidatable) route through _oracleSnapshot() → OracleModule.getPrice(). If the oracle is stale, down, or incomplete, all price-dependent operations revert.
Core vault logic — collateral, debt, CR enforcement:
→ src/vault/VaultManager.sol
→ Focus: _isSafe(), _oracleSnapshot(), liquidate(), resolveBadDebt()
Liquidation enforcement — close factor, keeper flow:
→ src/vault/LiquidationEngine.sol
→ Focus: executeLiquidation() — token flow sequence, close factor check, vault call
Oracle safety — staleness, sequencer, completeness:
→ src/oracle/OracleModule.sol
→ Focus: getPrice() — all five check layers
Debt token — mint/burn authorization, supply cap:
→ src/core/NXUSDToken.sol
→ Focus: mint(), burn(), setMaxSupply(), role gating
1. Keeper calls: LiqEngine.executeLiquidation(account, repayAmount)
2. LiqEngine: checks vault.isLiquidatable(account)
3. LiqEngine: checks repayAmount ≤ debt * closeFactorBps / 10000
4. LiqEngine: NXUSD.transferFrom(keeper → liqEngine, repayAmount)
5. LiqEngine: NXUSD.transfer(liqEngine → account, repayAmount)
6. LiqEngine: vault.liquidate(account, keeper, repayAmount)
7. VaultManager: verifies msg.sender == liqEngine
8. VaultManager: verifies account is liquidatable (re-checks CR)
9. VaultManager: decrements debtOf[account], decrements collateralOf[account]
10. VaultManager: NXUSD.burn(account, repayAmount) ← burns the NXUSD we just sent
11. VaultManager: COLLATERAL.transfer(keeper, seizeAmount) ← keeper receives collateral
Steps 4–11 are a single atomic transaction. If any step reverts, all token moves unwind.
| Area | Why it needs close review |
|---|---|
resolveBadDebt() oracle dependency |
Calls _oracleSnapshot() — if oracle is stale during incident, guardian cannot clear bad debt even while vault is paused |
| Double staleness window | OracleModule + VaultManager each apply their own maxDelay. Effective limit = min(two). Ensure these are configured consistently in production. |
liqEngine rotation |
Admin can change liqEngine to a new engine with different close factor. Previous engine is immediately blocked. |
setOracle() smoke-check |
Calls getPrice() on the candidate oracle at commit time. Will revert if oracle is stale at that moment. Operational friction risk. |
maxSupply = 0 |
No supply cap by default. Governance must set setMaxSupply() to activate the constitution's 1B NXUSD limit. |
These are intentional design choices, not bugs. Listed here so the auditor can focus on logic verification rather than questioning design intent.
The vault has no NXUSD reserves to absorb the undercollateralized supply. totalBadDebt makes the liability explicit. A governance recapitalization mechanism (out of scope for this audit) is the intended resolution path. The tradeoff: NXUSD peg integrity depends on governance acting promptly after bad debt events.
The protocol has no on-chain automated liquidation. Positions that cross the liquidation threshold (130% CR) are only liquidated when a keeper calls executeLiquidation(). During keeper inactivity or gas spikes, positions can become more deeply undercollateralized. The resolveBadDebt() guardian path exists specifically to handle the cases where the keeper bonus makes normal liquidation impossible.
All price-sensitive operations revert when the oracle is stale or the sequencer is down. This is the correct behavior on L2. The tradeoff: during Chainlink downtime, users cannot mint, withdraw (if they have debt), or be liquidated. The 7-day MAX_DELAY hard cap and the 1-hour production maxDelay provide the operational envelope.
The close factor limit lives in LiquidationEngine. If admin rotates to a new engine with a higher close factor, previous protections no longer apply to new liquidations. This is a governance responsibility: any new liqEngine must be reviewed before activation.
When resolveBadDebt() is called, the remaining collateral is transferred to the guardian's address (the caller). With a Safe multisig as guardian, this requires 4-of-7 approval. The mechanism is a practical emergency path, not a fee mechanism.
Total: 197 tests across 14 suites. 197/197 passing.
| Suite | Tests | Focus |
|---|---|---|
VaultManager.t.sol |
5 | Core happy-path: deposit/withdraw/mint/burn/liquidate |
VaultManagerAdversarial.t.sol |
58 | Constructor guards, CR boundary precision, oracle staleness propagation, pause behavior, liquidation input validation, CEI ordering |
VaultBadDebt.t.sol |
12 | H-04: bad debt resolution, accounting correctness, event emission, pause bypass, unauthorized access |
VaultLiqEngineRestriction.t.sol |
11 | Task 1: liqEngine gate, keeper bypass prevention, admin-only setter, event, rotation |
LiquidationEngine.t.sol |
5 | Happy-path liquidation, close factor, keeper-only gate |
LiquidationEngineAdversarial.t.sol |
21 | Constructor guards, close factor boundary, full liquidation at 100%, NXUSD flow correctness |
LiquidationPause.t.sol |
4 | Pause blocks execution, guardian pause/unpause paths |
OracleModule.t.sol |
5 | Happy-path price fetch, decimals, maxDelay, setFeed |
OracleModuleAdversarial.t.sol |
19 | Constructor guards, setFeed smoke-check, staleness, future timestamp, stale round (Task 2) |
OracleModuleSequencer.t.sol |
10 | H-03: sequencer down, grace period, restart timing, disabled check |
NXUSDToken.t.sol |
8 | Mint/burn happy-path, role gating, supply cap |
NXUSDTokenAdversarial.t.sol |
13 | Constructor, role guards, cap below supply, zero-address minting |
TimelockGovernance.t.sol |
21 | Governance timelock mechanics |
VaultPause.t.sol |
5 | Pause blocks all user operations |
Test types:
- Adversarial / negative path: ~140 tests (reverts, access control, boundary values)
- Happy path / integration: ~40 tests
- Invariant-like / state verification: ~17 tests (CEI ordering, accounting correctness, event emission)
| Contract | Address | Status |
|---|---|---|
| NXUSDToken | 0x515844Dd91956C749e33521B4f171dac4e04FE07 |
Live |
| VaultManager | 0xF09AAD220C6c4d805cF6cE5561B546f51ADFBb03 |
Live |
| LiquidationEngine | 0xF333d9ae2D70305758E714ecBeA938e9377a9f9D |
Live |
| OracleModule | 0xa1BD5AF1174140caB018e46eBCFEf1d005c3df84 |
Live |
| WETH (collateral) | 0x980B62Da83eFf3D4576C647993b0c1D7faf17c73 |
Live |
| Safe Multisig | 0x8626240187bb366a8566D338b84a7F84f237164F |
Live (4-of-7) |
Production parameters:
- Min collateral ratio: 150% (15000 bps)
- Liquidation threshold: 130% (13000 bps)
- Keeper bonus: 5% (hardcoded in
_seizeAmountForRepay) - Close factor: 50% (5000 bps, configurable)
- Oracle maxDelay: 1 hour (both OracleModule and VaultManager)
- Oracle: Chainlink ETH/USD (8 decimals)
- L2 sequencer feed: Arbitrum sequencer uptime feed
# Build
forge build
# Run full test suite
forge test
# Run with gas reporting
forge test --gas-report
# Run specific suite
forge test --match-contract VaultManagerAdversarialTest
# Deploy (requires .env with PRIVATE_KEY, ADMIN, ORACLE_FEED, etc.)
forge script script/DeployCore.s.sol --rpc-url $ARBITRUM_SEPOLIA_RPC_URL --broadcastRepository structure:
nexus-contracts/
├── src/
│ ├── core/ NXUSDToken.sol
│ ├── vault/ VaultManager.sol, LiquidationEngine.sol
│ └── oracle/ OracleModule.sol
├── test/ Forge test suites (14 files, 197 tests)
├── script/ Deployment scripts
├── deployments/ Address registries (JSON)
├── docs/ Internal documentation
└── constitution/ Protocol invariant definitions
Proprietary — NEXUS Finance © 2026