From 5255da19b0799cbbffef709489bcb493c55a35d5 Mon Sep 17 00:00:00 2001 From: Rick Date: Thu, 18 Jun 2026 10:31:20 +0800 Subject: [PATCH 1/2] feat: move StockOracleSwitch setGlobal to MANAGER + add batchSetStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setGlobal (global market switch) now requires MANAGER instead of BOT; BOT keeps per-stock open/close. The sensitive daily/global switch moves to governance while routine per-stock toggling stays with the automation bot. - Add batchSetStatus(address[] tokens, bool status): sets every listed registered stock to status (true=open / false=close) in one call (BOT role). Idempotent — tokens already in the target state are skipped; an unregistered token reverts the whole batch. - Update tests for both changes + fix stale "BOT calls setGlobal" comments in deploy_stockOracle.sol. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/oracle/deploy_stockOracle.sol | 4 +- src/oracle/StockOracleSwitch.sol | 36 +++++--- test/oracle/StockOracle.t.sol | 4 +- test/oracle/StockOracleSwitch.t.sol | 126 ++++++++++++++++++++++++--- 4 files changed, 143 insertions(+), 27 deletions(-) diff --git a/script/oracle/deploy_stockOracle.sol b/script/oracle/deploy_stockOracle.sol index 953c97db..6cadfbbf 100644 --- a/script/oracle/deploy_stockOracle.sol +++ b/script/oracle/deploy_stockOracle.sol @@ -11,7 +11,7 @@ import { StockOracleSwitch } from "../../src/oracle/StockOracleSwitch.sol"; /// @notice Deploys StockOracleSwitch + StockOracle (UUPS proxies) and registers the managed bStocks. /// admin = manager = deployer (hand off later via deploy_stockOracleTransferRole.sol); /// BOT is a dedicated wallet. Network config (resilient oracle, bot, stock list) is resolved -/// by chain id. The market stays CLOSED after deploy (globalEnabled = false) until the BOT opens it. +/// by chain id. The market stays CLOSED after deploy (globalEnabled = false) until MANAGER opens it. contract StockOracleDeploy is DeployBase { function run() public { uint256 deployerPrivateKey = _deployerKey(); @@ -57,7 +57,7 @@ contract StockOracleDeploy is DeployBase { console.log("StockOracle proxy: ", address(oracle)); // --- Register managed bStocks. setStock also enables each per-stock; the market stays closed - // globally until the BOT calls setGlobal(true) during trading hours. --- + // globally until MANAGER calls setGlobal(true) during trading hours. --- for (uint256 i = 0; i < stocks.length; i++) { stockSwitch.setStock(stocks[i], true); console.log("registered stock: ", stocks[i]); diff --git a/src/oracle/StockOracleSwitch.sol b/src/oracle/StockOracleSwitch.sol index 8d1f22f2..90acc2be 100644 --- a/src/oracle/StockOracleSwitch.sol +++ b/src/oracle/StockOracleSwitch.sol @@ -6,15 +6,16 @@ import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils /// @title StockOracleSwitch /// @notice Market open/closed control for tokenized stocks (bStocks), consumed by {StockOracle}. -/// @dev Two orthogonal layers: +/// @dev Layers: /// - `registered` (MANAGER): whether a token is a managed stock. Unregistered tokens are NOT /// gated (e.g. the loan token USDT) — {StockOracle} passes them straight through. -/// - open/closed (BOT): a per-stock `enabled` flag plus a single `globalEnabled` market-hours -/// switch. A registered stock is enabled only while both the global switch and its own flag are on. +/// - per-stock `enabled` (BOT): toggled individually via open / close, or in bulk via batchSetStatus. +/// - global market switch `globalEnabled` (MANAGER): the market-hours switch (setGlobal). A +/// registered stock is enabled only while both the global switch and its own flag are on. /// PAUSER can force the whole market closed in an emergency (globalEnabled = false). contract StockOracleSwitch is AccessControlEnumerableUpgradeable, UUPSUpgradeable { - bytes32 public constant MANAGER = keccak256("MANAGER"); // register / un-register stocks; admins BOT - bytes32 public constant BOT = keccak256("BOT"); // global daily switch + per-stock open / close + bytes32 public constant MANAGER = keccak256("MANAGER"); // register / un-register stocks; global daily switch; admins BOT + bytes32 public constant BOT = keccak256("BOT"); // per-stock open / close (single + batch) bytes32 public constant PAUSER = keccak256("PAUSER"); // emergency force-close /// @dev token => is a managed stock. Unregistered (false) => passthrough (never gated). @@ -38,8 +39,8 @@ contract StockOracleSwitch is AccessControlEnumerableUpgradeable, UUPSUpgradeabl } /// @param admin DEFAULT_ADMIN_ROLE holder (upgrade + role admin) - /// @param manager MANAGER role (register / un-register stocks; admins BOT) - /// @param bot BOT role (global daily switch + per-stock open / close) + /// @param manager MANAGER role (register / un-register stocks; global daily switch; admins BOT) + /// @param bot BOT role (per-stock open / close, single + batch) /// @param pauser PAUSER role (emergency force-close) function initialize(address admin, address manager, address bot, address pauser) external initializer { require(admin != address(0), ZeroAddress()); @@ -54,7 +55,7 @@ contract StockOracleSwitch is AccessControlEnumerableUpgradeable, UUPSUpgradeabl _grantRole(PAUSER, pauser); // MANAGER administers the BOT role, so it can grant / revoke BOT via grantRole / revokeRole. _setRoleAdmin(BOT, MANAGER); - // globalEnabled stays false on purpose: market is closed until BOT opens it. + // globalEnabled stays false on purpose: market is closed until MANAGER opens it. } /// @notice Whether `token` is currently enabled (tradable). Unregistered tokens are always enabled (passthrough). @@ -93,8 +94,23 @@ contract StockOracleSwitch is AccessControlEnumerableUpgradeable, UUPSUpgradeabl emit StockEnable(token, false); } - /// @notice Toggle the global market switch (daily open/close). BOT role. - function setGlobal(bool open) external onlyRole(BOT) { + /// @notice Bulk per-stock open/close. BOT role. Sets every token in `tokens` to `status` + /// (true = open, false = close) in one call. + /// @dev Idempotent: a token already in its target state is skipped (no event). Only registered stocks + /// can be toggled — an unregistered token reverts the whole batch. + function batchSetStatus(address[] calldata tokens, bool status) external onlyRole(BOT) { + for (uint256 i; i < tokens.length; ++i) { + address token = tokens[i]; + require(registered[token], NotRegistered()); + if (enabled[token] != status) { + enabled[token] = status; + emit StockEnable(token, status); + } + } + } + + /// @notice Toggle the global market switch (daily open/close). MANAGER role. + function setGlobal(bool open) external onlyRole(MANAGER) { require(open != globalEnabled, AlreadySet()); globalEnabled = open; emit GlobalEnabledSet(open); diff --git a/test/oracle/StockOracle.t.sol b/test/oracle/StockOracle.t.sol index d391bb97..7835c468 100644 --- a/test/oracle/StockOracle.t.sol +++ b/test/oracle/StockOracle.t.sol @@ -64,7 +64,7 @@ contract StockOracleTest is Test { function _openStock(address token) internal { vm.prank(manager); stockSwitch.setStock(token, true); // registers AND enables - vm.prank(bot); + vm.prank(manager); stockSwitch.setGlobal(true); } @@ -149,7 +149,7 @@ contract StockOracleTest is Test { function test_peek_revertsWhenStockDisabled() public { vm.prank(manager); stockSwitch.setStock(stock, true); // registered + enabled - vm.prank(bot); + vm.prank(manager); stockSwitch.setGlobal(true); vm.prank(bot); stockSwitch.close(stock); // market open, but the stock itself is disabled diff --git a/test/oracle/StockOracleSwitch.t.sol b/test/oracle/StockOracleSwitch.t.sol index 5f0a40d8..db39c26c 100644 --- a/test/oracle/StockOracleSwitch.t.sol +++ b/test/oracle/StockOracleSwitch.t.sol @@ -49,7 +49,7 @@ contract StockOracleSwitchTest is Test { function _openStock(address token) internal { vm.prank(manager); sw.setStock(token, true); // registers AND enables - vm.prank(bot); + vm.prank(manager); sw.setGlobal(true); } @@ -115,7 +115,7 @@ contract StockOracleSwitchTest is Test { function test_isEnabled_unregisteredAlwaysEnabled() public { // unregistered token passes through (always enabled) regardless of the global switch assertTrue(sw.isEnabled(usdt), "unregistered should be enabled while global off"); - vm.prank(bot); + vm.prank(manager); sw.setGlobal(true); assertTrue(sw.isEnabled(usdt), "unregistered should be enabled while global on"); } @@ -129,7 +129,7 @@ contract StockOracleSwitchTest is Test { function test_isEnabled_falseWhileStockDisabled() public { vm.prank(manager); sw.setStock(stock, true); // registered + enabled - vm.prank(bot); + vm.prank(manager); sw.setGlobal(true); vm.prank(bot); sw.close(stock); // BOT closes this specific stock @@ -280,28 +280,124 @@ contract StockOracleSwitchTest is Test { } // ---------------------------------------------------------------------- - // setGlobal (BOT) + // batchSetStatus (BOT) — bulk open / close // ---------------------------------------------------------------------- - function test_setGlobal_botTogglesAndEmits() public { + function test_batchSetStatus_closesThenOpensAll() public { + address s1 = makeAddr("s1"); + address s2 = makeAddr("s2"); + vm.startPrank(manager); + sw.setStock(s1, true); // registered + enabled + sw.setStock(s2, true); // registered + enabled + vm.stopPrank(); + + address[] memory tokens = new address[](2); + tokens[0] = s1; + tokens[1] = s2; + + // close both in one call (events emitted in array order) + vm.expectEmit(true, false, false, true, address(sw)); + emit StockEnable(s1, false); + vm.expectEmit(true, false, false, true, address(sw)); + emit StockEnable(s2, false); + vm.prank(bot); + sw.batchSetStatus(tokens, false); + assertFalse(sw.enabled(s1), "s1 closed"); + assertFalse(sw.enabled(s2), "s2 closed"); + + // re-open both in one call + vm.expectEmit(true, false, false, true, address(sw)); + emit StockEnable(s1, true); + vm.expectEmit(true, false, false, true, address(sw)); + emit StockEnable(s2, true); + vm.prank(bot); + sw.batchSetStatus(tokens, true); + assertTrue(sw.enabled(s1), "s1 reopened"); + assertTrue(sw.enabled(s2), "s2 reopened"); + } + + function test_batchSetStatus_idempotentSkipsNoOps() public { + vm.prank(manager); + sw.setStock(stock, true); // registered + enabled + + address[] memory tokens = new address[](1); + tokens[0] = stock; + + vm.recordLogs(); + vm.prank(bot); + sw.batchSetStatus(tokens, true); // already enabled -> no-op, must NOT revert + Vm.Log[] memory logs = vm.getRecordedLogs(); + + assertEq(logs.length, 0, "no event emitted for a no-op"); + assertTrue(sw.enabled(stock), "state unchanged"); + } + + function test_batchSetStatus_revertsForNonBot() public { + vm.prank(manager); + sw.setStock(stock, true); + + address[] memory tokens = new address[](1); + tokens[0] = stock; + + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, stranger, BOT)); + sw.batchSetStatus(tokens, false); + } + + /// @dev an unregistered token reverts the whole batch — registered entries before it roll back. + function test_batchSetStatus_revertsAndRollsBackOnUnregistered() public { + vm.prank(manager); + sw.setStock(stock, true); // registered + enabled + + address[] memory tokens = new address[](2); + tokens[0] = stock; // closed first + tokens[1] = usdt; // unregistered -> reverts + + vm.prank(bot); + vm.expectRevert(StockOracleSwitch.NotRegistered.selector); + sw.batchSetStatus(tokens, false); + + assertTrue(sw.enabled(stock), "batch reverted -> stock stays enabled (rolled back)"); + } + + function test_batchSetStatus_emptyTokensNoop() public { + address[] memory tokens = new address[](0); + vm.prank(bot); + sw.batchSetStatus(tokens, true); // no revert, nothing happens + } + + // ---------------------------------------------------------------------- + // setGlobal (MANAGER) + // ---------------------------------------------------------------------- + + function test_setGlobal_managerTogglesAndEmits() public { vm.expectEmit(false, false, false, true, address(sw)); emit GlobalEnabledSet(true); - vm.prank(bot); + vm.prank(manager); sw.setGlobal(true); assertTrue(sw.globalEnabled()); } - function test_setGlobal_revertsForNonBot() public { + function test_setGlobal_revertsForNonManager() public { vm.prank(stranger); - vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, stranger, BOT)); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, stranger, MANAGER) + ); + sw.setGlobal(true); + } + + /// @dev BOT no longer controls the global switch (it moved to MANAGER); BOT must be rejected. + function test_setGlobal_revertsForBot() public { + vm.prank(bot); + vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, bot, MANAGER)); sw.setGlobal(true); } function test_setGlobal_revertsWhenSameValue() public { // defaults to false; setting false again must revert - vm.prank(bot); + vm.prank(manager); vm.expectRevert(StockOracleSwitch.AlreadySet.selector); sw.setGlobal(false); } @@ -311,7 +407,7 @@ contract StockOracleSwitchTest is Test { // ---------------------------------------------------------------------- function test_emergencyClose_pauserForcesClosed() public { - vm.prank(bot); + vm.prank(manager); sw.setGlobal(true); assertTrue(sw.globalEnabled(), "market open"); @@ -356,20 +452,24 @@ contract StockOracleSwitchTest is Test { function test_manager_grantedBotCanOperate() public { address newBot = makeAddr("newBot"); vm.prank(manager); + sw.setStock(stock, true); // register (auto-enabled) + vm.prank(manager); sw.grantRole(BOT, newBot); vm.prank(newBot); - sw.setGlobal(true); - assertTrue(sw.globalEnabled(), "freshly granted BOT can flip the global switch"); + sw.close(stock); // freshly granted BOT can toggle a per-stock flag + assertFalse(sw.enabled(stock), "freshly granted BOT can close a stock"); } function test_manager_revokedBotCannotOperate() public { + vm.prank(manager); + sw.setStock(stock, true); // register so there is a stock to toggle vm.prank(manager); sw.revokeRole(BOT, bot); vm.prank(bot); vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, bot, BOT)); - sw.setGlobal(true); + sw.close(stock); } /// @dev DEFAULT_ADMIN can no longer grant BOT directly: BOT's admin is now MANAGER. From d43fc8a41405967c14d0473793bd5f6d84b0e7e3 Mon Sep 17 00:00:00 2001 From: Rick Date: Thu, 18 Jun 2026 10:51:19 +0800 Subject: [PATCH 2/2] refactor: batchSetStatus delegates to open/close (strict); open/close -> public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - open/close: external -> public so batchSetStatus can call them in-loop (the internal call preserves msg.sender = BOT, so onlyRole still passes). ABI/selectors unchanged. - batchSetStatus now delegates to open/close per element instead of inlining state writes; it is strict — an already-in-state (AlreadySet) or unregistered (NotRegistered) token reverts the whole batch. - Update test: idempotent no-op skip -> AlreadySet revert. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/oracle/StockOracleSwitch.sol | 20 ++++++++------------ test/oracle/StockOracleSwitch.t.sol | 11 ++++------- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/oracle/StockOracleSwitch.sol b/src/oracle/StockOracleSwitch.sol index 90acc2be..110f9cbd 100644 --- a/src/oracle/StockOracleSwitch.sol +++ b/src/oracle/StockOracleSwitch.sol @@ -79,7 +79,7 @@ contract StockOracleSwitch is AccessControlEnumerableUpgradeable, UUPSUpgradeabl } /// @notice Open a registered stock for trading. BOT role. Only registered stocks can be toggled. - function open(address token) external onlyRole(BOT) { + function open(address token) public onlyRole(BOT) { require(registered[token], NotRegistered()); require(!enabled[token], AlreadySet()); enabled[token] = true; @@ -87,25 +87,21 @@ contract StockOracleSwitch is AccessControlEnumerableUpgradeable, UUPSUpgradeabl } /// @notice Close a registered stock. BOT role. Only registered stocks can be toggled. - function close(address token) external onlyRole(BOT) { + function close(address token) public onlyRole(BOT) { require(registered[token], NotRegistered()); require(enabled[token], AlreadySet()); enabled[token] = false; emit StockEnable(token, false); } - /// @notice Bulk per-stock open/close. BOT role. Sets every token in `tokens` to `status` - /// (true = open, false = close) in one call. - /// @dev Idempotent: a token already in its target state is skipped (no event). Only registered stocks - /// can be toggled — an unregistered token reverts the whole batch. + /// @notice Bulk per-stock open/close. BOT role. Opens (`status = true`) or closes (`status = false`) + /// every token in `tokens` in one call by delegating to {open} / {close}. + /// @dev Strict: inherits the {open} / {close} guards, so a token already in the target state + /// (AlreadySet) or not registered (NotRegistered) reverts the whole batch. function batchSetStatus(address[] calldata tokens, bool status) external onlyRole(BOT) { for (uint256 i; i < tokens.length; ++i) { - address token = tokens[i]; - require(registered[token], NotRegistered()); - if (enabled[token] != status) { - enabled[token] = status; - emit StockEnable(token, status); - } + if (status) open(tokens[i]); + else close(tokens[i]); } } diff --git a/test/oracle/StockOracleSwitch.t.sol b/test/oracle/StockOracleSwitch.t.sol index db39c26c..0021edd5 100644 --- a/test/oracle/StockOracleSwitch.t.sol +++ b/test/oracle/StockOracleSwitch.t.sol @@ -316,20 +316,17 @@ contract StockOracleSwitchTest is Test { assertTrue(sw.enabled(s2), "s2 reopened"); } - function test_batchSetStatus_idempotentSkipsNoOps() public { + function test_batchSetStatus_revertsWhenAlreadyInState() public { vm.prank(manager); sw.setStock(stock, true); // registered + enabled address[] memory tokens = new address[](1); tokens[0] = stock; - vm.recordLogs(); + // already enabled -> open() hits AlreadySet -> the whole batch reverts vm.prank(bot); - sw.batchSetStatus(tokens, true); // already enabled -> no-op, must NOT revert - Vm.Log[] memory logs = vm.getRecordedLogs(); - - assertEq(logs.length, 0, "no event emitted for a no-op"); - assertTrue(sw.enabled(stock), "state unchanged"); + vm.expectRevert(StockOracleSwitch.AlreadySet.selector); + sw.batchSetStatus(tokens, true); } function test_batchSetStatus_revertsForNonBot() public {