Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions script/oracle/deploy_stockOracle.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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]);
Expand Down
36 changes: 24 additions & 12 deletions src/oracle/StockOracleSwitch.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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());
Expand All @@ -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).
Expand All @@ -78,23 +79,34 @@ 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;
emit StockEnable(token, true);
}

/// @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 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. 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) {
if (status) open(tokens[i]);
else close(tokens[i]);
}
}

/// @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);
Expand Down
4 changes: 2 additions & 2 deletions test/oracle/StockOracle.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down
123 changes: 110 additions & 13 deletions test/oracle/StockOracleSwitch.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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");
}
Expand All @@ -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
Expand Down Expand Up @@ -280,28 +280,121 @@ 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_revertsWhenAlreadyInState() public {
vm.prank(manager);
sw.setStock(stock, true); // registered + enabled

address[] memory tokens = new address[](1);
tokens[0] = stock;

// already enabled -> open() hits AlreadySet -> the whole batch reverts
vm.prank(bot);
vm.expectRevert(StockOracleSwitch.AlreadySet.selector);
sw.batchSetStatus(tokens, true);
}

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);
}
Expand All @@ -311,7 +404,7 @@ contract StockOracleSwitchTest is Test {
// ----------------------------------------------------------------------

function test_emergencyClose_pauserForcesClosed() public {
vm.prank(bot);
vm.prank(manager);
sw.setGlobal(true);
assertTrue(sw.globalEnabled(), "market open");

Expand Down Expand Up @@ -356,20 +449,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.
Expand Down
Loading