From bd795648512e54321f7e07e40490795235666790 Mon Sep 17 00:00:00 2001 From: Sergey Ratiashvili Date: Wed, 19 Nov 2025 15:06:12 +0100 Subject: [PATCH 1/7] Add core contract --- script/DeployMaxBTCERC20.script.sol | 17 +- src/MaxBTCCore.sol | 870 +++++++++++++++++++++++++++ src/MaxBTCERC20.sol | 87 ++- src/Receiver.sol | 64 ++ src/types/CoreTypes.sol | 3 + src/types/IAllowlist.sol | 7 + src/types/IExchangeRateProvider.sol | 18 + test/MaxBTCCore.test.sol | 518 +++++++++++++++++ test/MaxBTCCoreIntegration.test.sol | 871 ++++++++++++++++++++++++++++ test/MaxBTCERC20.test.sol | 54 +- 10 files changed, 2455 insertions(+), 54 deletions(-) create mode 100644 src/MaxBTCCore.sol create mode 100644 src/Receiver.sol create mode 100644 src/types/IAllowlist.sol create mode 100644 src/types/IExchangeRateProvider.sol create mode 100644 test/MaxBTCCore.test.sol create mode 100644 test/MaxBTCCoreIntegration.test.sol diff --git a/script/DeployMaxBTCERC20.script.sol b/script/DeployMaxBTCERC20.script.sol index a0ddd11..cc374bd 100644 --- a/script/DeployMaxBTCERC20.script.sol +++ b/script/DeployMaxBTCERC20.script.sol @@ -1,20 +1,26 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -import { Script } from "forge-std/Script.sol"; -import { console } from "forge-std/console.sol"; -import { MaxBTCERC20 } from "../src/MaxBTCERC20.sol"; -import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Script} from "forge-std/Script.sol"; +import {console} from "forge-std/console.sol"; +import {MaxBTCERC20} from "../src/MaxBTCERC20.sol"; +import { + ERC1967Proxy +} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; contract DeployMaxBTCERC20 is Script { function run() external { address implementation = vm.envAddress("IMPLEMENTATION"); address owner = vm.envAddress("OWNER"); address ics20 = vm.envAddress("ICS20"); + address core = vm.envAddress("CORE"); string memory name = vm.envString("TOKEN_NAME"); string memory symbol = vm.envString("TOKEN_SYMBOL"); - bytes memory initializeCall = abi.encodeCall(MaxBTCERC20.initialize, (owner, ics20, name, symbol)); + bytes memory initializeCall = abi.encodeCall( + MaxBTCERC20.initialize, + (owner, ics20, core, name, symbol) + ); vm.startBroadcast(); ERC1967Proxy proxy = new ERC1967Proxy(implementation, initializeCall); @@ -25,6 +31,7 @@ contract DeployMaxBTCERC20 is Script { console.log(" Implementation: ", implementation); console.log(" Owner: ", owner); console.log(" ICS20: ", ics20); + console.log(" CORE: ", core); console.log(" Name: ", name); console.log(" Symbol: ", symbol); } diff --git a/src/MaxBTCCore.sol b/src/MaxBTCCore.sol new file mode 100644 index 0000000..35c1c69 --- /dev/null +++ b/src/MaxBTCCore.sol @@ -0,0 +1,870 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {MaxBTCERC20} from "./MaxBTCERC20.sol"; +import {WithdrawalToken} from "./WithdrawalToken.sol"; +import {WaitosaurHolder} from "./WaitosaurHolder.sol"; +import {Batch} from "./types/CoreTypes.sol"; +import {IAllowlist} from "./types/IAllowlist.sol"; +import {IExchangeRateProvider} from "./types/IExchangeRateProvider.sol"; +import {WaitosaurObserver} from "./WaitosaurObserver.sol"; + +/// @notice Core settlement logic for the maxBTC protocol. +contract MaxBTCCore is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { + struct CoreConfig { + address depositToken; + address maxBtcToken; + address withdrawalToken; + address exchangeRateProvider; + address depositForwarder; + address waitosaurObserver; + address waitosaurHolder; + address allowlist; + address feeCollector; + address withdrawalManager; + address operator; + uint256 exchangeRateStalePeriod; + uint256 depositCost; // 1e18 precision + uint256 withdrawalCost; // 1e18 precision + uint256 depositsCap; // optional, deposit token decimals + bool capEnabled; + bool paused; + } + + enum ContractState { + Idle, + DepositEthereum, + DepositPending, + DepositJlp, + WithdrawJlp, + WithdrawPending, + WithdrawEthereum + } + + /// @notice Events + + event Deposit( + address indexed depositor, + address indexed recipient, + uint256 depositAmount, + uint256 maxBtcMinted + ); + + event Withdrawal( + address indexed withdrawer, + uint256 maxBtcBurned, + uint256 batchId + ); + + event BatchProcessed( + uint256 indexed batchId, + uint256 btcRequested, + uint256 collectedAmount, + bool finalized + ); + + event TickIdleNoOp(); + event TickDepositEthereum(uint256 flushedAmount); + event TickDepositPending(); + event TickDepositJlp(); + event TickWithdrawJlp(); + event TickWithdrawPending(uint256 lockedAmount); + event TickWithdrawEthereumFinalized(uint256 batchId); + + event WithdrawingBatchFinalized( + uint256 indexed batchId, + uint256 collectedAmount + ); + event PausedUpdated(bool paused); + event OperatorUpdated(address operator); + event FeeCollectorUpdated(address feeCollector); + event AllowlistUpdated(address allowlist); + event ExchangeRateProviderUpdated(address provider); + event WithdrawalManagerUpdated(address withdrawalManager); + event CostsUpdated(uint256 depositCost, uint256 withdrawalCost); + event DepositsCapUpdated(uint256 depositsCap, bool capEnabled); + event DepositForwarderUpdated(address depositForwarder); + event WaitosaurObserverUpdated(address waitosaurObserver); + event WaitosaurHolderUpdated(address waitosaurHolder); + event MintedByOwner(address indexed recipient, uint256 amount); + event FeeMinted(address indexed to, uint256 amount); + + /// @notice Errors + + error InvalidDepositTokenAddress(); + error InvalidDepositAmount(); + error InvalidMaxBTCTokenAddress(); + error InvalidWithdrawalTokenAddress(); + error InvalidExchangeRateReceiverAddress(); + error InvalidDepositForwarderAddress(); + error InvalidWaitosaurObserverAddress(); + error InvalidWaitosaurHolderAddress(); + error InvalidAllowlistAddress(); + error InvalidFeeCollectorAddress(); + error InvalidWithdrawalManagerAddress(); + error InvalidOperatorAddress(); + error ExchangeRateStale(); + error WithdrawingBatchAlreadyExists(); + error WithdrawingBatchMissing(); + error FinalizedBatchMissing(uint256 batchId); + error ContractPaused(); + error AddressNotAllowed(address account); + error DepositCapExceeded(); + error InvalidRecipient(); + error InvalidAmount(); + error FeeTooHigh(); + error SlippageLimitExceeded(uint256 requested, uint256 actual); + error WaitosaurLocked(); + + /// @dev keccak256(abi.encode(uint256(keccak256("maxbtc.core.config")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant CONFIG_STORAGE_SLOT = + 0xe8041c5a119ce847809f9491390b5e4b81852379983e998195264ecb0ca5b100; + /// @dev keccak256(abi.encode(uint256(keccak256("maxbtc.core.batch_state")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant BATCH_STATE_STORAGE_SLOT = + 0xcd680cc7c8e435be1f7479ad5e3bda309608af714cd2b7b35d4d58c3c8569700; + /// @dev keccak256(abi.encode(uint256(keccak256("maxbtc.core.finalized_batches")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant FINALIZED_BATCHES_STORAGE_SLOT = + 0x6ba6b86991a1f4fd0c4351857af540e99efdf5c523d2e0e4d1a5236d81710f00; + /// @dev keccak256(abi.encode(uint256(keccak256("maxbtc.core.fsm_state")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant FSM_STORAGE_SLOT = + 0x6d0daac8be12ff028013290dddbb8756fc5a2529ca2bd6de90f4c8bd522e4300; + + struct BatchState { + Batch activeBatch; + Batch withdrawingBatch; + bool hasWithdrawingBatch; + } + + struct FinalizedBatchesStorage { + mapping(uint256 => Batch) batches; + uint256[] finalizedBatchIds; + } + + modifier onlyOperatorOrOwner() { + _onlyOperatorOrOwner(); + _; + } + + function _onlyOperatorOrOwner() internal view { + CoreConfig storage config = _getCoreConfig(); + if (_msgSender() != owner() && _msgSender() != config.operator) { + revert InvalidOperatorAddress(); + } + } + + modifier notPaused() { + _notPaused(); + _; + } + + function _notPaused() internal view { + CoreConfig storage config = _getCoreConfig(); + if (config.paused) { + revert ContractPaused(); + } + } + + modifier onlyAllowlisted(address account) { + _onlyAllowlisted(account); + _; + } + + function _onlyAllowlisted(address account) internal view { + CoreConfig storage config = _getCoreConfig(); + if (!IAllowlist(config.allowlist).isAddressAllowed(account)) { + revert AddressNotAllowed(account); + } + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function _getCoreConfig() private pure returns (CoreConfig storage $) { + assembly { + $.slot := CONFIG_STORAGE_SLOT + } + } + + function _state() private view returns (ContractState) { + uint256 value; + assembly { + value := sload(FSM_STORAGE_SLOT) + } + return ContractState(value); + } + + function _setState(ContractState newState) private { + assembly { + sstore(FSM_STORAGE_SLOT, newState) + } + } + + function initialize( + address owner_, + address depositToken_, + address maxBtcToken_, + address withdrawalToken_, + address exchangeRateProvider_, + address depositForwarder_, + address waitosaurObserver_, + address waitosaurHolder_, + uint256 exchangeRateStalePeriod_, + address allowlist_, + address feeCollector_, + address withdrawalManager_, + address operator_, + uint256 depositCost_, + uint256 withdrawalCost_, + uint256 depositsCap_, + bool capEnabled_ + ) public initializer { + __Ownable_init(owner_); + __Ownable2Step_init(); + if (depositToken_ == address(0)) { + revert InvalidDepositTokenAddress(); + } + if (maxBtcToken_ == address(0)) { + revert InvalidMaxBTCTokenAddress(); + } + if (withdrawalToken_ == address(0)) { + revert InvalidWithdrawalTokenAddress(); + } + if (exchangeRateProvider_ == address(0)) { + revert InvalidExchangeRateReceiverAddress(); + } + if (depositForwarder_ == address(0)) { + revert InvalidDepositForwarderAddress(); + } + if (waitosaurObserver_ == address(0)) { + revert InvalidWaitosaurObserverAddress(); + } + if (waitosaurHolder_ == address(0)) { + revert InvalidWaitosaurHolderAddress(); + } + if (feeCollector_ == address(0)) { + revert InvalidFeeCollectorAddress(); + } + if (withdrawalManager_ == address(0)) { + revert InvalidWithdrawalManagerAddress(); + } + if (operator_ == address(0)) { + revert InvalidOperatorAddress(); + } + if (allowlist_ == address(0)) { + revert InvalidAllowlistAddress(); + } + if (depositCost_ >= 1e18 || withdrawalCost_ >= 1e18) { + revert FeeTooHigh(); + } + CoreConfig storage config = _getCoreConfig(); + config.depositToken = depositToken_; + config.maxBtcToken = maxBtcToken_; + config.withdrawalToken = withdrawalToken_; + config.exchangeRateProvider = exchangeRateProvider_; + config.depositForwarder = depositForwarder_; + config.waitosaurObserver = waitosaurObserver_; + config.waitosaurHolder = waitosaurHolder_; + config.exchangeRateStalePeriod = exchangeRateStalePeriod_; + config.allowlist = allowlist_; + config.feeCollector = feeCollector_; + config.withdrawalManager = withdrawalManager_; + config.operator = operator_; + config.depositCost = depositCost_; + config.withdrawalCost = withdrawalCost_; + config.depositsCap = depositsCap_; + config.capEnabled = capEnabled_; + config.paused = false; + + BatchState storage batchState = _getBatchState(); + batchState.activeBatch = _createNewBatch(0); + _setState(ContractState.Idle); + } + + function _getBatchState() private pure returns (BatchState storage $) { + assembly { + $.slot := BATCH_STATE_STORAGE_SLOT + } + } + + function _getFinalizedBatchesStorage() + private + pure + returns (FinalizedBatchesStorage storage $) + { + assembly { + $.slot := FINALIZED_BATCHES_STORAGE_SLOT + } + } + + function _depositDecimals() private view returns (uint256) { + CoreConfig storage config = _getCoreConfig(); + return uint256(IERC20Metadata(config.depositToken).decimals()); + } + + function _createNewBatch( + uint256 batchId + ) private view returns (Batch memory) { + return + Batch({ + batchId: batchId, + btcRequested: 0, + maxBtcBurned: 0, + collectedAmount: 0, + collectorHistoricalBalance: 0, + depositDecimals: _depositDecimals() + }); + } + + function _activeBatch() private view returns (Batch storage) { + BatchState storage batchState = _getBatchState(); + return batchState.activeBatch; + } + + function activeBatch() external view returns (Batch memory) { + Batch storage currentBatch = _activeBatch(); + return currentBatch; + } + + function withdrawingBatch() external view returns (Batch memory, bool) { + BatchState storage batchState = _getBatchState(); + return (batchState.withdrawingBatch, batchState.hasWithdrawingBatch); + } + + function finalizedBatch( + uint256 batchId + ) public view returns (Batch memory) { + FinalizedBatchesStorage + storage finalized = _getFinalizedBatchesStorage(); + Batch memory batch = finalized.batches[batchId]; + if (batch.depositDecimals == 0) { + revert FinalizedBatchMissing(batchId); + } + return batch; + } + + function finalizedBatches() external view returns (Batch[] memory) { + FinalizedBatchesStorage + storage finalized = _getFinalizedBatchesStorage(); + uint256 length = finalized.finalizedBatchIds.length; + Batch[] memory batches = new Batch[](length); + for (uint256 i = 0; i < length; i++) { + uint256 batchId = finalized.finalizedBatchIds[i]; + batches[i] = finalized.batches[batchId]; + } + return batches; + } + + function _addFinalizedBatch(Batch memory batch) internal { + FinalizedBatchesStorage + storage finalized = _getFinalizedBatchesStorage(); + finalized.batches[batch.batchId] = batch; + finalized.finalizedBatchIds.push(batch.batchId); + } + + function contractState() external view returns (ContractState) { + return _state(); + } + + function setPaused(bool newPaused) external onlyOwner { + CoreConfig storage config = _getCoreConfig(); + config.paused = newPaused; + emit PausedUpdated(newPaused); + } + + function setOperator(address newOperator) external onlyOwner { + if (newOperator == address(0)) { + revert InvalidOperatorAddress(); + } + CoreConfig storage config = _getCoreConfig(); + config.operator = newOperator; + emit OperatorUpdated(newOperator); + } + + function setFeeCollector(address newFeeCollector) external onlyOwner { + if (newFeeCollector == address(0)) { + revert InvalidFeeCollectorAddress(); + } + CoreConfig storage config = _getCoreConfig(); + config.feeCollector = newFeeCollector; + emit FeeCollectorUpdated(newFeeCollector); + } + + function setAllowlist(address newAllowlist) external onlyOwner { + if (newAllowlist == address(0)) { + revert InvalidAllowlistAddress(); + } + CoreConfig storage config = _getCoreConfig(); + config.allowlist = newAllowlist; + emit AllowlistUpdated(newAllowlist); + } + + function setExchangeRateProvider( + address newExchangeRateProvider + ) external onlyOwner { + if (newExchangeRateProvider == address(0)) { + revert InvalidExchangeRateReceiverAddress(); + } + CoreConfig storage config = _getCoreConfig(); + config.exchangeRateProvider = newExchangeRateProvider; + emit ExchangeRateProviderUpdated(newExchangeRateProvider); + } + + function setWithdrawalManager( + address newWithdrawalManager + ) external onlyOwner { + if (newWithdrawalManager == address(0)) { + revert InvalidWithdrawalManagerAddress(); + } + CoreConfig storage config = _getCoreConfig(); + config.withdrawalManager = newWithdrawalManager; + emit WithdrawalManagerUpdated(newWithdrawalManager); + } + + function setDepositForwarder( + address newDepositForwarder + ) external onlyOwner { + if (newDepositForwarder == address(0)) { + revert InvalidDepositForwarderAddress(); + } + CoreConfig storage config = _getCoreConfig(); + config.depositForwarder = newDepositForwarder; + emit DepositForwarderUpdated(newDepositForwarder); + } + + function setWaitosaurObserver( + address newWaitosaurObserver + ) external onlyOwner { + if (newWaitosaurObserver == address(0)) { + revert InvalidWaitosaurObserverAddress(); + } + CoreConfig storage config = _getCoreConfig(); + config.waitosaurObserver = newWaitosaurObserver; + emit WaitosaurObserverUpdated(newWaitosaurObserver); + } + + function setWaitosaurHolder(address newWaitosaurHolder) external onlyOwner { + if (newWaitosaurHolder == address(0)) { + revert InvalidWaitosaurHolderAddress(); + } + CoreConfig storage config = _getCoreConfig(); + config.waitosaurHolder = newWaitosaurHolder; + emit WaitosaurHolderUpdated(newWaitosaurHolder); + } + + function setCosts( + uint256 newDepositCost, + uint256 newWithdrawalCost + ) external onlyOwner { + if (newDepositCost >= 1e18 || newWithdrawalCost >= 1e18) { + revert FeeTooHigh(); + } + CoreConfig storage config = _getCoreConfig(); + config.depositCost = newDepositCost; + config.withdrawalCost = newWithdrawalCost; + emit CostsUpdated(newDepositCost, newWithdrawalCost); + } + + function setDepositsCap(uint256 newCap, bool enabled) external onlyOwner { + CoreConfig storage config = _getCoreConfig(); + config.depositsCap = newCap; + config.capEnabled = enabled; + emit DepositsCapUpdated(newCap, enabled); + } + + /// @notice Owner-only mint of maxBTC + /// @param amount Amount of maxBTC to mint (in 1e8 units) + /// @param recipient Recipient address to receive the freshly minted maxBTC + function mintByOwner( + uint256 amount, + address recipient + ) external onlyOwner notPaused { + if (recipient == address(0)) { + revert InvalidRecipient(); + } + CoreConfig storage config = _getCoreConfig(); + MaxBTCERC20(config.maxBtcToken).mint(recipient, amount); + emit MintedByOwner(recipient, amount); + } + + /// @notice Fee collector mints protocol fee to itself + /// @param amount Amount of maxBTC to mint (in 1e8 units) + function mintFee(uint256 amount) external notPaused { + CoreConfig storage config = _getCoreConfig(); + if (_msgSender() != config.feeCollector) { + revert InvalidFeeCollectorAddress(); + } + MaxBTCERC20(config.maxBtcToken).mint(_msgSender(), amount); + emit FeeMinted(_msgSender(), amount); + } + + function deposit( + uint256 amount, + address recipient, + uint256 minReceiveAmount + ) external notPaused onlyAllowlisted(recipient) { + CoreConfig storage config = _getCoreConfig(); + if (recipient == address(0)) { + revert InvalidRecipient(); + } + if (amount == 0) { + revert InvalidAmount(); + } + + _checkDepositCap(config, amount); + + (uint256 exchangeRate, uint256 lastUpdated) = _getExchangeRate(config); + if (block.timestamp - lastUpdated >= config.exchangeRateStalePeriod) { + revert ExchangeRateStale(); + } + + uint256 maxBtcToMint = _calculateMintAmount( + amount, + exchangeRate, + config.depositCost + ); + if (maxBtcToMint == 0) { + revert InvalidAmount(); + } + if (minReceiveAmount != 0 && maxBtcToMint < minReceiveAmount) { + revert SlippageLimitExceeded(minReceiveAmount, maxBtcToMint); + } + + SafeERC20.safeTransferFrom( + IERC20(config.depositToken), + msg.sender, + address(this), + amount + ); + + MaxBTCERC20(config.maxBtcToken).mint(recipient, maxBtcToMint); + emit Deposit(msg.sender, recipient, amount, maxBtcToMint); + } + + function withdraw( + uint256 maxBtcAmount + ) external notPaused onlyAllowlisted(_msgSender()) { + CoreConfig storage config = _getCoreConfig(); + if (maxBtcAmount == 0) { + revert InvalidAmount(); + } + + Batch storage batch = _activeBatch(); + batch.maxBtcBurned += maxBtcAmount; + MaxBTCERC20(config.maxBtcToken).burn(_msgSender(), maxBtcAmount); + uint256 batchId = batch.batchId; + WithdrawalToken(config.withdrawalToken).mint( + _msgSender(), + batchId, + maxBtcAmount, + "" + ); + emit Withdrawal(_msgSender(), maxBtcAmount, batchId); + } + + /// @notice Processes the active batch using available deposits. + /// @dev It offsets withdrawals with deposits, sends fees to the + /// collector, and either finalizes or moves the batch to + /// WITHDRAWING for off-chain settlement. + function tick() + external + notPaused + onlyOperatorOrOwner + returns (Batch memory processedBatch, bool finalized) + { + CoreConfig storage config = _getCoreConfig(); + ContractState state = _state(); + BatchState storage batchState = _getBatchState(); + Batch memory batch = batchState.activeBatch; + uint256 depositBalance = IERC20(config.depositToken).balanceOf( + address(this) + ); + + if (state == ContractState.Idle) { + if (batch.maxBtcBurned > 0) { + (processedBatch, finalized) = _processWithdrawals( + config, + batchState, + batch, + depositBalance + ); + if (!finalized) { + _setState(ContractState.WithdrawJlp); + } + return (processedBatch, finalized); + } + if (depositBalance > 0) { + _flushDeposits(config, depositBalance); + _setState(ContractState.DepositEthereum); + emit TickDepositEthereum(depositBalance); + } + emit TickIdleNoOp(); + return (processedBatch, finalized); + } + + if (state == ContractState.DepositEthereum) { + _ensureWaitosaurUnlocked(config); + _setState(ContractState.DepositPending); + emit TickDepositPending(); + return (processedBatch, finalized); + } + if (state == ContractState.DepositPending) { + _setState(ContractState.DepositJlp); + emit TickDepositJlp(); + return (processedBatch, finalized); + } + if (state == ContractState.DepositJlp) { + _setState(ContractState.Idle); + emit TickIdleNoOp(); + return (processedBatch, finalized); + } + + if (state == ContractState.WithdrawJlp) { + _setState(ContractState.WithdrawPending); + emit TickWithdrawJlp(); + return (processedBatch, finalized); + } + if (state == ContractState.WithdrawPending) { + WaitosaurHolder holder = WaitosaurHolder(config.waitosaurHolder); + uint256 lockedAmount = holder.lockedAmount(); + if (lockedAmount > 0) { + batchState.withdrawingBatch.collectedAmount += lockedAmount; + holder.unlock(); + } + _setState(ContractState.WithdrawEthereum); + emit TickWithdrawPending(lockedAmount); + return (processedBatch, finalized); + } + if (state == ContractState.WithdrawEthereum) { + _finalizeWithdrawingBatch(batchState.withdrawingBatch); + _setState(ContractState.Idle); + finalized = true; + emit TickWithdrawEthereumFinalized( + batchState.withdrawingBatch.batchId + ); + return (processedBatch, finalized); + } + } + + /// @notice Finalizes the pending withdrawing batch after off-chain settlement. + function _finalizeWithdrawingBatch(Batch memory withdrawing) internal { + BatchState storage batchState = _getBatchState(); + if (!batchState.hasWithdrawingBatch) { + revert WithdrawingBatchMissing(); + } + + _addFinalizedBatch(withdrawing); + delete batchState.withdrawingBatch; + batchState.hasWithdrawingBatch = false; + + emit WithdrawingBatchFinalized( + withdrawing.batchId, + withdrawing.collectedAmount + ); + } + + function finalizeWithdrawingBatch( + uint256 totalCollectedAmount + ) external notPaused onlyOperatorOrOwner { + BatchState storage batchState = _getBatchState(); + if (!batchState.hasWithdrawingBatch) { + revert WithdrawingBatchMissing(); + } + Batch memory withdrawing = batchState.withdrawingBatch; + if (totalCollectedAmount < withdrawing.collectedAmount) { + revert InvalidAmount(); + } + uint256 additional = totalCollectedAmount - withdrawing.collectedAmount; + CoreConfig storage config = _getCoreConfig(); + if (additional > 0) { + SafeERC20.safeTransfer( + IERC20(config.depositToken), + config.withdrawalManager, + additional + ); + } + withdrawing.collectedAmount = totalCollectedAmount; + _finalizeWithdrawingBatch(withdrawing); + _setState(ContractState.Idle); + } + + function _ensureWaitosaurUnlocked(CoreConfig storage config) private view { + if (WaitosaurObserver(config.waitosaurObserver).lockedAmount() > 0) { + revert WaitosaurLocked(); + } + } + + function _processWithdrawals( + CoreConfig storage config, + BatchState storage batchState, + Batch memory batch, + uint256 depositBalance + ) private returns (Batch memory processedBatch, bool finalized) { + (uint256 exchangeRate, uint256 lastUpdated) = _getExchangeRate(config); + if (block.timestamp - lastUpdated >= config.exchangeRateStalePeriod) { + revert ExchangeRateStale(); + } + + batch.btcRequested = (batch.maxBtcBurned * exchangeRate) / 1e18; + + // depositBeforeFees = ceil(btcRequested / (1 - depositCost)) + uint256 depositBeforeFees = _ceilDiv( + batch.btcRequested * 1e18, + 1e18 - config.depositCost + ); + + // It calculates how much of the withdrawal can be offset using the deposits. + // Compares two values: the total amount of available deposits and the calculated + // amount of BTC withdrawals plus withdrawal costs, and takes the lesser of the two. + // This ensures that we do not exceed the available deposits. + uint256 offsettingAmountFull = depositBeforeFees <= depositBalance + ? depositBeforeFees + : depositBalance; + + uint256 offsettingAfterDepositCost = (offsettingAmountFull * + (1e18 - config.depositCost)) / 1e18; + uint256 offsettingAmount = (offsettingAfterDepositCost * + (1e18 - config.withdrawalCost)) / 1e18; + + uint256 offsettingCost = offsettingAmountFull - offsettingAmount; + + batch.collectedAmount = offsettingAmount; + + if (offsettingAmount > 0) { + SafeERC20.safeTransfer( + IERC20(config.depositToken), + config.withdrawalManager, + offsettingAmount + ); + } + if (offsettingCost > 0) { + SafeERC20.safeTransfer( + IERC20(config.depositToken), + config.feeCollector, + offsettingCost + ); + } + + processedBatch = batch; + + if (depositBeforeFees <= depositBalance) { + _addFinalizedBatch(batch); + finalized = true; + batchState.activeBatch = _createNewBatch(batch.batchId + 1); + } else { + if (batchState.hasWithdrawingBatch) { + revert WithdrawingBatchAlreadyExists(); + } + batchState.withdrawingBatch = batch; + batchState.hasWithdrawingBatch = true; + batchState.activeBatch = _createNewBatch(batch.batchId + 1); + } + + emit BatchProcessed( + batch.batchId, + batch.btcRequested, + batch.collectedAmount, + finalized + ); + } + + function _flushDeposits( + CoreConfig storage config, + uint256 depositBalance + ) private { + if (depositBalance == 0) { + return; + } + + if (config.waitosaurObserver == address(0)) { + revert InvalidWaitosaurObserverAddress(); + } + + WaitosaurObserver(config.waitosaurObserver).lock(depositBalance); + + SafeERC20.safeTransfer( + IERC20(config.depositToken), + config.depositForwarder, + depositBalance + ); + } + + function _checkDepositCap( + CoreConfig storage config, + uint256 depositAmount + ) private view { + if (!config.capEnabled) { + return; + } + (int256 aumRaw, uint8 decimals) = IExchangeRateProvider( + config.exchangeRateProvider + ).getAum(); + if (aumRaw < 0) { + revert DepositCapExceeded(); + } + + uint256 scaledAum = _scaleAmount( + // casting to 'uint256' is safe because aumRaw is always positive + // forge-lint: disable-next-line(unsafe-typecast) + uint256(aumRaw), + decimals, + _depositDecimals() + ); + if (scaledAum + depositAmount > config.depositsCap) { + revert DepositCapExceeded(); + } + } + + function _calculateMintAmount( + uint256 amount, + uint256 exchangeRate, + uint256 depositCost + ) private pure returns (uint256) { + if (exchangeRate == 0) { + // We use InvalidDepositAmount here as a zero exchange rate makes any deposit invalid. + revert InvalidDepositAmount(); + } + uint256 amountAfterFee = (amount * (1e18 - depositCost)) / 1e18; + return (amountAfterFee * 1e18) / exchangeRate; + } + + function _getExchangeRate( + CoreConfig storage config + ) private view returns (uint256, uint256) { + return IExchangeRateProvider(config.exchangeRateProvider).getTwaer(); + } + + function _ceilDiv(uint256 a, uint256 b) private pure returns (uint256) { + if (b == 0) { + revert InvalidAmount(); + } + return a == 0 ? 0 : ((a - 1) / b) + 1; + } + + function _scaleAmount( + uint256 amount, + uint256 fromDecimals, + uint256 toDecimals + ) private pure returns (uint256) { + if (fromDecimals == toDecimals) { + return amount; + } + if (fromDecimals < toDecimals) { + uint256 factor = 10 ** (toDecimals - fromDecimals); + return amount * factor; + } + uint256 divisor = 10 ** (fromDecimals - toDecimals); + return amount / divisor; + } + + function _authorizeUpgrade( + address newImplementation + ) internal override onlyOwner {} +} diff --git a/src/MaxBTCERC20.sol b/src/MaxBTCERC20.sol index bd1a264..dbd9808 100644 --- a/src/MaxBTCERC20.sol +++ b/src/MaxBTCERC20.sol @@ -9,23 +9,35 @@ // - disable renounceOwnership() pragma solidity ^0.8.28; -import { IMintableAndBurnable } from "./IMintableAndBurnable.sol"; -import { ERC20Upgradeable } from "@openzeppelin-upgradeable/token/ERC20/ERC20Upgradeable.sol"; -import { UUPSUpgradeable } from "@openzeppelin-contracts/proxy/utils/UUPSUpgradeable.sol"; -import { Ownable2StepUpgradeable } from "@openzeppelin-upgradeable/access/Ownable2StepUpgradeable.sol"; -import { StorageSlot } from "@openzeppelin/contracts/utils/StorageSlot.sol"; - -contract MaxBTCERC20 is IMintableAndBurnable, UUPSUpgradeable, ERC20Upgradeable, Ownable2StepUpgradeable { - /// @notice Caller is not the ICS20 contract +import {IMintableAndBurnable} from "./IMintableAndBurnable.sol"; +import {ERC20Upgradeable} from "@openzeppelin-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin-contracts/proxy/utils/UUPSUpgradeable.sol"; +import {OwnableUpgradeable} from "@openzeppelin-upgradeable/access/OwnableUpgradeable.sol"; + +contract MaxBTCERC20 is + IMintableAndBurnable, + UUPSUpgradeable, + ERC20Upgradeable, + OwnableUpgradeable +{ + /// @notice Caller is not allowed /// @param caller The address of the caller - error CallerIsNotICS20(address caller); + error CallerIsNotAllowed(address caller); - /// @notice Provided ICS20 address is invalid - error InvalidICS20(); + struct TokenConfig { + address ics20; + address core; + } - /// @notice ERC-7201 slot for the ICS20 contract address - /// @dev keccak256(abi.encode(uint256(keccak256("maxbtc.erc20.ics20")) - 1)) & ~bytes32(uint256(0xff)) - bytes32 private constant ICS20_STORAGE_SLOT = 0xaa9b9403d129a09996409713bb21f8632c135ae1789678b7128d16411b23e500; + /// @dev keccak256(abi.encode(uint256(keccak256("maxbtc.erc20.config")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant CONFIG_STORAGE_SLOT = + 0x60e64ce940b41f99536b34ed9aceefb0cc4425527635a944fc8718b4d4247c00; + + function _getCoreConfig() private pure returns (TokenConfig storage $) { + assembly { + $.slot := CONFIG_STORAGE_SLOT + } + } /// @dev This contract is meant to be deployed by a proxy, so the constructor is not used // natlint-disable-next-line MissingNotice @@ -36,31 +48,35 @@ contract MaxBTCERC20 is IMintableAndBurnable, UUPSUpgradeable, ERC20Upgradeable, /// @notice Initializes the MaxBTCERC20 contract /// @param owner_ The owner of the contract, allowing it to be upgraded /// @param ics20_ The ICS20 contract address + /// @param core_ The Core contract address /// @param name_ The name of the token /// @param symbol_ The symbol of the token function initialize( address owner_, address ics20_, + address core_, string calldata name_, string calldata symbol_ - ) - external - initializer - { + ) external initializer { __ERC20_init(name_, symbol_); __Ownable_init(owner_); - - if (ics20_ == address(0)) { - revert InvalidICS20(); - } - - StorageSlot.getAddressSlot(ICS20_STORAGE_SLOT).value = ics20_; + TokenConfig storage config = _getCoreConfig(); + config.ics20 = ics20_; + config.core = core_; } /// @notice Returns the ICS20 contract address /// @return The ICS20 contract address function ics20() external view returns (address) { - return StorageSlot.getAddressSlot(ICS20_STORAGE_SLOT).value; + TokenConfig storage config = _getCoreConfig(); + return config.ics20; + } + + /// @notice Returns the Core contract address + /// @return The Core contract address + function core() external view returns (address) { + TokenConfig storage config = _getCoreConfig(); + return config.core; } /// @inheritdoc ERC20Upgradeable @@ -69,17 +85,20 @@ contract MaxBTCERC20 is IMintableAndBurnable, UUPSUpgradeable, ERC20Upgradeable, } /// @inheritdoc IMintableAndBurnable - function mint(address mintAddress, uint256 amount) external onlyICS20 { + function mint(address mintAddress, uint256 amount) external allowed { _mint(mintAddress, amount); } /// @inheritdoc IMintableAndBurnable - function burn(address mintAddress, uint256 amount) external onlyICS20 { + function burn(address mintAddress, uint256 amount) external allowed { _burn(mintAddress, amount); } /// @inheritdoc UUPSUpgradeable - function _authorizeUpgrade(address) internal view override(UUPSUpgradeable) onlyOwner { } + function _authorizeUpgrade( + address + ) internal view override(UUPSUpgradeable) onlyOwner {} + // solhint-disable-previous-line no-empty-blocks /// @notice prevents `owner` from renouncing ownership and potentially locking assets forever @@ -89,8 +108,16 @@ contract MaxBTCERC20 is IMintableAndBurnable, UUPSUpgradeable, ERC20Upgradeable, } /// @notice Modifier to check if the caller is the ICS20 contract - modifier onlyICS20() { - require(_msgSender() == StorageSlot.getAddressSlot(ICS20_STORAGE_SLOT).value, CallerIsNotICS20(_msgSender())); + modifier allowed() { + _allowed(); _; } + + function _allowed() internal view { + TokenConfig storage config = _getCoreConfig(); + require( + _msgSender() == config.ics20 || _msgSender() == config.core, + CallerIsNotAllowed(_msgSender()) + ); + } } diff --git a/src/Receiver.sol b/src/Receiver.sol new file mode 100644 index 0000000..30b8c86 --- /dev/null +++ b/src/Receiver.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.28; + +contract Receiver { + address public publisher; + uint256 private er; + uint256 private ts; + int256 private aum; + uint8 private aumDecimals; + + event PublisherUpdated( + address indexed oldPublisher, + address indexed newPublisher + ); + + event ValuesPublished(uint256 er, uint256 ts); + event AumPublished(int256 aum, uint8 decimals); + + error NotPublisher(); + + constructor(address _publisher) { + require(_publisher != address(0), "zero publisher"); + publisher = _publisher; + } + + modifier onlyPublisher() { + _onlyPublisher(); + _; + } + + function _onlyPublisher() internal view { + if (msg.sender != publisher) revert NotPublisher(); + } + + function setPublisher(address newPublisher) external onlyPublisher { + require(newPublisher != address(0), "zero addr"); + emit PublisherUpdated(publisher, newPublisher); + publisher = newPublisher; + } + + function publish(uint256 newEr, uint256 newTs) external onlyPublisher { + er = newEr; + ts = newTs; + emit ValuesPublished(newEr, newTs); + } + + function publishAum(int256 newAum, uint8 decimals_) external onlyPublisher { + aum = newAum; + aumDecimals = decimals_; + emit AumPublished(newAum, decimals_); + } + + function getLatest() external view returns (uint256 _er, uint256 _ts) { + return (er, ts); + } + + function getTwaer() external view returns (uint256 _er, uint256 _ts) { + return (er, ts); + } + + function getAum() external view returns (int256 _aum, uint8 decimals_) { + return (aum, aumDecimals); + } +} diff --git a/src/types/CoreTypes.sol b/src/types/CoreTypes.sol index 317d84a..66ae616 100644 --- a/src/types/CoreTypes.sol +++ b/src/types/CoreTypes.sol @@ -9,6 +9,9 @@ struct Batch { uint256 maxBtcBurned; /// If in FINALIZED state, how much BTC was actually collected? uint256 collectedAmount; + /// Snapshot of deposits held when the batch was moved to WITHDRAWING + /// (mirrors `collector_historical_balance` in the Rust core for reconciliation) + uint256 collectorHistoricalBalance; /// Number of decimals carried by the `deposit_denom` asset uint256 depositDecimals; } diff --git a/src/types/IAllowlist.sol b/src/types/IAllowlist.sol new file mode 100644 index 0000000..e901106 --- /dev/null +++ b/src/types/IAllowlist.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @notice Minimal allowlist interface mirroring the CosmWasm allowlist query. +interface IAllowlist { + function isAddressAllowed(address account) external view returns (bool); +} diff --git a/src/types/IExchangeRateProvider.sol b/src/types/IExchangeRateProvider.sol new file mode 100644 index 0000000..ae610ca --- /dev/null +++ b/src/types/IExchangeRateProvider.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @notice Exchange rate provider interface used by MaxBTCCore. +/// @dev Mirrors the Rust core contract queries `GetTwaer` and `GetAum`. +interface IExchangeRateProvider { + /// @return er The time weighted average exchange rate, scaled to 1e18 + /// @return timestamp The timestamp of the last publication + function getTwaer() external view returns (uint256 er, uint256 timestamp); + + /// @return er The latest exchange rate, scaled to 1e18 + /// @return timestamp The timestamp of the last publication + function getLatest() external view returns (uint256 er, uint256 timestamp); + + /// @return aum Total assets under management in the deposit token denomination + /// @return decimals Number of decimals for the returned AUM value + function getAum() external view returns (int256 aum, uint8 decimals); +} diff --git a/test/MaxBTCCore.test.sol b/test/MaxBTCCore.test.sol new file mode 100644 index 0000000..7e66321 --- /dev/null +++ b/test/MaxBTCCore.test.sol @@ -0,0 +1,518 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {MaxBTCCore} from "../src/MaxBTCCore.sol"; +import {MaxBTCERC20} from "../src/MaxBTCERC20.sol"; +import {WithdrawalToken} from "../src/WithdrawalToken.sol"; +import {WaitosaurHolder} from "../src/WaitosaurHolder.sol"; +import {Receiver} from "../src/Receiver.sol"; +import {Batch} from "../src/types/CoreTypes.sol"; +import {IAllowlist} from "../src/types/IAllowlist.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract MockERC20 is ERC20 { + uint8 private immutable _DECIMALS; + + constructor( + string memory name_, + string memory symbol_, + uint8 decimals_ + ) ERC20(name_, symbol_) { + _DECIMALS = decimals_; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function decimals() public view override returns (uint8) { + return _DECIMALS; + } +} + +contract MockAllowlist is IAllowlist { + mapping(address => bool) public allowed; + + function setAllowed(address account, bool allowed_) external { + allowed[account] = allowed_; + } + + function isAddressAllowed(address account) external view returns (bool) { + return allowed[account]; + } +} + +contract MockWaitosaurObserver { + bool public locked; + uint256 public lastLockedAmount; + + function setLocked(bool newLocked) external { + locked = newLocked; + } + + function lock(uint256 amount) external { + require(!locked, "locked"); + locked = true; + lastLockedAmount = amount; + } + + function isLocked() external view returns (bool) { + return locked; + } + + function lockedAmount() external view returns (uint256) { + return locked ? lastLockedAmount : 0; + } +} + +contract MaxBTCCoreTest is Test { + MaxBTCCore private core; + MaxBTCERC20 private maxbtc; + WithdrawalToken private withdrawalToken; + WaitosaurHolder private waitosaurHolder; + Receiver private provider; + MockERC20 private depositToken; + MockAllowlist private allowlist; + MockWaitosaurObserver private waitosaurObserver; + + address private constant USER = address(0xA11CE); + address private constant WITHDRAWAL_MANAGER = address(0xBEEF); + address private constant FEE_COLLECTOR = address(0xFEE); + address private constant DEPOSIT_FORWARDER = address(0xD3F0); + address private constant OPERATOR = address(0x0B0B); + + uint256 private constant DEPOSIT_COST = 2e16; // 2% + uint256 private constant WITHDRAWAL_COST = 1e16; // 1% + + function setUp() external { + MaxBTCCore coreImpl = new MaxBTCCore(); + MaxBTCERC20 maxbtcImpl = new MaxBTCERC20(); + WithdrawalToken withdrawalTokenImpl = new WithdrawalToken(); + WaitosaurHolder waitosaurHolderImpl = new WaitosaurHolder(); + provider = new Receiver(address(this)); + depositToken = new MockERC20("WBTC", "WBTC", 8); + allowlist = new MockAllowlist(); + waitosaurObserver = new MockWaitosaurObserver(); + + ERC1967Proxy maxbtcProxy = new ERC1967Proxy(address(maxbtcImpl), ""); + ERC1967Proxy withdrawalProxy = new ERC1967Proxy( + address(withdrawalTokenImpl), + "" + ); + ERC1967Proxy waitosaurProxy = new ERC1967Proxy( + address(waitosaurHolderImpl), + abi.encodeCall( + WaitosaurHolder.initialize, + ( + address(this), + address(depositToken), + OPERATOR, + address(this), + WITHDRAWAL_MANAGER + ) + ) + ); + waitosaurHolder = WaitosaurHolder(address(waitosaurProxy)); + + ERC1967Proxy coreProxy = new ERC1967Proxy( + address(coreImpl), + abi.encodeCall( + MaxBTCCore.initialize, + ( + address(this), + address(depositToken), + address(maxbtcProxy), + address(withdrawalProxy), + address(provider), + DEPOSIT_FORWARDER, + address(waitosaurObserver), + address(waitosaurHolder), + 1 days, + address(allowlist), + FEE_COLLECTOR, + WITHDRAWAL_MANAGER, + OPERATOR, + DEPOSIT_COST, + WITHDRAWAL_COST, + 0, + false + ) + ) + ); + + core = MaxBTCCore(address(coreProxy)); + maxbtc = MaxBTCERC20(address(maxbtcProxy)); + withdrawalToken = WithdrawalToken(address(withdrawalProxy)); + + maxbtc.initialize( + address(this), + address(this), // ICS20 for test mints + address(core), + "maxBTC", + "maxBTC" + ); + + withdrawalToken.initialize( + address(this), + address(core), + "ipfs://test/", + "Redemption", + "rMAX-" + ); + waitosaurHolder.updateConfig( + OPERATOR, + address(core), + WITHDRAWAL_MANAGER + ); + + allowlist.setAllowed(USER, true); + } + + function testDepositMintsWithFeeAndAllowlist() external { + uint256 amount = 1e8; // 1 WBTC with 8 decimals + _publishRate(2e18); + depositToken.mint(USER, amount); + + vm.startPrank(USER); + depositToken.approve(address(core), amount); + core.deposit(amount, USER, 0); + vm.stopPrank(); + + uint256 expectedMint = (amount * (1e18 - DEPOSIT_COST)) / 2e18; + assertEq(maxbtc.balanceOf(USER), expectedMint, "minted amount"); + assertEq( + depositToken.balanceOf(address(core)), + amount, + "deposits held by core" + ); + } + + function testDepositFailsWhenPaused() external { + core.setPaused(true); + depositToken.mint(USER, 1); + vm.startPrank(USER); + depositToken.approve(address(core), 1); + vm.expectRevert(MaxBTCCore.ContractPaused.selector); + core.deposit(1, USER, 0); + vm.stopPrank(); + } + + function testMintByOwnerMintsToRecipient() external { + maxbtc.mint(address(0xFACE), 0); // noop to ensure contract exists + uint256 amount = 5e7; + core.mintByOwner(amount, USER); + assertEq(maxbtc.balanceOf(USER), amount, "owner mint delivered"); + } + + function testMintFeeByCollector() external { + uint256 amount = 3e7; + vm.prank(FEE_COLLECTOR); + core.mintFee(amount); + assertEq(maxbtc.balanceOf(FEE_COLLECTOR), amount, "fee minted"); + } + + function testDepositRejectsNotAllowlisted() external { + allowlist.setAllowed(USER, false); + _publishRate(1e18); + depositToken.mint(USER, 1e8); + vm.startPrank(USER); + depositToken.approve(address(core), 1e8); + vm.expectRevert( + abi.encodeWithSelector(MaxBTCCore.AddressNotAllowed.selector, USER) + ); + core.deposit(1e8, USER, 0); + vm.stopPrank(); + } + + function testDepositStaleExchangeRate() external { + uint256 amount = 1e8; + depositToken.mint(USER, amount); + provider.publish(1e18, block.timestamp - 2 days); + vm.startPrank(USER); + depositToken.approve(address(core), amount); + vm.expectRevert(MaxBTCCore.ExchangeRateStale.selector); + core.deposit(amount, USER, 0); + vm.stopPrank(); + } + + function testDepositExceedsCapReverts() external { + core.setDepositsCap(100e8, true); + _publishRate(1e18); + provider.publishAum(int256(100e8), depositToken.decimals()); + depositToken.mint(USER, 1); + vm.startPrank(USER); + depositToken.approve(address(core), 1); + vm.expectRevert(MaxBTCCore.DepositCapExceeded.selector); + core.deposit(1, USER, 0); + vm.stopPrank(); + } + + function testDepositSlippageLimit() external { + uint256 amount = 1e8; + _publishRate(1e18); + depositToken.mint(USER, amount); + vm.startPrank(USER); + depositToken.approve(address(core), amount); + uint256 minted = (amount * (1e18 - DEPOSIT_COST)) / 1e18; + vm.expectRevert( + abi.encodeWithSelector( + MaxBTCCore.SlippageLimitExceeded.selector, + minted + 1, + minted + ) + ); + core.deposit(amount, USER, minted + 1); + vm.stopPrank(); + } + + function testWithdrawAndTickFinalizesWhenCovered() external { + uint256 depositAmount = 1e8; + _publishRate(1e18); + depositToken.mint(USER, depositAmount); + + vm.startPrank(USER); + depositToken.approve(address(core), depositAmount); + core.deposit(depositAmount, USER, 0); + + uint256 burnAmount = 5e7; // 0.5 maxBTC + core.withdraw(burnAmount); + vm.stopPrank(); + + vm.prank(OPERATOR); + (Batch memory processed, bool finalized) = core.tick(); + + uint256 depositBeforeFees = (processed.btcRequested * + 1e18 + + (1e18 - DEPOSIT_COST) - + 1) / (1e18 - DEPOSIT_COST); + uint256 offsettingAfterDepositCost = (depositBeforeFees * + (1e18 - DEPOSIT_COST)) / 1e18; + uint256 expectedCollected = (offsettingAfterDepositCost * + (1e18 - WITHDRAWAL_COST)) / 1e18; + uint256 expectedCost = depositBeforeFees - expectedCollected; + + assertTrue(finalized, "finalized"); + assertEq(processed.maxBtcBurned, burnAmount, "burned amount"); + assertEq(processed.collectedAmount, expectedCollected, "collected"); + assertEq( + depositToken.balanceOf(WITHDRAWAL_MANAGER), + expectedCollected, + "withdrawal manager received" + ); + assertEq( + depositToken.balanceOf(FEE_COLLECTOR), + expectedCost, + "fee collector received" + ); + + Batch memory stored = core.finalizedBatch(processed.batchId); + assertEq(stored.collectedAmount, expectedCollected, "finalized stored"); + assertEq( + core.activeBatch().batchId, + processed.batchId + 1, + "new batch" + ); + } + + function testPartialCoverageMovesToWithdrawingAndFinalizeLater() external { + _publishRate(1e18); + + // Mint maxBTC directly via ICS20 mock hook. + maxbtc.mint(USER, 2e8); + // Fund the core with limited deposits so the batch cannot be fully covered. + depositToken.mint(address(core), 5e7); + + vm.prank(USER); + maxbtc.approve(address(core), type(uint256).max); + vm.prank(USER); + core.withdraw(2e8); + + vm.prank(OPERATOR); + (Batch memory processed, bool finalized) = core.tick(); + assertFalse(finalized, "should move to withdrawing"); + uint256 expectedCollected = (5e7 * (1e18 - DEPOSIT_COST)) / 1e18; + expectedCollected = + (expectedCollected * (1e18 - WITHDRAWAL_COST)) / + 1e18; + assertEq( + processed.collectedAmount, + expectedCollected, + "partially covered" + ); + assertEq( + depositToken.balanceOf(WITHDRAWAL_MANAGER), + expectedCollected, + "covered portion sent" + ); + + // Off-chain bridge delivers remaining 1.5 WBTC. + uint256 targetCollected = 2e8; + uint256 additional = targetCollected - processed.collectedAmount; + depositToken.mint(address(core), additional); + vm.prank(OPERATOR); + core.finalizeWithdrawingBatch(targetCollected); + + Batch memory stored = core.finalizedBatch(processed.batchId); + assertEq(stored.collectedAmount, targetCollected, "final collected"); + assertEq( + depositToken.balanceOf(WITHDRAWAL_MANAGER), + targetCollected, + "withdrawal manager received rest" + ); + } + + function testFsmDepositCycleFlushesAndReturnsToIdle() external { + uint256 depositAmount = 5e7; + depositToken.mint(address(core), depositAmount); + assertEq(uint8(core.contractState()), 0, "starts idle"); + + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 1, "moved to DepositEthereum"); + assertEq( + depositToken.balanceOf(DEPOSIT_FORWARDER), + depositAmount, + "forwarder received flush" + ); + // Simulate observer unlocking after external chain confirmation. + waitosaurObserver.setLocked(false); + + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 2, "DepositPending"); + + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 3, "DepositJlp"); + + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 0, "back to Idle"); + } + + function testDepositEthereumRevertsWhenWaitosaurLocked() external { + uint256 depositAmount = 1e7; + depositToken.mint(address(core), depositAmount); + vm.prank(OPERATOR); + core.tick(); // Idle -> DepositEthereum + waitosaurObserver.setLocked(true); + vm.prank(OPERATOR); + vm.expectRevert(MaxBTCCore.WaitosaurLocked.selector); + core.tick(); // should revert trying to go DepositPending + } + + function testFsmWithdrawCycleCompletes() external { + _publishRate(1e18); + // mint maxBTC and partial deposits so the batch is not fully covered + maxbtc.mint(USER, 2e8); + depositToken.mint(address(core), 5e7); + + vm.prank(USER); + maxbtc.approve(address(core), type(uint256).max); + vm.prank(USER); + core.withdraw(2e8); + + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 4, "WithdrawJlp"); + + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 5, "WithdrawPending"); + + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 6, "WithdrawNeutron"); + + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 0, "back to Idle"); + Batch memory stored = core.finalizedBatch(0); + assertGt(stored.collectedAmount, 0, "finalized batch collected"); + } + + function testWithdrawPendingProcessesWaitosaurLock() external { + _publishRate(1e18); + maxbtc.mint(USER, 1e8); + + uint256 lockedAmount = 3e7; + depositToken.mint(address(waitosaurHolder), lockedAmount); + vm.prank(OPERATOR); + waitosaurHolder.lock(lockedAmount); + + vm.prank(USER); + maxbtc.approve(address(core), type(uint256).max); + vm.prank(USER); + core.withdraw(1e8); + + vm.prank(OPERATOR); + core.tick(); // Idle -> WithdrawJlp + vm.prank(OPERATOR); + core.tick(); // WithdrawJlp -> WithdrawPending + + vm.prank(OPERATOR); + core.tick(); // WithdrawPending processes waitosaur lock + assertEq(uint8(core.contractState()), 6, "moved to WithdrawEthereum"); + assertEq(waitosaurHolder.lockedAmount(), 0, "waitosaur unlocked"); + assertEq( + depositToken.balanceOf(WITHDRAWAL_MANAGER), + lockedAmount, + "locked funds forwarded to withdrawal manager" + ); + + vm.prank(OPERATOR); + core.tick(); // finalize -> Idle + assertEq(uint8(core.contractState()), 0, "back to Idle"); + Batch memory finalized = core.finalizedBatch(0); + assertEq(finalized.collectedAmount, lockedAmount, "collected updated"); + } + + function testWithdrawNotAllowlistedReverts() external { + allowlist.setAllowed(USER, false); + vm.expectRevert( + abi.encodeWithSelector(MaxBTCCore.AddressNotAllowed.selector, USER) + ); + vm.prank(USER); + core.withdraw(1); + } + + function testWithdrawPausedReverts() external { + core.setPaused(true); + vm.expectRevert(MaxBTCCore.ContractPaused.selector); + vm.prank(USER); + core.withdraw(1); + } + + function testTickWithoutBurnedReverts() external { + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 0, "remains idle"); + } + + function testFinalizeWithdrawingBatchMissingReverts() external { + vm.prank(OPERATOR); + vm.expectRevert(MaxBTCCore.WithdrawingBatchMissing.selector); + core.finalizeWithdrawingBatch(1); + } + + function testTwoStepOwnershipTransfer() external { + address newOwner = address(0xABCD); + core.transferOwnership(newOwner); + assertEq(core.pendingOwner(), newOwner, "pending owner set"); + + vm.prank(newOwner); + core.acceptOwnership(); + assertEq(core.owner(), newOwner, "ownership transferred"); + assertEq(core.pendingOwner(), address(0), "pending cleared"); + } + + function _publishRate(uint256 er) private { + provider.publish(er, block.timestamp); + provider.publishAum( + int256(depositToken.totalSupply()), + depositToken.decimals() + ); + } +} diff --git a/test/MaxBTCCoreIntegration.test.sol b/test/MaxBTCCoreIntegration.test.sol new file mode 100644 index 0000000..50aa624 --- /dev/null +++ b/test/MaxBTCCoreIntegration.test.sol @@ -0,0 +1,871 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {MaxBTCCore} from "../src/MaxBTCCore.sol"; +import {MaxBTCERC20} from "../src/MaxBTCERC20.sol"; +import {WithdrawalToken} from "../src/WithdrawalToken.sol"; +import {WithdrawalManager} from "../src/WithdrawalManager.sol"; +import {WaitosaurHolder} from "../src/WaitosaurHolder.sol"; +import {WaitosaurObserver, IAumOracle} from "../src/WaitosaurObserver.sol"; +import {FeeCollector} from "../src/FeeCollector.sol"; +import {Receiver} from "../src/Receiver.sol"; +import {IAllowlist} from "../src/types/IAllowlist.sol"; +import {Batch} from "../src/types/CoreTypes.sol"; + +/// @notice Lightweight mock ERC20 with configurable decimals for WBTC-like asset. +contract MockERC20 is ERC20 { + uint8 private immutable _DECIMALS; + + constructor( + string memory name_, + string memory symbol_, + uint8 decimals_ + ) ERC20(name_, symbol_) { + _DECIMALS = decimals_; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function decimals() public view override returns (uint8) { + return _DECIMALS; + } +} + +/// @notice Minimal allowlist mock used by the core contract. +contract MockAllowlist is IAllowlist { + mapping(address => bool) public allowed; + + function setAllowed(address account, bool isAllowed) external { + allowed[account] = isAllowed; + } + + function isAddressAllowed(address account) external view returns (bool) { + return allowed[account]; + } +} + +contract MockAumOracle is IAumOracle { + uint256 public balance; + + function setBalance(uint256 newBalance) external { + balance = newBalance; + } + + function getSpotBalance( + string calldata /* asset */ + ) external view returns (uint256) { + return balance; + } +} + +/// @dev Lightweight upgraded core for upgrade path test +contract CoreV2 is MaxBTCCore { + function version() external pure returns (string memory) { + return "v2"; + } +} + +/// @notice Integration-style test that wires core, ERC20, withdrawal token, and manager together. +contract MaxBTCCoreIntegrationTest is Test { + MaxBTCCore private core; + MaxBTCERC20 private maxbtc; + WithdrawalToken private withdrawalToken; + WithdrawalManager private manager; + WaitosaurHolder private waitosaurHolder; + FeeCollector private feeCollector; + WaitosaurObserver private waitosaurObserver; + MockAumOracle private oracle; + Receiver private provider; + MockERC20 private wbtc; + MockAllowlist private allowlist; + + address private constant USER = address(0xA11CE); + address private constant OWNER = address(0x0B0B); + address private constant OPERATOR = address(0x0C0C); + address private constant DEPOSIT_FORWARDER = address(0xD00D); + address private constant TREASURY = address(0xBADA55); + uint256 private constant DEPOSIT_COST = 1e16; // 1% + uint256 private constant WITHDRAWAL_COST = 1e16; // 1% + uint256 private constant FEE_REDUCTION = 1e17; // 10% + + function setUp() external { + MaxBTCCore coreImpl = new MaxBTCCore(); + MaxBTCERC20 maxbtcImpl = new MaxBTCERC20(); + WithdrawalToken withdrawalTokenImpl = new WithdrawalToken(); + WithdrawalManager managerImpl = new WithdrawalManager(); + WaitosaurHolder waitosaurHolderImpl = new WaitosaurHolder(); + WaitosaurObserver waitosaurObserverImpl = new WaitosaurObserver(); + FeeCollector feeCollectorImpl = new FeeCollector(); + + wbtc = new MockERC20("WBTC", "WBTC", 8); + provider = new Receiver(address(this)); + allowlist = new MockAllowlist(); + allowlist.setAllowed(USER, true); + oracle = new MockAumOracle(); + oracle.setBalance(type(uint256).max); + // Seed ER for fee collector baseline + provider.publish(1e18, block.timestamp); + + // Deploy proxies (uninitialized), so we can pass addresses into core init. + ERC1967Proxy maxbtcProxy = new ERC1967Proxy(address(maxbtcImpl), ""); + ERC1967Proxy withdrawalProxy = new ERC1967Proxy( + address(withdrawalTokenImpl), + "" + ); + ERC1967Proxy managerProxy = new ERC1967Proxy(address(managerImpl), ""); + ERC1967Proxy feeCollectorProxy = new ERC1967Proxy( + address(feeCollectorImpl), + "" + ); + + // Initialize core with the real addresses. + ERC1967Proxy waitosaurProxy = new ERC1967Proxy( + address(waitosaurHolderImpl), + abi.encodeCall( + WaitosaurHolder.initialize, + ( + address(this), + address(wbtc), + OPERATOR, + address(this), + address(managerProxy) + ) + ) + ); + waitosaurHolder = WaitosaurHolder(address(waitosaurProxy)); + ERC1967Proxy waitosaurObserverProxy = new ERC1967Proxy( + address(waitosaurObserverImpl), + abi.encodeCall( + WaitosaurObserver.initialize, + (address(this), address(this), OPERATOR, address(oracle), "BTC") + ) + ); + waitosaurObserver = WaitosaurObserver(address(waitosaurObserverProxy)); + + ERC1967Proxy coreProxy = new ERC1967Proxy( + address(coreImpl), + abi.encodeCall( + MaxBTCCore.initialize, + ( + OWNER, + address(wbtc), + address(maxbtcProxy), + address(withdrawalProxy), + address(provider), + DEPOSIT_FORWARDER, + address(waitosaurObserver), + address(waitosaurHolder), + 1 days, + address(allowlist), + address(feeCollectorProxy), + address(managerProxy), + OPERATOR, + DEPOSIT_COST, + WITHDRAWAL_COST, + 0, + false + ) + ) + ); + core = MaxBTCCore(address(coreProxy)); + maxbtc = MaxBTCERC20(address(maxbtcProxy)); + withdrawalToken = WithdrawalToken(address(withdrawalProxy)); + manager = WithdrawalManager(address(managerProxy)); + waitosaurObserver.updateConfig( + address(core), + OPERATOR, + address(oracle), + "" + ); + waitosaurHolder.updateConfig(OPERATOR, address(core), address(manager)); + + // Now initialize dependent contracts with the finalized core address. + maxbtc.initialize( + address(this), + address(this), + address(core), + "maxBTC", + "maxBTC" + ); + withdrawalToken.initialize( + address(this), + address(core), + "ipfs://base/", + "Redemption", + "rMAX-" + ); + manager.initialize( + address(this), + address(core), + address(wbtc), + address(withdrawalToken) + ); + feeCollector = FeeCollector(address(feeCollectorProxy)); + feeCollector.initialize( + OWNER, + address(core), + address(provider), + FEE_REDUCTION, + 1, + address(maxbtc) + ); + + // Manager needs to know core for finalized batch lookups + vm.prank(address(this)); + manager.updateConfig( + address(core), + address(wbtc), + address(withdrawalToken) + ); + } + + function testIntegrationDepositWithdrawRedeem() external { + // Publish ER and AUM + uint256 er = 1e18; // 1:1 + provider.publish(er, block.timestamp); + provider.publishAum(0, wbtc.decimals()); + + // User deposits 2 WBTC + uint256 depositAmount = 2e8; + wbtc.mint(USER, depositAmount); + vm.startPrank(USER); + wbtc.approve(address(core), depositAmount); + core.deposit(depositAmount, USER, 0); + vm.stopPrank(); + + // User withdraws 1 maxBTC + uint256 burnAmount = 1e8; + vm.prank(USER); + core.withdraw(burnAmount); + + // Operator processes batch; fully covered by deposits so it finalizes immediately. + vm.prank(OPERATOR); + (Batch memory processed, bool finalized) = core.tick(); + assertTrue(finalized, "batch should finalize"); + + // Check manager received the payout and fee collector got the fee. + uint256 expectedCollected = processed.collectedAmount; + assertGt(expectedCollected, 0, "collected positive"); + assertEq( + wbtc.balanceOf(address(manager)), + expectedCollected, + "manager funded" + ); + assertGt( + wbtc.balanceOf(address(feeCollector)), + 0, + "fee collector funded" + ); + + // User redeems withdrawal token through manager + vm.startPrank(USER); + withdrawalToken.safeTransferFrom( + USER, + address(manager), + processed.batchId, + burnAmount, + "" + ); + vm.stopPrank(); + + // Redemption should burn redemption tokens and send collectedAmount to the user. + assertEq( + withdrawalToken.totalSupply(processed.batchId), + 0, + "redemption tokens burned" + ); + assertEq( + wbtc.balanceOf(USER), + expectedCollected, + "user received redemption payout" + ); + } + + function testDepositCycleTicksFollowFsm() external { + uint256 depositAmount = 5e7; + // Fund core directly to simulate accumulated deposits + wbtc.mint(address(core), depositAmount); + + // Idle -> DepositEthereum; locks waitosaur observer and flushes funds + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 1, "DepositEthereum"); + assertEq(waitosaurObserver.lockedAmount(), depositAmount); + assertEq(wbtc.balanceOf(DEPOSIT_FORWARDER), depositAmount, "flushed"); + + // Unlock observer and complete cycle back to Idle + vm.prank(OPERATOR); + waitosaurObserver.unlock(); + assertEq(waitosaurObserver.lockedAmount(), 0, "observer unlocked"); + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 2, "DepositPending"); + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 3, "DepositJlp"); + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 0, "Idle"); + } + + function testWithdrawCycleTicksWithWaitosaurHolder() external { + provider.publish(1e18, block.timestamp); + provider.publishAum(0, wbtc.decimals()); + + // Mint some maxBTC to user + maxbtc.mint(USER, 2e8); + // Provide partial deposits to core + wbtc.mint(address(core), 5e7); + + vm.startPrank(USER); + maxbtc.approve(address(core), type(uint256).max); + core.withdraw(2e8); + vm.stopPrank(); + + // Idle -> WithdrawJlp + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 4, "WithdrawJlp"); + + // WithdrawJlp -> WithdrawPending + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 5, "WithdrawPending"); + + // Deposit some BTC into waitosaur holder and lock it to be collected. + uint256 lockedAmount = 3e7; + wbtc.mint(address(waitosaurHolder), lockedAmount); + vm.prank(OPERATOR); + waitosaurHolder.lock(lockedAmount); + + // WithdrawPending -> WithdrawEthereum; should pull locked funds + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 6, "WithdrawEthereum"); + // First leg was partially covered with fees applied, then lockedAmount added. + uint256 expectedCovered = (5e7 * (1e18 - DEPOSIT_COST)) / 1e18; + expectedCovered = (expectedCovered * (1e18 - WITHDRAWAL_COST)) / 1e18; + uint256 expectedTotal = expectedCovered + lockedAmount; + assertEq( + wbtc.balanceOf(address(manager)), + expectedTotal, + "manager got collected + partial" + ); + assertEq(waitosaurHolder.lockedAmount(), 0, "holder unlocked"); + + // Finalize and return to Idle + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 0, "Idle"); + + Batch memory finalized = core.finalizedBatch(0); + assertEq(finalized.collectedAmount, expectedTotal); + } + + function testFeeCollectorCollectsAndClaims() external { + // Mint supply via deposit + provider.publish(1e18, block.timestamp); + uint256 depositAmount = 2e8; + wbtc.mint(USER, depositAmount); + vm.startPrank(USER); + wbtc.approve(address(core), depositAmount); + core.deposit(depositAmount, USER, 0); + vm.stopPrank(); + uint256 totalSupplyBefore = maxbtc.totalSupply(); + + // Advance time to satisfy collection period and increase ER + vm.warp(block.timestamp + 2); + provider.publish(11e17, block.timestamp); // 1.1x ER + + // Collect fee (mints to feeCollector address through core.mintFee) + vm.prank(OWNER); + feeCollector.collectFee(); + uint256 minted = maxbtc.balanceOf(address(feeCollector)); + assertGt(minted, 0, "fee minted"); + assertEq( + maxbtc.totalSupply(), + totalSupplyBefore + minted, + "supply increased by minted fee" + ); + + // Claim minted fees to treasury + vm.prank(OWNER); + feeCollector.claim(minted, TREASURY); + assertEq(maxbtc.balanceOf(TREASURY), minted, "claimed to treasury"); + } + + function testAllowlistEndToEnd() external { + allowlist.setAllowed(USER, false); + wbtc.mint(USER, 1e8); + vm.startPrank(USER); + wbtc.approve(address(core), 1e8); + vm.expectRevert( + abi.encodeWithSelector(MaxBTCCore.AddressNotAllowed.selector, USER) + ); + core.deposit(1e8, USER, 0); + vm.stopPrank(); + } + + function testMinReceiveSlippage() external { + provider.publish(1e18, block.timestamp); + uint256 amount = 1e8; + wbtc.mint(USER, amount); + vm.startPrank(USER); + wbtc.approve(address(core), amount); + uint256 minted = (amount * (1e18 - DEPOSIT_COST)) / 1e18; + vm.expectRevert( + abi.encodeWithSelector( + MaxBTCCore.SlippageLimitExceeded.selector, + minted + 1, + minted + ) + ); + core.deposit(amount, USER, minted + 1); + vm.stopPrank(); + } + + function testCapAumRejection() external { + vm.prank(OWNER); + core.setDepositsCap(1e8, true); // cap 1 WBTC + provider.publishAum(int256(1e8), wbtc.decimals()); // already at cap + provider.publish(1e18, block.timestamp); + wbtc.mint(USER, 1); + vm.startPrank(USER); + wbtc.approve(address(core), 1); + vm.expectRevert(MaxBTCCore.DepositCapExceeded.selector); + core.deposit(1, USER, 0); + vm.stopPrank(); + } + + function testPauseAndOperatorPermissions() external { + vm.prank(OWNER); + core.setPaused(true); + vm.startPrank(USER); + wbtc.mint(USER, 1); + wbtc.approve(address(core), 1); + vm.expectRevert(MaxBTCCore.ContractPaused.selector); + core.deposit(1, USER, 0); + vm.stopPrank(); + + vm.prank(OWNER); + core.setPaused(false); + vm.prank(OPERATOR); + core.tick(); // operator may tick while unpaused + address random = address(0x1234); + vm.startPrank(random); + vm.expectRevert(MaxBTCCore.InvalidOperatorAddress.selector); + core.tick(); + vm.stopPrank(); + } + + function testUpgradePathKeepsState() external { + provider.publish(1e18, block.timestamp); + provider.publishAum(0, wbtc.decimals()); + uint256 depositAmount = 1e8; + // Seed a deposit and withdrawal to populate storage + wbtc.mint(USER, depositAmount); + vm.startPrank(USER); + wbtc.approve(address(core), depositAmount); + core.deposit(depositAmount, USER, 0); + core.withdraw(5e7); + vm.stopPrank(); + vm.prank(OPERATOR); + (Batch memory processed, bool finalizedBefore) = core.tick(); + assertTrue(finalizedBefore, "finalized pre-upgrade"); + Batch memory storedBefore = core.finalizedBatch(processed.batchId); + uint256 activeBatchIdBefore = core.activeBatch().batchId; + uint256 supplyBefore = maxbtc.totalSupply(); + + CoreV2 newImpl = new CoreV2(); + vm.prank(OWNER); + core.upgradeToAndCall(address(newImpl), ""); + assertEq(CoreV2(address(core)).version(), "v2"); + assertEq( + core.activeBatch().batchId, + activeBatchIdBefore, + "batch id kept" + ); + Batch memory storedAfter = core.finalizedBatch(processed.batchId); + assertEq( + storedAfter.collectedAmount, + storedBefore.collectedAmount, + "finalized batch kept" + ); + + // Post-upgrade operations still work + wbtc.mint(USER, depositAmount); + vm.startPrank(USER); + wbtc.approve(address(core), depositAmount); + core.deposit(depositAmount, USER, 0); + vm.stopPrank(); + assertGt( + maxbtc.totalSupply(), + supplyBefore, + "post-upgrade deposit works" + ); + } + + function testMultiCycleInterleavedFsmAndLockCarryOver() external { + provider.publish(1e18, block.timestamp); + // Cycle 1: cover withdrawal fully and return to Idle + wbtc.mint(USER, 2e8); + vm.startPrank(USER); + wbtc.approve(address(core), 2e8); + core.deposit(2e8, USER, 0); + core.withdraw(5e7); + vm.stopPrank(); + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 0, "finalized and idle"); + assertEq(core.activeBatch().batchId, 1, "batch incremented"); + + // Cycle 2: leave observer locked from previous flush and ensure next tick reverts + uint256 depositBalance = 1e8; + wbtc.mint(address(core), depositBalance); + vm.prank(OPERATOR); + core.tick(); // Idle -> DepositEthereum and lock + assertEq(uint8(core.contractState()), 1, "DepositEthereum"); + vm.prank(OPERATOR); + vm.expectRevert(MaxBTCCore.WaitosaurLocked.selector); + core.tick(); + // Unlock and finish cycle, batch id should advance to 2 after full cycle + vm.prank(OPERATOR); + waitosaurObserver.unlock(); + vm.prank(OPERATOR); + core.tick(); // DepositPending + vm.prank(OPERATOR); + core.tick(); // DepositJlp + vm.prank(OPERATOR); + core.tick(); // Idle + // Start a new withdrawal to advance batch id + vm.prank(USER); + core.withdraw(5e7); + vm.prank(OPERATOR); + core.tick(); // Idle -> WithdrawJlp (creates withdrawing batch id 1, active batch id 2) + vm.prank(OPERATOR); + core.tick(); // WithdrawPending + vm.prank(OPERATOR); + core.tick(); // WithdrawEthereum + vm.prank(OPERATOR); + core.tick(); // finalize -> Idle + assertEq(core.activeBatch().batchId, 2, "batch incremented again"); + } + + function testFinalizeWithdrawingBatchEdgeAmounts() external { + provider.publish(1e18, block.timestamp); + // Mint via owner (no deposits) then trigger withdrawing batch + vm.prank(OWNER); + core.mintByOwner(1e8, USER); + assertEq(maxbtc.balanceOf(USER), 1e8, "owner mint delivered"); + vm.prank(USER); + core.withdraw(1e8); + vm.prank(OPERATOR); + core.tick(); // creates withdrawing batch + + // Calling finalize with same collectedAmount should succeed and keep amount + vm.prank(OPERATOR); + core.finalizeWithdrawingBatch(0); + Batch memory stored = core.finalizedBatch(0); + assertEq(stored.collectedAmount, 0, "kept collected amount"); + + // No withdrawing batch after finalization + vm.prank(OPERATOR); + vm.expectRevert(MaxBTCCore.WithdrawingBatchMissing.selector); + core.finalizeWithdrawingBatch(0); + + // Recreate withdrawing batch and ensure lower amount reverts + vm.prank(OWNER); + core.mintByOwner(1e8, USER); + vm.prank(USER); + core.withdraw(1e8); + // Provide partial deposits so collectedAmount > 0 + wbtc.mint(address(core), 1e7); + vm.prank(OPERATOR); + core.tick(); + vm.prank(OPERATOR); + vm.expectRevert(MaxBTCCore.InvalidAmount.selector); + core.finalizeWithdrawingBatch(0); + } + + function testRedemptionRoundingEdge() external { + provider.publish(1e18, block.timestamp); + // Mint via owner then burn large amount, tiny collected + vm.prank(OWNER); + core.mintByOwner(1e8, USER); + assertEq(maxbtc.balanceOf(USER), 1e8, "owner mint delivered"); + vm.prank(USER); + core.withdraw(1e8); + vm.prank(OPERATOR); + core.tick(); // create withdrawing batch + // Finalize with tiny collected amount of 1 sat (1 wei of WBTC decimals) + wbtc.mint(address(core), 1); // fund core for transfer + vm.prank(OPERATOR); + core.finalizeWithdrawingBatch(1); + + Batch memory finalized = core.finalizedBatch(0); + assertEq(finalized.collectedAmount, 1, "tiny collected stored"); + + // Redeem should pay out floor proportionally (all to user) + vm.prank(USER); + withdrawalToken.safeTransferFrom(USER, address(manager), 0, 1e8, ""); + assertEq(wbtc.balanceOf(USER), 1, "received tiny payout"); + assertEq(withdrawalToken.totalSupply(0), 0, "all tokens burned"); + } + + function testUpgradeKeepsConfigAndMidState() external { + provider.publish(1e18, block.timestamp); + // Move FSM to DepositPending before upgrade + wbtc.mint(address(core), 1e7); + vm.prank(OPERATOR); + core.tick(); // DepositEthereum + uint8 preState = uint8(core.contractState()); + CoreV2 newImpl = new CoreV2(); + vm.prank(OWNER); + core.upgradeToAndCall(address(newImpl), ""); + assertEq(CoreV2(address(core)).version(), "v2"); + assertEq(uint8(core.contractState()), preState, "state preserved"); + uint256 forwarderBefore = wbtc.balanceOf(DEPOSIT_FORWARDER); + uint256 extraDeposit = 5e6; + wbtc.mint(address(core), extraDeposit); + vm.prank(OPERATOR); + waitosaurObserver.unlock(); // unlock observer for continued deposit flow + vm.prank(OPERATOR); + core.tick(); + vm.prank(OPERATOR); + core.tick(); + vm.prank(OPERATOR); + core.tick(); + assertEq(uint8(core.contractState()), 0, "returned idle"); + vm.prank(OPERATOR); + core.tick(); // flush extra deposit from Idle + assertEq( + wbtc.balanceOf(DEPOSIT_FORWARDER) - forwarderBefore, + extraDeposit, + "forwarder still receives flush" + ); + } + + function testAllowlistToggleMidSession() external { + provider.publish(1e18, block.timestamp); + wbtc.mint(USER, 1e8); + vm.startPrank(USER); + wbtc.approve(address(core), 1e8); + core.deposit(1e8, USER, 0); // allowed + vm.stopPrank(); + + // Disallow user blocks further deposits + allowlist.setAllowed(USER, false); + vm.startPrank(USER); + vm.expectRevert( + abi.encodeWithSelector(MaxBTCCore.AddressNotAllowed.selector, USER) + ); + core.deposit(1, USER, 0); + vm.stopPrank(); + + // Re-allow and withdraw works + allowlist.setAllowed(USER, true); + vm.prank(USER); + core.withdraw(5e7); + vm.prank(OPERATOR); + (Batch memory processed, ) = core.tick(); + assertGt(processed.collectedAmount, 0, "processed withdrawal"); + } + + function testPauseDuringMidFsmBlocksTick() external { + wbtc.mint(address(core), 1e7); + vm.prank(OPERATOR); + core.tick(); // Idle -> DepositEthereum + assertEq(uint8(core.contractState()), 1, "DepositEthereum"); + vm.prank(OWNER); + core.setPaused(true); + vm.prank(OPERATOR); + vm.expectRevert(MaxBTCCore.ContractPaused.selector); + core.tick(); + vm.prank(OWNER); + core.setPaused(false); + vm.prank(OPERATOR); + waitosaurObserver.unlock(); + vm.prank(OPERATOR); + core.tick(); // continue to DepositPending + assertEq(uint8(core.contractState()), 2, "DepositPending"); + } + + function testExtremeCostsZeroAndHigh() external { + provider.publish(1e18, block.timestamp); + vm.prank(OWNER); + core.setCosts(0, 0); + wbtc.mint(USER, 1e8); + vm.startPrank(USER); + wbtc.approve(address(core), 1e8); + core.deposit(1e8, USER, 1e8); // 1:1 mint + vm.stopPrank(); + assertEq(maxbtc.balanceOf(USER), 1e8, "minted 1:1"); + + // Near-limit costs still accepted + uint256 nearMax = 999e15; // 99.9% + vm.prank(OWNER); + core.setCosts(nearMax, nearMax); + uint256 amount = 1e8; + wbtc.mint(USER, amount); + vm.startPrank(USER); + wbtc.approve(address(core), amount); + uint256 minReceive = (amount * (1e18 - nearMax)) / 1e18; + core.deposit(amount, USER, minReceive); + vm.stopPrank(); + assertEq( + maxbtc.balanceOf(USER), + 1e8 + minReceive, + "minted with high fee" + ); + } + + function testMinReceiveFailsOnWorseErAfterApproval() external { + provider.publish(1e18, block.timestamp); + uint256 amount = 1e8; + wbtc.mint(USER, amount); + uint256 minReceive = (amount * (1e18 - DEPOSIT_COST)) / 1e18; + vm.startPrank(USER); + wbtc.approve(address(core), amount); + // Publish worse ER so mint amount drops + vm.stopPrank(); + provider.publish(2e18, block.timestamp); + vm.startPrank(USER); + vm.expectRevert( + abi.encodeWithSelector( + MaxBTCCore.SlippageLimitExceeded.selector, + minReceive, + minReceive / 2 + ) + ); + core.deposit(amount, USER, minReceive); + vm.stopPrank(); + } + + function testWaitosaurHolderUnlockFailures() external { + _moveToWithdrawPendingWithLock(5e7); + vm.prank(OPERATOR); + vm.expectRevert(WaitosaurHolder.InsufficientBalance.selector); + core.tick(); // WithdrawPending -> should fail unlock + + // Unauthorized unlock attempt + vm.expectRevert(WaitosaurHolder.NotUnLocker.selector); + waitosaurHolder.unlock(); + } + + function testCapToggleAllowsLaterDeposits() external { + provider.publish(1e18, block.timestamp); + vm.prank(OWNER); + core.setDepositsCap(1e8, true); // cap 1 WBTC + provider.publishAum(int256(1e8), wbtc.decimals()); // at cap + wbtc.mint(USER, 1e6); + vm.startPrank(USER); + wbtc.approve(address(core), 1e6); + vm.expectRevert(MaxBTCCore.DepositCapExceeded.selector); + core.deposit(1e6, USER, 0); + vm.stopPrank(); + + // Disable cap and deposit succeeds + vm.prank(OWNER); + core.setDepositsCap(0, false); + vm.startPrank(USER); + core.deposit(1e6, USER, 0); + vm.stopPrank(); + assertGt(maxbtc.balanceOf(USER), 0, "deposit now allowed"); + } + + function _moveToWithdrawPendingWithLock(uint256 lockAmount) private { + provider.publish(1e18, block.timestamp); + vm.prank(OWNER); + core.mintByOwner(1e8, USER); + assertEq(maxbtc.balanceOf(USER), 1e8, "owner mint delivered"); + vm.prank(USER); + core.withdraw(1e8); + vm.prank(OPERATOR); + core.tick(); // Idle -> WithdrawJlp + vm.prank(OPERATOR); + core.tick(); // WithdrawJlp -> WithdrawPending + vm.prank(OPERATOR); + waitosaurHolder.lock(lockAmount); + } + + function testMultiUserWithdrawalsAndRedemptions() external { + provider.publish(1e18, block.timestamp); + uint256 depositAmount = 2e8; + // Two users deposit + address user1 = USER; + address user2 = address(0xB0B0); + allowlist.setAllowed(user2, true); + wbtc.mint(user1, depositAmount); + wbtc.mint(user2, depositAmount); + vm.startPrank(user1); + wbtc.approve(address(core), depositAmount); + core.deposit(depositAmount, user1, 0); + vm.stopPrank(); + vm.startPrank(user2); + wbtc.approve(address(core), depositAmount); + core.deposit(depositAmount, user2, 0); + vm.stopPrank(); + + // Both withdraw + vm.startPrank(user1); + core.withdraw(5e7); + vm.stopPrank(); + vm.startPrank(user2); + core.withdraw(5e7); + vm.stopPrank(); + + // Tick should finalize (enough deposits) + vm.prank(OPERATOR); + core.tick(); + Batch memory finalized = core.finalizedBatch(0); + uint256 totalSupplyRedemption = withdrawalToken.totalSupply( + finalized.batchId + ); + assertEq(totalSupplyRedemption, 1e8, "redemption supply"); + + // Each redeems half + vm.startPrank(user1); + withdrawalToken.safeTransferFrom( + user1, + address(manager), + finalized.batchId, + 5e7, + "" + ); + vm.stopPrank(); + vm.startPrank(user2); + withdrawalToken.safeTransferFrom( + user2, + address(manager), + finalized.batchId, + 5e7, + "" + ); + vm.stopPrank(); + assertEq( + withdrawalToken.totalSupply(finalized.batchId), + 0, + "all redemption burned" + ); + // Manager transfers proportionally + uint256 expectedUser = (finalized.collectedAmount * 5e7) / 1e8; + assertEq(wbtc.balanceOf(user1), expectedUser); + assertEq(wbtc.balanceOf(user2), expectedUser); + } + + function testObserverLockedBlocksDepositTick() external { + wbtc.mint(address(core), 1e7); + // First tick locks observer + vm.prank(OPERATOR); + core.tick(); + // Second tick should revert until unlock is called + vm.prank(OPERATOR); + vm.expectRevert(MaxBTCCore.WaitosaurLocked.selector); + core.tick(); + } +} diff --git a/test/MaxBTCERC20.test.sol b/test/MaxBTCERC20.test.sol index 5f1ae88..fa65294 100644 --- a/test/MaxBTCERC20.test.sol +++ b/test/MaxBTCERC20.test.sol @@ -1,49 +1,65 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -import { Test } from "forge-std/Test.sol"; -import { MaxBTCERC20 } from "../src/MaxBTCERC20.sol"; -import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Test} from "forge-std/Test.sol"; +import {MaxBTCERC20} from "../src/MaxBTCERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; contract MaxBTCERC20Test is Test { address private constant OWNER = address(1); address private constant ICS20 = address(2); - address private constant ESCROW = address(3); + address private constant CORE = address(3); + address private constant ESCROW = address(4); - MaxBTCERC20 private maxBTCERC20; + MaxBTCERC20 private maxBtcErc20; function setUp() external { MaxBTCERC20 implementation = new MaxBTCERC20(); - bytes memory maxBTCERC20InitializeCall = - abi.encodeCall(MaxBTCERC20.initialize, (OWNER, ICS20, "Structured maxBTC", "maxBTC")); - ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), maxBTCERC20InitializeCall); - maxBTCERC20 = MaxBTCERC20(address(proxy)); + bytes memory maxBTCERC20InitializeCall = abi.encodeCall( + MaxBTCERC20.initialize, + (OWNER, ICS20, CORE, "Structured maxBTC", "maxBTC") + ); + ERC1967Proxy proxy = new ERC1967Proxy( + address(implementation), + maxBTCERC20InitializeCall + ); + maxBtcErc20 = MaxBTCERC20(address(proxy)); } function testMintSuccess() external { vm.startPrank(ICS20); - maxBTCERC20.mint(ESCROW, 100); - assertEq(maxBTCERC20.balanceOf(ESCROW), 100); + maxBtcErc20.mint(ESCROW, 100); + assertEq(maxBtcErc20.balanceOf(ESCROW), 100); } function testMintUnauthorized() external { vm.startPrank(OWNER); - vm.expectRevert(abi.encodeWithSelector(MaxBTCERC20.CallerIsNotICS20.selector, OWNER)); - maxBTCERC20.mint(ESCROW, 100); + vm.expectRevert( + abi.encodeWithSelector( + MaxBTCERC20.CallerIsNotAllowed.selector, + OWNER + ) + ); + maxBtcErc20.mint(ESCROW, 100); } function testBurnSuccess() external { vm.startPrank(ICS20); - maxBTCERC20.mint(ESCROW, 100); - maxBTCERC20.burn(ESCROW, 20); - assertEq(maxBTCERC20.balanceOf(ESCROW), 80); + maxBtcErc20.mint(ESCROW, 100); + maxBtcErc20.burn(ESCROW, 20); + assertEq(maxBtcErc20.balanceOf(ESCROW), 80); } function testBurnUnauthorized() external { vm.startPrank(ICS20); - maxBTCERC20.mint(ESCROW, 100); + maxBtcErc20.mint(ESCROW, 100); vm.startPrank(OWNER); - vm.expectRevert(abi.encodeWithSelector(MaxBTCERC20.CallerIsNotICS20.selector, OWNER)); - maxBTCERC20.burn(ESCROW, 20); + vm.expectRevert( + abi.encodeWithSelector( + MaxBTCERC20.CallerIsNotAllowed.selector, + OWNER + ) + ); + maxBtcErc20.burn(ESCROW, 20); } } From 010b07231420f3d21ad4577b63e48ec8fb5df989 Mon Sep 17 00:00:00 2001 From: Albert Andrejev Date: Thu, 4 Dec 2025 20:48:29 +0200 Subject: [PATCH 2/7] Fixes after rebase --- test/MaxBTCCore.test.sol | 7 ++----- test/MaxBTCCoreIntegration.test.sol | 31 ++++++++++++++++++++--------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/test/MaxBTCCore.test.sol b/test/MaxBTCCore.test.sol index 7e66321..f642085 100644 --- a/test/MaxBTCCore.test.sol +++ b/test/MaxBTCCore.test.sol @@ -161,11 +161,8 @@ contract MaxBTCCoreTest is Test { "Redemption", "rMAX-" ); - waitosaurHolder.updateConfig( - OPERATOR, - address(core), - WITHDRAWAL_MANAGER - ); + waitosaurHolder.updateRoles(OPERATOR, address(core)); + waitosaurHolder.updateConfig(WITHDRAWAL_MANAGER); allowlist.setAllowed(USER, true); } diff --git a/test/MaxBTCCoreIntegration.test.sol b/test/MaxBTCCoreIntegration.test.sol index 50aa624..cfd5e51 100644 --- a/test/MaxBTCCoreIntegration.test.sol +++ b/test/MaxBTCCoreIntegration.test.sol @@ -4,11 +4,14 @@ pragma solidity ^0.8.28; import {Test} from "forge-std/Test.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {MaxBTCCore} from "../src/MaxBTCCore.sol"; import {MaxBTCERC20} from "../src/MaxBTCERC20.sol"; import {WithdrawalToken} from "../src/WithdrawalToken.sol"; import {WithdrawalManager} from "../src/WithdrawalManager.sol"; import {WaitosaurHolder} from "../src/WaitosaurHolder.sol"; +import {WaitosaurBase} from "../src/WaitosaurBase.sol"; import {WaitosaurObserver, IAumOracle} from "../src/WaitosaurObserver.sol"; import {FeeCollector} from "../src/FeeCollector.sol"; import {Receiver} from "../src/Receiver.sol"; @@ -176,13 +179,10 @@ contract MaxBTCCoreIntegrationTest is Test { maxbtc = MaxBTCERC20(address(maxbtcProxy)); withdrawalToken = WithdrawalToken(address(withdrawalProxy)); manager = WithdrawalManager(address(managerProxy)); - waitosaurObserver.updateConfig( - address(core), - OPERATOR, - address(oracle), - "" - ); - waitosaurHolder.updateConfig(OPERATOR, address(core), address(manager)); + waitosaurObserver.updateRoles(address(core), OPERATOR); + waitosaurObserver.updateConfig(address(oracle), ""); + waitosaurHolder.updateRoles(OPERATOR, address(core)); + waitosaurHolder.updateConfig(address(manager)); // Now initialize dependent contracts with the finalized core address. maxbtc.initialize( @@ -748,12 +748,24 @@ contract MaxBTCCoreIntegrationTest is Test { function testWaitosaurHolderUnlockFailures() external { _moveToWithdrawPendingWithLock(5e7); + // Ensure roles set correctly for unlocker + waitosaurHolder.updateRoles(OPERATOR, address(core)); + // Drain holder balance to force insufficient balance on unlock + vm.startPrank(address(waitosaurHolder)); + SafeERC20.safeTransfer( + IERC20(address(wbtc)), + address(0xdead), + wbtc.balanceOf(address(waitosaurHolder)) + ); + vm.stopPrank(); vm.prank(OPERATOR); - vm.expectRevert(WaitosaurHolder.InsufficientBalance.selector); + vm.expectRevert(WaitosaurBase.InsufficientAssetAmount.selector); core.tick(); // WithdrawPending -> should fail unlock // Unauthorized unlock attempt - vm.expectRevert(WaitosaurHolder.NotUnLocker.selector); + address random = address(0xBADC0DE); + vm.prank(random); + vm.expectRevert(WaitosaurBase.Unauthorized.selector); waitosaurHolder.unlock(); } @@ -789,6 +801,7 @@ contract MaxBTCCoreIntegrationTest is Test { core.tick(); // Idle -> WithdrawJlp vm.prank(OPERATOR); core.tick(); // WithdrawJlp -> WithdrawPending + wbtc.mint(address(waitosaurHolder), lockAmount); vm.prank(OPERATOR); waitosaurHolder.lock(lockAmount); } From d59b05fe2b65487b2c2cf14a6d3c6cb21c3098fe Mon Sep 17 00:00:00 2001 From: Sergey Ratiashvili Date: Wed, 19 Nov 2025 15:06:12 +0100 Subject: [PATCH 3/7] Add allow list contract --- src/Allowlist.sol | 132 ++++++++++++++++++++++++++ src/MaxBTCCore.sol | 4 +- src/WaitosaurHolder.sol | 2 + test/Allowlist.test.sol | 139 ++++++++++++++++++++++++++++ test/MaxBTCCore.test.sol | 41 ++++---- test/MaxBTCCoreIntegration.test.sol | 40 ++++---- 6 files changed, 314 insertions(+), 44 deletions(-) create mode 100644 src/Allowlist.sol create mode 100644 test/Allowlist.test.sol diff --git a/src/Allowlist.sol b/src/Allowlist.sol new file mode 100644 index 0000000..60eb7d6 --- /dev/null +++ b/src/Allowlist.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; + +/// @notice Minimal external interface for ZKMe-like approval checker. +interface IZkMe { + function hasApproved( + address cooperator, + address user + ) external view returns (bool); +} + +/// @notice Simple allowlist contract with optional ZKMe fallback, adapted from the Rust implementation. +contract Allowlist is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { + struct ZkMeSettings { + address contractAddr; + address cooperator; + bool enabled; + } + + struct AllowlistStorage { + mapping(address => bool) allowed; + ZkMeSettings zkMe; + } + + // keccak256(abi.encode(uint256(keccak256("maxbtc.allowlist.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ALLOWLIST_STORAGE_SLOT = + 0x726794df19ef5494e47a09cfc5a29af7f99de86be3f96230f62fe88e31729e00; + + event AddressAllowed(address indexed account); + event AddressDenied(address indexed account); + event ZkMeSettingsUpdated( + address contractAddr, + address cooperator, + bool enabled + ); + event ZkMeSettingsReset(); + + error ZeroAddressNotAllowed(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address owner_) public initializer { + __Ownable_init(owner_); + __Ownable2Step_init(); + __UUPSUpgradeable_init(); + } + + function _getStorage() private pure returns (AllowlistStorage storage $) { + assembly { + $.slot := ALLOWLIST_STORAGE_SLOT + } + } + + function allow(address[] calldata accounts) external onlyOwner { + uint256 len = accounts.length; + for (uint256 i = 0; i < len; i++) { + address account = accounts[i]; + if (account == address(0)) { + revert ZeroAddressNotAllowed(); + } + _getStorage().allowed[account] = true; + emit AddressAllowed(account); + } + } + + function deny(address[] calldata accounts) external onlyOwner { + uint256 len = accounts.length; + for (uint256 i = 0; i < len; i++) { + address account = accounts[i]; + if (account == address(0)) { + revert ZeroAddressNotAllowed(); + } + delete _getStorage().allowed[account]; + emit AddressDenied(account); + } + } + + function setZkMeSettings( + address contractAddr, + address cooperator + ) external onlyOwner { + if (contractAddr == address(0) && cooperator == address(0)) { + _getStorage().zkMe = ZkMeSettings({ + contractAddr: address(0), + cooperator: address(0), + enabled: false + }); + emit ZkMeSettingsReset(); + } else { + if (contractAddr == address(0) || cooperator == address(0)) { + revert ZeroAddressNotAllowed(); + } + _getStorage().zkMe = ZkMeSettings({ + contractAddr: contractAddr, + cooperator: cooperator, + enabled: true + }); + + emit ZkMeSettingsUpdated(contractAddr, cooperator, true); + } + } + + function isAddressAllowed(address account) external view returns (bool) { + AllowlistStorage storage $ = _getStorage(); + if ($.allowed[account]) { + return true; + } + if ($.zkMe.enabled) { + return + IZkMe($.zkMe.contractAddr).hasApproved( + $.zkMe.cooperator, + account + ); + } + return false; + } + + function zkMeSettings() external view returns (ZkMeSettings memory) { + return _getStorage().zkMe; + } + + function _authorizeUpgrade( + address newImplementation + ) internal override onlyOwner {} +} diff --git a/src/MaxBTCCore.sol b/src/MaxBTCCore.sol index 35c1c69..779320f 100644 --- a/src/MaxBTCCore.sol +++ b/src/MaxBTCCore.sol @@ -11,9 +11,9 @@ import {MaxBTCERC20} from "./MaxBTCERC20.sol"; import {WithdrawalToken} from "./WithdrawalToken.sol"; import {WaitosaurHolder} from "./WaitosaurHolder.sol"; import {Batch} from "./types/CoreTypes.sol"; -import {IAllowlist} from "./types/IAllowlist.sol"; import {IExchangeRateProvider} from "./types/IExchangeRateProvider.sol"; import {WaitosaurObserver} from "./WaitosaurObserver.sol"; +import {Allowlist} from "./Allowlist.sol"; /// @notice Core settlement logic for the maxBTC protocol. contract MaxBTCCore is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { @@ -177,7 +177,7 @@ contract MaxBTCCore is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { function _onlyAllowlisted(address account) internal view { CoreConfig storage config = _getCoreConfig(); - if (!IAllowlist(config.allowlist).isAddressAllowed(account)) { + if (!Allowlist(config.allowlist).isAddressAllowed(account)) { revert AddressNotAllowed(account); } } diff --git a/src/WaitosaurHolder.sol b/src/WaitosaurHolder.sol index 31e5fd9..31b40b7 100644 --- a/src/WaitosaurHolder.sol +++ b/src/WaitosaurHolder.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {WaitosaurBase, WaitosaurState} from "./WaitosaurBase.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; struct WaitosaurHolderConfig { address token; diff --git a/test/Allowlist.test.sol b/test/Allowlist.test.sol new file mode 100644 index 0000000..79ef98c --- /dev/null +++ b/test/Allowlist.test.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test, console2} from "forge-std/Test.sol"; +import {Allowlist, IZkMe} from "../src/Allowlist.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +contract MockZkMe is IZkMe { + bool public approved; + + function setApproved(bool newVal) external { + approved = newVal; + } + + function hasApproved(address, address) external view returns (bool) { + return approved; + } +} + +contract AllowlistTest is Test { + Allowlist private allowlist; + MockZkMe private zkme; + address private constant OWNER = address(0xABCD); + address private constant USER = address(0xBEEF); + address private constant OTHER = address(0xCAFE); + + function setUp() external { + Allowlist impl = new Allowlist(); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encodeCall(Allowlist.initialize, (OWNER)) + ); + allowlist = Allowlist(address(proxy)); + zkme = new MockZkMe(); + } + + function testAllowAndDeny() external { + assertFalse(Allowlist(address(allowlist)).isAddressAllowed(USER)); + assertFalse(Allowlist(address(allowlist)).isAddressAllowed(OTHER)); + + vm.prank(OWNER); + allowlist.allow(_arr(USER, OTHER)); + assertTrue(Allowlist(address(allowlist)).isAddressAllowed(USER)); + assertTrue(Allowlist(address(allowlist)).isAddressAllowed(OTHER)); + + vm.prank(OWNER); + allowlist.deny(_arr(USER)); + assertFalse(Allowlist(address(allowlist)).isAddressAllowed(USER)); + assertTrue(Allowlist(address(allowlist)).isAddressAllowed(OTHER)); + } + + function testZkMeFallback() external { + vm.prank(OWNER); + allowlist.setZkMeSettings(address(zkme), OTHER); + assertFalse(Allowlist(address(allowlist)).isAddressAllowed(USER)); + zkme.setApproved(true); + assertTrue(Allowlist(address(allowlist)).isAddressAllowed(USER)); + + vm.prank(OWNER); + allowlist.setZkMeSettings(address(0), address(0)); + zkme.setApproved(false); + assertFalse(Allowlist(address(allowlist)).isAddressAllowed(USER)); + } + + function testOnlyOwnerGuards() external { + address attacker = USER; + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector( + OwnableUpgradeable.OwnableUnauthorizedAccount.selector, + attacker + ) + ); + allowlist.allow(_arr(USER)); + + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector( + OwnableUpgradeable.OwnableUnauthorizedAccount.selector, + attacker + ) + ); + allowlist.deny(_arr(USER)); + + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector( + OwnableUpgradeable.OwnableUnauthorizedAccount.selector, + attacker + ) + ); + allowlist.setZkMeSettings(address(zkme), OTHER); + } + + function testInvalidAddressReverts() external { + vm.prank(OWNER); + vm.expectRevert(Allowlist.ZeroAddressNotAllowed.selector); + allowlist.allow(_arr(address(0))); + + vm.prank(OWNER); + vm.expectRevert(Allowlist.ZeroAddressNotAllowed.selector); + allowlist.setZkMeSettings(address(zkme), address(0)); + } + + function testTwoStepOwnershipTransfer() external { + address newOwner = address(0x1234); + vm.prank(OWNER); + allowlist.transferOwnership(newOwner); + assertEq( + allowlist.pendingOwner(), + newOwner, + "pending owner was not set properly" + ); + + vm.prank(newOwner); + allowlist.acceptOwnership(); + assertEq(allowlist.owner(), newOwner, "ownership transferred"); + assertEq( + allowlist.pendingOwner(), + address(0), + "pending owner is no cleared" + ); + } + + function _arr(address a) private pure returns (address[] memory arr) { + arr = new address[](1); + arr[0] = a; + } + + function _arr( + address a, + address b + ) private pure returns (address[] memory arr) { + arr = new address[](2); + arr[0] = a; + arr[1] = b; + } +} diff --git a/test/MaxBTCCore.test.sol b/test/MaxBTCCore.test.sol index f642085..d7e4448 100644 --- a/test/MaxBTCCore.test.sol +++ b/test/MaxBTCCore.test.sol @@ -7,9 +7,9 @@ import {MaxBTCCore} from "../src/MaxBTCCore.sol"; import {MaxBTCERC20} from "../src/MaxBTCERC20.sol"; import {WithdrawalToken} from "../src/WithdrawalToken.sol"; import {WaitosaurHolder} from "../src/WaitosaurHolder.sol"; +import {Allowlist} from "../src/Allowlist.sol"; import {Receiver} from "../src/Receiver.sol"; import {Batch} from "../src/types/CoreTypes.sol"; -import {IAllowlist} from "../src/types/IAllowlist.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; contract MockERC20 is ERC20 { @@ -32,18 +32,6 @@ contract MockERC20 is ERC20 { } } -contract MockAllowlist is IAllowlist { - mapping(address => bool) public allowed; - - function setAllowed(address account, bool allowed_) external { - allowed[account] = allowed_; - } - - function isAddressAllowed(address account) external view returns (bool) { - return allowed[account]; - } -} - contract MockWaitosaurObserver { bool public locked; uint256 public lastLockedAmount; @@ -74,7 +62,7 @@ contract MaxBTCCoreTest is Test { WaitosaurHolder private waitosaurHolder; Receiver private provider; MockERC20 private depositToken; - MockAllowlist private allowlist; + Allowlist private allowlist; MockWaitosaurObserver private waitosaurObserver; address private constant USER = address(0xA11CE); @@ -93,7 +81,12 @@ contract MaxBTCCoreTest is Test { WaitosaurHolder waitosaurHolderImpl = new WaitosaurHolder(); provider = new Receiver(address(this)); depositToken = new MockERC20("WBTC", "WBTC", 8); - allowlist = new MockAllowlist(); + Allowlist allowlistImpl = new Allowlist(); + ERC1967Proxy allowlistProxy = new ERC1967Proxy( + address(allowlistImpl), + abi.encodeCall(Allowlist.initialize, (address(this))) + ); + allowlist = Allowlist(address(allowlistProxy)); waitosaurObserver = new MockWaitosaurObserver(); ERC1967Proxy maxbtcProxy = new ERC1967Proxy(address(maxbtcImpl), ""); @@ -161,10 +154,13 @@ contract MaxBTCCoreTest is Test { "Redemption", "rMAX-" ); - waitosaurHolder.updateRoles(OPERATOR, address(core)); - waitosaurHolder.updateConfig(WITHDRAWAL_MANAGER); + waitosaurHolder.updateConfig( + OPERATOR, + address(core), + WITHDRAWAL_MANAGER + ); - allowlist.setAllowed(USER, true); + allowlist.allow(_arr(USER)); } function testDepositMintsWithFeeAndAllowlist() external { @@ -211,7 +207,7 @@ contract MaxBTCCoreTest is Test { } function testDepositRejectsNotAllowlisted() external { - allowlist.setAllowed(USER, false); + allowlist.deny(_arr(USER)); _publishRate(1e18); depositToken.mint(USER, 1e8); vm.startPrank(USER); @@ -467,7 +463,7 @@ contract MaxBTCCoreTest is Test { } function testWithdrawNotAllowlistedReverts() external { - allowlist.setAllowed(USER, false); + allowlist.deny(_arr(USER)); vm.expectRevert( abi.encodeWithSelector(MaxBTCCore.AddressNotAllowed.selector, USER) ); @@ -512,4 +508,9 @@ contract MaxBTCCoreTest is Test { depositToken.decimals() ); } + + function _arr(address a) private pure returns (address[] memory arr) { + arr = new address[](1); + arr[0] = a; + } } diff --git a/test/MaxBTCCoreIntegration.test.sol b/test/MaxBTCCoreIntegration.test.sol index cfd5e51..240f82f 100644 --- a/test/MaxBTCCoreIntegration.test.sol +++ b/test/MaxBTCCoreIntegration.test.sol @@ -11,11 +11,10 @@ import {MaxBTCERC20} from "../src/MaxBTCERC20.sol"; import {WithdrawalToken} from "../src/WithdrawalToken.sol"; import {WithdrawalManager} from "../src/WithdrawalManager.sol"; import {WaitosaurHolder} from "../src/WaitosaurHolder.sol"; -import {WaitosaurBase} from "../src/WaitosaurBase.sol"; import {WaitosaurObserver, IAumOracle} from "../src/WaitosaurObserver.sol"; +import {Allowlist} from "../src/Allowlist.sol"; import {FeeCollector} from "../src/FeeCollector.sol"; import {Receiver} from "../src/Receiver.sol"; -import {IAllowlist} from "../src/types/IAllowlist.sol"; import {Batch} from "../src/types/CoreTypes.sol"; /// @notice Lightweight mock ERC20 with configurable decimals for WBTC-like asset. @@ -39,19 +38,6 @@ contract MockERC20 is ERC20 { } } -/// @notice Minimal allowlist mock used by the core contract. -contract MockAllowlist is IAllowlist { - mapping(address => bool) public allowed; - - function setAllowed(address account, bool isAllowed) external { - allowed[account] = isAllowed; - } - - function isAddressAllowed(address account) external view returns (bool) { - return allowed[account]; - } -} - contract MockAumOracle is IAumOracle { uint256 public balance; @@ -85,7 +71,7 @@ contract MaxBTCCoreIntegrationTest is Test { MockAumOracle private oracle; Receiver private provider; MockERC20 private wbtc; - MockAllowlist private allowlist; + Allowlist private allowlist; address private constant USER = address(0xA11CE); address private constant OWNER = address(0x0B0B); @@ -107,8 +93,13 @@ contract MaxBTCCoreIntegrationTest is Test { wbtc = new MockERC20("WBTC", "WBTC", 8); provider = new Receiver(address(this)); - allowlist = new MockAllowlist(); - allowlist.setAllowed(USER, true); + Allowlist allowlistImpl = new Allowlist(); + ERC1967Proxy allowlistProxy = new ERC1967Proxy( + address(allowlistImpl), + abi.encodeCall(Allowlist.initialize, (address(this))) + ); + allowlist = Allowlist(address(allowlistProxy)); + allowlist.allow(_arr(USER)); oracle = new MockAumOracle(); oracle.setBalance(type(uint256).max); // Seed ER for fee collector baseline @@ -400,7 +391,7 @@ contract MaxBTCCoreIntegrationTest is Test { } function testAllowlistEndToEnd() external { - allowlist.setAllowed(USER, false); + allowlist.deny(_arr(USER)); wbtc.mint(USER, 1e8); vm.startPrank(USER); wbtc.approve(address(core), 1e8); @@ -659,7 +650,7 @@ contract MaxBTCCoreIntegrationTest is Test { vm.stopPrank(); // Disallow user blocks further deposits - allowlist.setAllowed(USER, false); + allowlist.deny(_arr(USER)); vm.startPrank(USER); vm.expectRevert( abi.encodeWithSelector(MaxBTCCore.AddressNotAllowed.selector, USER) @@ -668,7 +659,7 @@ contract MaxBTCCoreIntegrationTest is Test { vm.stopPrank(); // Re-allow and withdraw works - allowlist.setAllowed(USER, true); + allowlist.allow(_arr(USER)); vm.prank(USER); core.withdraw(5e7); vm.prank(OPERATOR); @@ -812,7 +803,7 @@ contract MaxBTCCoreIntegrationTest is Test { // Two users deposit address user1 = USER; address user2 = address(0xB0B0); - allowlist.setAllowed(user2, true); + allowlist.allow(_arr(user2)); wbtc.mint(user1, depositAmount); wbtc.mint(user2, depositAmount); vm.startPrank(user1); @@ -881,4 +872,9 @@ contract MaxBTCCoreIntegrationTest is Test { vm.expectRevert(MaxBTCCore.WaitosaurLocked.selector); core.tick(); } + + function _arr(address a) private pure returns (address[] memory arr) { + arr = new address[](1); + arr[0] = a; + } } From e81609cefd44baf721c3d9b5c26e86a30adde640 Mon Sep 17 00:00:00 2001 From: Albert Andrejev Date: Thu, 4 Dec 2025 21:13:23 +0200 Subject: [PATCH 4/7] fixes after rebase --- test/MaxBTCCore.test.sol | 7 ++----- test/MaxBTCCoreIntegration.test.sol | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/test/MaxBTCCore.test.sol b/test/MaxBTCCore.test.sol index d7e4448..596d791 100644 --- a/test/MaxBTCCore.test.sol +++ b/test/MaxBTCCore.test.sol @@ -154,11 +154,8 @@ contract MaxBTCCoreTest is Test { "Redemption", "rMAX-" ); - waitosaurHolder.updateConfig( - OPERATOR, - address(core), - WITHDRAWAL_MANAGER - ); + waitosaurHolder.updateRoles(OPERATOR, address(core)); + waitosaurHolder.updateConfig(WITHDRAWAL_MANAGER); allowlist.allow(_arr(USER)); } diff --git a/test/MaxBTCCoreIntegration.test.sol b/test/MaxBTCCoreIntegration.test.sol index 240f82f..2b46d98 100644 --- a/test/MaxBTCCoreIntegration.test.sol +++ b/test/MaxBTCCoreIntegration.test.sol @@ -10,6 +10,7 @@ import {MaxBTCCore} from "../src/MaxBTCCore.sol"; import {MaxBTCERC20} from "../src/MaxBTCERC20.sol"; import {WithdrawalToken} from "../src/WithdrawalToken.sol"; import {WithdrawalManager} from "../src/WithdrawalManager.sol"; +import {WaitosaurBase} from "../src/WaitosaurBase.sol"; import {WaitosaurHolder} from "../src/WaitosaurHolder.sol"; import {WaitosaurObserver, IAumOracle} from "../src/WaitosaurObserver.sol"; import {Allowlist} from "../src/Allowlist.sol"; From 610c8a80b02a54ec6c4b2b975c3a89e01907c206 Mon Sep 17 00:00:00 2001 From: Albert Andrejev Date: Thu, 4 Dec 2025 21:14:43 +0200 Subject: [PATCH 5/7] fix assert messages --- test/Allowlist.test.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Allowlist.test.sol b/test/Allowlist.test.sol index 79ef98c..19dc152 100644 --- a/test/Allowlist.test.sol +++ b/test/Allowlist.test.sol @@ -115,11 +115,11 @@ contract AllowlistTest is Test { vm.prank(newOwner); allowlist.acceptOwnership(); - assertEq(allowlist.owner(), newOwner, "ownership transferred"); + assertEq(allowlist.owner(), newOwner, "ownership was not transferred"); assertEq( allowlist.pendingOwner(), address(0), - "pending owner is no cleared" + "pending owner was not cleared" ); } From 2c318216065540e69fb94dcd6300438a824ec844 Mon Sep 17 00:00:00 2001 From: Albert Andrejev Date: Thu, 4 Dec 2025 21:45:35 +0200 Subject: [PATCH 6/7] Fix tests --- test/Allowlist.test.sol | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/test/Allowlist.test.sol b/test/Allowlist.test.sol index 19dc152..2357f11 100644 --- a/test/Allowlist.test.sol +++ b/test/Allowlist.test.sol @@ -7,14 +7,22 @@ import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.s import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; contract MockZkMe is IZkMe { - bool public approved; + mapping(address => bool) public approved; + address public cooperator; - function setApproved(bool newVal) external { - approved = newVal; + function setCooperator(address _cooperator) external { + cooperator = _cooperator; } - function hasApproved(address, address) external view returns (bool) { - return approved; + function setApproved(address _approved) external { + approved[_approved] = true; + } + + function hasApproved( + address _cooperator, + address user + ) external view returns (bool) { + return _cooperator == cooperator && approved[user]; } } @@ -33,6 +41,7 @@ contract AllowlistTest is Test { ); allowlist = Allowlist(address(proxy)); zkme = new MockZkMe(); + zkme.setCooperator(OTHER); } function testAllowAndDeny() external { @@ -54,12 +63,12 @@ contract AllowlistTest is Test { vm.prank(OWNER); allowlist.setZkMeSettings(address(zkme), OTHER); assertFalse(Allowlist(address(allowlist)).isAddressAllowed(USER)); - zkme.setApproved(true); + zkme.setApproved(USER); assertTrue(Allowlist(address(allowlist)).isAddressAllowed(USER)); vm.prank(OWNER); + // Remove zkMe settings from allowlist allowlist.setZkMeSettings(address(0), address(0)); - zkme.setApproved(false); assertFalse(Allowlist(address(allowlist)).isAddressAllowed(USER)); } From a884d2e4f929194db40594f7f26ff6f9d5f66d7b Mon Sep 17 00:00:00 2001 From: Albert Andrejev Date: Fri, 5 Dec 2025 18:22:45 +0200 Subject: [PATCH 7/7] remove not required code --- src/MaxBTCCore.sol | 45 ++++++++++++----------------- src/WaitosaurHolder.sol | 2 -- test/Allowlist.test.sol | 2 +- test/MaxBTCCore.test.sol | 7 +++-- test/MaxBTCCoreIntegration.test.sol | 12 ++++---- 5 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/MaxBTCCore.sol b/src/MaxBTCCore.sol index 779320f..48d9042 100644 --- a/src/MaxBTCCore.sol +++ b/src/MaxBTCCore.sol @@ -69,8 +69,8 @@ contract MaxBTCCore is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { bool finalized ); - event TickIdleNoOp(); - event TickDepositEthereum(uint256 flushedAmount); + event TickIdle(); + event TickDepositEthereum(); event TickDepositPending(); event TickDepositJlp(); event TickWithdrawJlp(); @@ -577,7 +577,7 @@ contract MaxBTCCore is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { external notPaused onlyOperatorOrOwner - returns (Batch memory processedBatch, bool finalized) + returns (bool finalized) { CoreConfig storage config = _getCoreConfig(); ContractState state = _state(); @@ -589,7 +589,7 @@ contract MaxBTCCore is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { if (state == ContractState.Idle) { if (batch.maxBtcBurned > 0) { - (processedBatch, finalized) = _processWithdrawals( + finalized = _processWithdrawals( config, batchState, batch, @@ -598,38 +598,37 @@ contract MaxBTCCore is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { if (!finalized) { _setState(ContractState.WithdrawJlp); } - return (processedBatch, finalized); + return (finalized); } if (depositBalance > 0) { _flushDeposits(config, depositBalance); _setState(ContractState.DepositEthereum); - emit TickDepositEthereum(depositBalance); } - emit TickIdleNoOp(); - return (processedBatch, finalized); + emit TickIdle(); + return (finalized); } if (state == ContractState.DepositEthereum) { _ensureWaitosaurUnlocked(config); _setState(ContractState.DepositPending); - emit TickDepositPending(); - return (processedBatch, finalized); + emit TickDepositEthereum(); + return (false); } if (state == ContractState.DepositPending) { _setState(ContractState.DepositJlp); - emit TickDepositJlp(); - return (processedBatch, finalized); + emit TickDepositPending(); + return (false); } if (state == ContractState.DepositJlp) { _setState(ContractState.Idle); - emit TickIdleNoOp(); - return (processedBatch, finalized); + emit TickDepositJlp(); + return (false); } if (state == ContractState.WithdrawJlp) { _setState(ContractState.WithdrawPending); emit TickWithdrawJlp(); - return (processedBatch, finalized); + return (false); } if (state == ContractState.WithdrawPending) { WaitosaurHolder holder = WaitosaurHolder(config.waitosaurHolder); @@ -640,7 +639,7 @@ contract MaxBTCCore is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { } _setState(ContractState.WithdrawEthereum); emit TickWithdrawPending(lockedAmount); - return (processedBatch, finalized); + return (false); } if (state == ContractState.WithdrawEthereum) { _finalizeWithdrawingBatch(batchState.withdrawingBatch); @@ -649,7 +648,7 @@ contract MaxBTCCore is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { emit TickWithdrawEthereumFinalized( batchState.withdrawingBatch.batchId ); - return (processedBatch, finalized); + return (finalized); } } @@ -706,7 +705,7 @@ contract MaxBTCCore is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { BatchState storage batchState, Batch memory batch, uint256 depositBalance - ) private returns (Batch memory processedBatch, bool finalized) { + ) private returns (bool finalized) { (uint256 exchangeRate, uint256 lastUpdated) = _getExchangeRate(config); if (block.timestamp - lastUpdated >= config.exchangeRateStalePeriod) { revert ExchangeRateStale(); @@ -752,21 +751,19 @@ contract MaxBTCCore is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { ); } - processedBatch = batch; - if (depositBeforeFees <= depositBalance) { _addFinalizedBatch(batch); finalized = true; - batchState.activeBatch = _createNewBatch(batch.batchId + 1); } else { if (batchState.hasWithdrawingBatch) { revert WithdrawingBatchAlreadyExists(); } batchState.withdrawingBatch = batch; batchState.hasWithdrawingBatch = true; - batchState.activeBatch = _createNewBatch(batch.batchId + 1); } + batchState.activeBatch = _createNewBatch(batch.batchId + 1); + emit BatchProcessed( batch.batchId, batch.btcRequested, @@ -783,10 +780,6 @@ contract MaxBTCCore is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { return; } - if (config.waitosaurObserver == address(0)) { - revert InvalidWaitosaurObserverAddress(); - } - WaitosaurObserver(config.waitosaurObserver).lock(depositBalance); SafeERC20.safeTransfer( diff --git a/src/WaitosaurHolder.sol b/src/WaitosaurHolder.sol index 31b40b7..31e5fd9 100644 --- a/src/WaitosaurHolder.sol +++ b/src/WaitosaurHolder.sol @@ -4,8 +4,6 @@ pragma solidity ^0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {WaitosaurBase, WaitosaurState} from "./WaitosaurBase.sol"; -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; struct WaitosaurHolderConfig { address token; diff --git a/test/Allowlist.test.sol b/test/Allowlist.test.sol index 2357f11..40b54ee 100644 --- a/test/Allowlist.test.sol +++ b/test/Allowlist.test.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -import {Test, console2} from "forge-std/Test.sol"; +import {Test} from "forge-std/Test.sol"; import {Allowlist, IZkMe} from "../src/Allowlist.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; diff --git a/test/MaxBTCCore.test.sol b/test/MaxBTCCore.test.sol index 596d791..91f1f0c 100644 --- a/test/MaxBTCCore.test.sol +++ b/test/MaxBTCCore.test.sol @@ -271,7 +271,8 @@ contract MaxBTCCoreTest is Test { vm.stopPrank(); vm.prank(OPERATOR); - (Batch memory processed, bool finalized) = core.tick(); + bool finalized = core.tick(); + Batch memory processed = core.finalizedBatch(0); uint256 depositBeforeFees = (processed.btcRequested * 1e18 + @@ -320,8 +321,10 @@ contract MaxBTCCoreTest is Test { core.withdraw(2e8); vm.prank(OPERATOR); - (Batch memory processed, bool finalized) = core.tick(); + bool finalized = core.tick(); assertFalse(finalized, "should move to withdrawing"); + (Batch memory processed, bool has) = core.withdrawingBatch(); + assertTrue(has, "withdrawing batch exists"); uint256 expectedCollected = (5e7 * (1e18 - DEPOSIT_COST)) / 1e18; expectedCollected = (expectedCollected * (1e18 - WITHDRAWAL_COST)) / diff --git a/test/MaxBTCCoreIntegration.test.sol b/test/MaxBTCCoreIntegration.test.sol index 2b46d98..62cd0c4 100644 --- a/test/MaxBTCCoreIntegration.test.sol +++ b/test/MaxBTCCoreIntegration.test.sol @@ -237,8 +237,9 @@ contract MaxBTCCoreIntegrationTest is Test { // Operator processes batch; fully covered by deposits so it finalizes immediately. vm.prank(OPERATOR); - (Batch memory processed, bool finalized) = core.tick(); + bool finalized = core.tick(); assertTrue(finalized, "batch should finalize"); + Batch memory processed = core.finalizedBatch(0); // Check manager received the payout and fee collector got the fee. uint256 expectedCollected = processed.collectedAmount; @@ -467,9 +468,9 @@ contract MaxBTCCoreIntegrationTest is Test { core.withdraw(5e7); vm.stopPrank(); vm.prank(OPERATOR); - (Batch memory processed, bool finalizedBefore) = core.tick(); + bool finalizedBefore = core.tick(); assertTrue(finalizedBefore, "finalized pre-upgrade"); - Batch memory storedBefore = core.finalizedBatch(processed.batchId); + Batch memory storedBefore = core.finalizedBatch(0); uint256 activeBatchIdBefore = core.activeBatch().batchId; uint256 supplyBefore = maxbtc.totalSupply(); @@ -482,7 +483,7 @@ contract MaxBTCCoreIntegrationTest is Test { activeBatchIdBefore, "batch id kept" ); - Batch memory storedAfter = core.finalizedBatch(processed.batchId); + Batch memory storedAfter = core.finalizedBatch(0); assertEq( storedAfter.collectedAmount, storedBefore.collectedAmount, @@ -664,7 +665,8 @@ contract MaxBTCCoreIntegrationTest is Test { vm.prank(USER); core.withdraw(5e7); vm.prank(OPERATOR); - (Batch memory processed, ) = core.tick(); + core.tick(); + Batch memory processed = core.finalizedBatch(0); assertGt(processed.collectedAmount, 0, "processed withdrawal"); }