Skip to content

Commit 48f5cdb

Browse files
security: H-03 sequencer uptime check + H-04 bad debt resolution
Fixes two confirmed HIGH findings from internal pre-audit security review prior to LOTIQUE LAB submission. H-03 (OracleModule): Add Chainlink L2 sequencer uptime check to getPrice(). When sequencerFeed is configured, reverts if the Arbitrum sequencer is down or within the 3600-second post-restart grace period (SEQ_GRACE_PERIOD). Optional: address(0) disables the check for non-Arbitrum networks. Wired to Arbitrum One sequencer feed (0xFdB631F5EE196F0ed6FAa767959853A9F217697D) in .env.mainnet.example and both deploy scripts via SEQUENCER_FEED env var. OracleModule constructor gains a fourth parameter: address sequencerFeed_. H-04 (VaultManager): Add resolveBadDebt() GUARDIAN-gated emergency function for irrecoverably underwater positions where seize > collateral prevents normal liquidation. Clears vault state, seizes remaining collateral to caller, tracks uncovered debt in totalBadDebt, emits BadDebtResolved event. Operable while paused for incident response. Normal liquidation unaffected. Tests: +22 new tests (OracleModuleSequencer.t.sol, VaultBadDebt.t.sol). 183/183 tests pass. All existing test suites updated for new constructor sig. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 99f25df commit 48f5cdb

17 files changed

Lines changed: 1193 additions & 15 deletions

.env.mainnet.example

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# ============================================================
2+
# NEXUS Finance — Mainnet Environment Template
3+
# Network: Arbitrum One (chain ID 42161)
4+
# Date: 2026-03-21
5+
# ============================================================
6+
# USAGE:
7+
# cp .env.mainnet.example .env.mainnet
8+
# Fill in [TBD] values before deploy
9+
# source .env.mainnet
10+
# forge script script/DeployCoreHardened.s.sol --rpc-url $ARBITRUM_ONE_RPC_URL --broadcast
11+
#
12+
# SECURITY:
13+
# NEVER commit .env.mainnet to version control.
14+
# .env.mainnet is in .gitignore.
15+
# ============================================================
16+
17+
# -------------------------------------------------------
18+
# RPC
19+
# -------------------------------------------------------
20+
ARBITRUM_ONE_RPC_URL=https://arb1.arbitrum.io/rpc
21+
22+
# -------------------------------------------------------
23+
# ORACLE PARAMETERS
24+
# Confirmed: Oracle Phase 2.1 (2026-03-21)
25+
# Evidence: docs/ORACLE_MAINNET_CONFIG.md
26+
# -------------------------------------------------------
27+
28+
# Chainlink ETH/USD feed on Arbitrum One
29+
# Verified on-chain: decimals=8, description="ETH / USD", version=4
30+
# Live at block 444134833: answer=$2,155.71, age=146s
31+
ORACLE_FEED=0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612
32+
33+
# H-03 FIX: Chainlink Arbitrum One sequencer uptime feed.
34+
# Required for Arbitrum deployments to enforce sequencer liveness check.
35+
# Prevents liquidation raids on positions that could not defend themselves
36+
# during sequencer downtime. Grace period: 3600s (OracleModule.SEQ_GRACE_PERIOD).
37+
# Source: https://docs.chain.link/data-feeds/l2-sequencer-feeds
38+
# Verified on Arbitrum One: latestRoundData().answer==0 (sequencer up), decimals=0
39+
SEQUENCER_FEED=0xFdB631F5EE196F0ed6FAa767959853A9F217697D
40+
41+
# OracleModule.maxDelay (seconds)
42+
# Empirically derived: 25-round analysis, max gap=1261s, 3600s = 2.85× max
43+
# Deviation-driven feed (0.05%) — not heartbeat-driven
44+
ORACLE_MAX_DELAY=3600
45+
46+
# VaultManager.maxDelay (seconds)
47+
# MUST equal ORACLE_MAX_DELAY — tighter value silently governs all vault ops
48+
VAULT_MAX_DELAY=3600
49+
50+
# -------------------------------------------------------
51+
# COLLATERAL
52+
# -------------------------------------------------------
53+
# WETH on Arbitrum One — confirm via:
54+
# cast call 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1 "symbol()(string)" --rpc-url $ARBITRUM_ONE_RPC_URL
55+
# Expected: "WETH"
56+
COLLATERAL_ADDRESS=0x82aF49447D8a07e3bd95BD0d56f35241523fBab1
57+
58+
# -------------------------------------------------------
59+
# VAULT PARAMETERS
60+
# -------------------------------------------------------
61+
# Confirm these against NXUSD_GENESIS_PARAMETERS.md before deploy
62+
MIN_COLLATERAL_RATIO_BPS=15000 # 150%
63+
LIQUIDATION_RATIO_BPS=13000 # 130%
64+
CLOSE_FACTOR_BPS=5000 # 50%
65+
KEEPER_BONUS_BPS=500 # 5%
66+
67+
# -------------------------------------------------------
68+
# GOVERNANCE ADDRESSES
69+
# TBD — fill before deploy
70+
# -------------------------------------------------------
71+
MAINNET_SAFE=[TBD] # New mainnet Safe — NOT the testnet Safe
72+
MAINNET_TIMELOCK=[TBD] # Deployed via DeployTimelockController.s.sol (GOV-03)
73+
MAINNET_GUARDIAN=[TBD] # Option A or B from MAINNET_OPERATIONS_PACK.md
74+
MAINNET_KEEPER_BOT=[TBD] # Primary automated keeper address
75+
MAINNET_KEEPER_BACKUP=[TBD] # Secondary keeper address
76+
77+
# -------------------------------------------------------
78+
# DEPLOY CREDENTIALS
79+
# TBD — provide at deploy time; never store in file
80+
# -------------------------------------------------------
81+
# PRIVATE_KEY=[deployer private key — provide via env at deploy time]
82+
# ARBISCAN_API_KEY=[for contract verification]
83+
84+
# -------------------------------------------------------
85+
# SUPPLY CAP
86+
# -------------------------------------------------------
87+
# 0 = inactive at deploy; activate via governance post-launch
88+
INITIAL_SUPPLY_CAP=0

script/DeployCore.s.sol

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ contract DeployCore is Script {
1414
address oracleFeed;
1515
address collateralToken;
1616
address keeper;
17+
// H-03: Chainlink L2 sequencer uptime feed. address(0) disables the check.
18+
// Set to 0xFdB631F5EE196F0ed6FAa767959853A9F217697D on Arbitrum One.
19+
// Leave as address(0) on testnets or non-Arbitrum networks.
20+
address sequencerFeed;
1721
uint256 oracleMaxDelay;
1822
uint256 vaultMaxDelay;
1923
uint256 minCollateralRatioBps;
@@ -26,6 +30,9 @@ contract DeployCore is Script {
2630
cfg.oracleFeed = vm.envAddress("ORACLE_FEED");
2731
cfg.collateralToken = vm.envAddress("COLLATERAL_TOKEN");
2832
cfg.keeper = vm.envAddress("KEEPER");
33+
// SEQUENCER_FEED: set to address(0) for testnets; Arbitrum One sequencer
34+
// uptime feed address for mainnet. See OracleModule.sequencerFeed.
35+
cfg.sequencerFeed = vm.envOr("SEQUENCER_FEED", address(0));
2936

3037
cfg.oracleMaxDelay = vm.envUint("ORACLE_MAX_DELAY");
3138
cfg.vaultMaxDelay = vm.envUint("VAULT_MAX_DELAY");
@@ -42,7 +49,7 @@ contract DeployCore is Script {
4249

4350
NXUSDToken nxusd = new NXUSDToken(cfg.admin);
4451

45-
OracleModule oracle = new OracleModule(cfg.admin, cfg.oracleFeed, cfg.oracleMaxDelay);
52+
OracleModule oracle = new OracleModule(cfg.admin, cfg.oracleFeed, cfg.oracleMaxDelay, cfg.sequencerFeed);
4653

4754
VaultManager vault = new VaultManager(
4855
cfg.admin,
@@ -73,6 +80,7 @@ contract DeployCore is Script {
7380
console2.log("Admin :", cfg.admin);
7481
console2.log("Keeper :", cfg.keeper);
7582
console2.log("Oracle Feed :", cfg.oracleFeed);
83+
console2.log("Sequencer Feed :", cfg.sequencerFeed);
7684
console2.log("Collateral Token :", cfg.collateralToken);
7785
}
7886
}

script/DeployCoreHardened.s.sol

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ contract DeployCoreHardenedScript is Script {
2121
address collateralToken;
2222
address keeper;
2323
address guardian;
24+
// H-03: Chainlink L2 sequencer uptime feed. address(0) disables the check.
25+
// Arbitrum One: 0xFdB631F5EE196F0ed6FAa767959853A9F217697D
26+
// Testnets / non-Arbitrum: address(0)
27+
address sequencerFeed;
2428
uint256 oracleMaxDelay;
2529
uint256 vaultMaxDelay;
2630
uint256 minCollateralRatioBps;
@@ -34,6 +38,9 @@ contract DeployCoreHardenedScript is Script {
3438
cfg.collateralToken = vm.envAddress("COLLATERAL_TOKEN");
3539
cfg.keeper = vm.envAddress("KEEPER");
3640
cfg.guardian = vm.envAddress("GUARDIAN");
41+
// SEQUENCER_FEED: mandatory for Arbitrum One mainnet deployments.
42+
// Omitting or setting to address(0) disables the H-03 protection.
43+
cfg.sequencerFeed = vm.envOr("SEQUENCER_FEED", address(0));
3744

3845
cfg.oracleMaxDelay = vm.envUint("ORACLE_MAX_DELAY");
3946
cfg.vaultMaxDelay = vm.envUint("VAULT_MAX_DELAY");
@@ -50,7 +57,7 @@ contract DeployCoreHardenedScript is Script {
5057

5158
NXUSDToken nxusd = new NXUSDToken(cfg.admin);
5259

53-
OracleModule oracle = new OracleModule(cfg.admin, cfg.oracleFeed, cfg.oracleMaxDelay);
60+
OracleModule oracle = new OracleModule(cfg.admin, cfg.oracleFeed, cfg.oracleMaxDelay, cfg.sequencerFeed);
5461

5562
VaultManager vault = new VaultManager(
5663
cfg.admin,
@@ -93,6 +100,7 @@ contract DeployCoreHardenedScript is Script {
93100
console2.log("Keeper :", cfg.keeper);
94101
console2.log("Guardian :", cfg.guardian);
95102
console2.log("Oracle Feed :", cfg.oracleFeed);
103+
console2.log("Sequencer Feed :", cfg.sequencerFeed);
96104
console2.log("Collateral Token :", cfg.collateralToken);
97105
console2.log("Vault paused :", vault.paused());
98106
console2.log("Liq paused :", liq.paused());

script/DeploySmokeStack.s.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ contract DeploySmokeStack is Script {
4949

5050
NXUSDToken nxusd = new NXUSDToken(cfg.admin);
5151

52-
OracleModule oracle = new OracleModule(cfg.admin, address(feed), cfg.oracleMaxDelay);
52+
OracleModule oracle = new OracleModule(cfg.admin, address(feed), cfg.oracleMaxDelay, address(0));
5353

5454
VaultManager vault = new VaultManager(
5555
cfg.admin,

src/oracle/OracleModule.sol

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,52 @@ interface IAggregatorV3 {
1212
}
1313

1414
contract OracleModule is AccessControl {
15+
/// @notice Hard upper bound on maxDelay. Prevents disabling freshness checks
16+
/// via extreme values (e.g. type(uint256).max). 7 days is generous
17+
/// enough for irregular testnet feeds while blocking the attack vector.
18+
uint256 public constant MAX_DELAY = 7 days;
19+
20+
/// @notice Grace period enforced after Arbitrum sequencer restart before prices
21+
/// are accepted. Prevents liquidation raids on positions that could not
22+
/// defend themselves during sequencer downtime.
23+
/// Chainlink recommendation for Arbitrum L2 deployments: minimum 3600s.
24+
uint256 public constant SEQ_GRACE_PERIOD = 3600;
25+
1526
IAggregatorV3 public feed;
1627
uint256 public maxDelay;
1728

29+
/// @notice Chainlink L2 sequencer uptime feed. address(0) = check disabled.
30+
/// Must be set to the Arbitrum sequencer uptime feed on Arbitrum One:
31+
/// 0xFdB631F5EE196F0ed6FAa767959853A9F217697D
32+
/// Leave as address(0) on non-Arbitrum networks or in testing.
33+
IAggregatorV3 public sequencerFeed;
34+
1835
event OracleFeedSet(address indexed feed, address indexed by);
1936
event OracleMaxDelaySet(uint256 maxDelay, address indexed by);
37+
event SequencerFeedSet(address indexed feed, address indexed by);
2038

21-
constructor(address admin, address feed_, uint256 maxDelay_) {
39+
/// @param admin Address granted DEFAULT_ADMIN_ROLE.
40+
/// @param feed_ Chainlink price feed address.
41+
/// @param maxDelay_ Maximum accepted age of price data (seconds).
42+
/// @param sequencerFeed_ Chainlink L2 sequencer uptime feed. address(0) disables
43+
/// the check (use on non-Arbitrum networks or in tests).
44+
constructor(address admin, address feed_, uint256 maxDelay_, address sequencerFeed_) {
2245
require(admin != address(0), "ORACLE: admin is zero");
2346
require(feed_ != address(0), "ORACLE: feed is zero");
2447
require(maxDelay_ > 0, "ORACLE: maxDelay is zero");
48+
require(maxDelay_ <= MAX_DELAY, "ORACLE: maxDelay too large");
2549

2650
_grantRole(DEFAULT_ADMIN_ROLE, admin);
2751

2852
feed = IAggregatorV3(feed_);
2953
maxDelay = maxDelay_;
3054

55+
// sequencerFeed_ == address(0) is valid and disables the uptime check.
56+
sequencerFeed = IAggregatorV3(sequencerFeed_);
57+
3158
emit OracleFeedSet(feed_, admin);
3259
emit OracleMaxDelaySet(maxDelay_, admin);
60+
emit SequencerFeedSet(sequencerFeed_, admin);
3361
}
3462

3563
function setFeed(address feed_) external onlyRole(DEFAULT_ADMIN_ROLE) {
@@ -47,11 +75,46 @@ contract OracleModule is AccessControl {
4775

4876
function setMaxDelay(uint256 maxDelay_) external onlyRole(DEFAULT_ADMIN_ROLE) {
4977
require(maxDelay_ > 0, "ORACLE: maxDelay is zero");
78+
require(maxDelay_ <= MAX_DELAY, "ORACLE: maxDelay too large");
5079
maxDelay = maxDelay_;
5180
emit OracleMaxDelaySet(maxDelay_, msg.sender);
5281
}
5382

83+
/// @notice Set or clear the Arbitrum L2 sequencer uptime feed.
84+
/// Pass address(0) to disable the check (non-Arbitrum networks).
85+
/// Pass the Chainlink sequencer uptime feed address to enable it.
86+
function setSequencerFeed(address sequencerFeed_) external onlyRole(DEFAULT_ADMIN_ROLE) {
87+
sequencerFeed = IAggregatorV3(sequencerFeed_);
88+
emit SequencerFeedSet(sequencerFeed_, msg.sender);
89+
}
90+
91+
/// @notice Returns the current price from the configured Chainlink feed.
92+
///
93+
/// H-03 FIX: When sequencerFeed is configured, checks the Chainlink
94+
/// Arbitrum sequencer uptime feed before accepting any price data:
95+
/// - answer == 0: sequencer is up (Chainlink convention)
96+
/// - answer != 0: sequencer is down → revert
97+
/// - SEQ_GRACE_PERIOD must have elapsed since last sequencer restart
98+
/// before prices are accepted, preventing liquidation raids on positions
99+
/// that could not be defended during downtime.
54100
function getPrice() external view returns (uint256 price, uint256 updatedAt, uint8 decimals) {
101+
// ── L2 sequencer uptime check ─────────────────────────────────────────
102+
// Only executed when sequencerFeed is configured (non-zero).
103+
// Chainlink L2 sequencer uptime feed convention:
104+
// latestRoundData().answer == 0 → sequencer is UP
105+
// latestRoundData().answer != 0 → sequencer is DOWN
106+
// latestRoundData().startedAt → timestamp of last state change
107+
//
108+
// After a restart (answer flips to 0), startedAt records the restart time.
109+
// The grace period ensures that at least SEQ_GRACE_PERIOD seconds have passed
110+
// since restart before any price-sensitive operation is allowed, giving users
111+
// time to respond to price changes that occurred during downtime.
112+
if (address(sequencerFeed) != address(0)) {
113+
(, int256 seqAnswer, uint256 seqStartedAt,,) = sequencerFeed.latestRoundData();
114+
require(seqAnswer == 0, "ORACLE: sequencer down");
115+
require(block.timestamp - seqStartedAt >= SEQ_GRACE_PERIOD, "ORACLE: sequencer grace period");
116+
}
117+
55118
decimals = feed.decimals();
56119

57120
(, int256 answer,, uint256 upd,) = feed.latestRoundData();

0 commit comments

Comments
 (0)