From d256e0b05e69355dc8c7c6c77c17891982fe5a80 Mon Sep 17 00:00:00 2001 From: Gabriel Speckhahn <749488+gabspeck@users.noreply.github.com> Date: Tue, 28 Oct 2025 17:57:34 +0000 Subject: [PATCH 01/11] wip: ucef for xERC20 --- .gitignore | 4 +- contracts/token/xUCEF.sol | 332 +++++++++++++++++++++++++++++++ contracts/token/xUCEFFactory.sol | 136 +++++++++++++ contracts/token/xUCEFLockbox.sol | 144 ++++++++++++++ package.json | 5 +- pnpm-lock.yaml | 41 ++-- pnpm-workspace.yaml | 4 + 7 files changed, 647 insertions(+), 19 deletions(-) create mode 100644 contracts/token/xUCEF.sol create mode 100644 contracts/token/xUCEFFactory.sol create mode 100644 contracts/token/xUCEFLockbox.sol create mode 100644 pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore index cececed..fbaffa1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ dist .env.test.local .env.production.local -.vscode \ No newline at end of file +.vscode + +.pnpm-store/ \ No newline at end of file diff --git a/contracts/token/xUCEF.sol b/contracts/token/xUCEF.sol new file mode 100644 index 0000000..25e9e57 --- /dev/null +++ b/contracts/token/xUCEF.sol @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IXERC20} from 'xerc20/solidity/interfaces/IXERC20.sol'; +import {UCEF} from './UCEF.sol'; +import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; +import {ERC20Permit} from '@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol'; +import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol'; + + +contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { + /** + * @notice The duration it takes for the limits to fully replenish + */ + uint256 private constant _DURATION = 1 days; + + /** + * @notice The address of the factory which deployed this contract + */ + address public immutable FACTORY; + + /** + * @notice The address of the lockbox contract + */ + address public lockbox; + + /** + * @notice Maps bridge address to bridge configurations + */ + mapping(address => Bridge) public bridges; + + /** + * @notice Constructs the initial config of the XUCEF + * + * @param _name The name of the token + * @param _symbol The symbol of the token + * @param _factory The factory which deployed this contract + */ + constructor(string memory _name, string memory _symbol, address _factory) UCEF(_name, _symbol) ERC20Permit(_name) Ownable(_factory) { + FACTORY = _factory; + } + + /** + * @notice Mints tokens for a user + * @dev Can only be called by a bridge + * @param _user The address of the user who needs tokens minted + * @param _amount The amount of tokens being minted + */ + function mint(address _user, uint256 _amount) public { + _mintWithCaller(msg.sender, _user, _amount); + } + + /** + * @notice Burns tokens for a user + * @dev Can only be called by a bridge + * @param _user The address of the user who needs tokens burned + * @param _amount The amount of tokens being burned + */ + function burn(address _user, uint256 _amount) public { + if (msg.sender != _user) { + _spendAllowance(_user, msg.sender, _amount); + } + + _burnWithCaller(msg.sender, _user, _amount); + } + + /** + * @notice Sets the lockbox address + * + * @param _lockbox The address of the lockbox + */ + function setLockbox(address _lockbox) public { + if (msg.sender != FACTORY) revert IXERC20_NotFactory(); + lockbox = _lockbox; + + emit LockboxSet(_lockbox); + } + + /** + * @notice Updates the limits of any bridge + * @dev Can only be called by the owner + * @param _mintingLimit The updated minting limit we are setting to the bridge + * @param _burningLimit The updated burning limit we are setting to the bridge + * @param _bridge The address of the bridge we are setting the limits too + */ + function setLimits(address _bridge, uint256 _mintingLimit, uint256 _burningLimit) external onlyOwner { + if (_mintingLimit > (type(uint256).max / 2) || _burningLimit > (type(uint256).max / 2)) { + revert IXERC20_LimitsTooHigh(); + } + + _changeMinterLimit(_bridge, _mintingLimit); + _changeBurnerLimit(_bridge, _burningLimit); + emit BridgeLimitsSet(_mintingLimit, _burningLimit, _bridge); + } + + /** + * @notice Returns the max limit of a bridge + * + * @param _bridge the bridge we are viewing the limits of + * @return _limit The limit the bridge has + */ + function mintingMaxLimitOf(address _bridge) public view returns (uint256 _limit) { + _limit = bridges[_bridge].minterParams.maxLimit; + } + + /** + * @notice Returns the max limit of a bridge + * + * @param _bridge the bridge we are viewing the limits of + * @return _limit The limit the bridge has + */ + function burningMaxLimitOf(address _bridge) public view returns (uint256 _limit) { + _limit = bridges[_bridge].burnerParams.maxLimit; + } + + /** + * @notice Returns the current limit of a bridge + * + * @param _bridge the bridge we are viewing the limits of + * @return _limit The limit the bridge has + */ + function mintingCurrentLimitOf(address _bridge) public view returns (uint256 _limit) { + _limit = _getCurrentLimit( + bridges[_bridge].minterParams.currentLimit, + bridges[_bridge].minterParams.maxLimit, + bridges[_bridge].minterParams.timestamp, + bridges[_bridge].minterParams.ratePerSecond + ); + } + + /** + * @notice Returns the current limit of a bridge + * + * @param _bridge the bridge we are viewing the limits of + * @return _limit The limit the bridge has + */ + function burningCurrentLimitOf(address _bridge) public view returns (uint256 _limit) { + _limit = _getCurrentLimit( + bridges[_bridge].burnerParams.currentLimit, + bridges[_bridge].burnerParams.maxLimit, + bridges[_bridge].burnerParams.timestamp, + bridges[_bridge].burnerParams.ratePerSecond + ); + } + + /** + * @notice Uses the limit of any bridge + * @param _bridge The address of the bridge who is being changed + * @param _change The change in the limit + */ + function _useMinterLimits(address _bridge, uint256 _change) internal { + uint256 _currentLimit = mintingCurrentLimitOf(_bridge); + bridges[_bridge].minterParams.timestamp = block.timestamp; + bridges[_bridge].minterParams.currentLimit = _currentLimit - _change; + } + + /** + * @notice Uses the limit of any bridge + * @param _bridge The address of the bridge who is being changed + * @param _change The change in the limit + */ + function _useBurnerLimits(address _bridge, uint256 _change) internal { + uint256 _currentLimit = burningCurrentLimitOf(_bridge); + bridges[_bridge].burnerParams.timestamp = block.timestamp; + bridges[_bridge].burnerParams.currentLimit = _currentLimit - _change; + } + + /** + * @notice Updates the limit of any bridge + * @dev Can only be called by the owner + * @param _bridge The address of the bridge we are setting the limit too + * @param _limit The updated limit we are setting to the bridge + */ + function _changeMinterLimit(address _bridge, uint256 _limit) internal { + uint256 _oldLimit = bridges[_bridge].minterParams.maxLimit; + uint256 _currentLimit = mintingCurrentLimitOf(_bridge); + bridges[_bridge].minterParams.maxLimit = _limit; + + bridges[_bridge].minterParams.currentLimit = _calculateNewCurrentLimit(_limit, _oldLimit, _currentLimit); + + bridges[_bridge].minterParams.ratePerSecond = _limit / _DURATION; + bridges[_bridge].minterParams.timestamp = block.timestamp; + } + + /** + * @notice Updates the limit of any bridge + * @dev Can only be called by the owner + * @param _bridge The address of the bridge we are setting the limit too + * @param _limit The updated limit we are setting to the bridge + */ + function _changeBurnerLimit(address _bridge, uint256 _limit) internal { + uint256 _oldLimit = bridges[_bridge].burnerParams.maxLimit; + uint256 _currentLimit = burningCurrentLimitOf(_bridge); + bridges[_bridge].burnerParams.maxLimit = _limit; + + bridges[_bridge].burnerParams.currentLimit = _calculateNewCurrentLimit(_limit, _oldLimit, _currentLimit); + + bridges[_bridge].burnerParams.ratePerSecond = _limit / _DURATION; + bridges[_bridge].burnerParams.timestamp = block.timestamp; + } + + /** + * @notice Updates the current limit + * + * @param _limit The new limit + * @param _oldLimit The old limit + * @param _currentLimit The current limit + * @return _newCurrentLimit The new current limit + */ + function _calculateNewCurrentLimit( + uint256 _limit, + uint256 _oldLimit, + uint256 _currentLimit + ) internal pure returns (uint256 _newCurrentLimit) { + uint256 _difference; + + if (_oldLimit > _limit) { + _difference = _oldLimit - _limit; + _newCurrentLimit = _currentLimit > _difference ? _currentLimit - _difference : 0; + } else { + _difference = _limit - _oldLimit; + _newCurrentLimit = _currentLimit + _difference; + } + } + + /** + * @notice Gets the current limit + * + * @param _currentLimit The current limit + * @param _maxLimit The max limit + * @param _timestamp The timestamp of the last update + * @param _ratePerSecond The rate per second + * @return _limit The current limit + */ + function _getCurrentLimit( + uint256 _currentLimit, + uint256 _maxLimit, + uint256 _timestamp, + uint256 _ratePerSecond + ) internal view returns (uint256 _limit) { + _limit = _currentLimit; + if (_limit == _maxLimit) { + return _limit; + } else if (_timestamp + _DURATION <= block.timestamp) { + _limit = _maxLimit; + } else if (_timestamp + _DURATION > block.timestamp) { + uint256 _timePassed = block.timestamp - _timestamp; + uint256 _calculatedLimit = _limit + (_timePassed * _ratePerSecond); + _limit = _calculatedLimit > _maxLimit ? _maxLimit : _calculatedLimit; + } + } + + /** + * @notice Internal function for burning tokens + * + * @param _caller The caller address + * @param _user The user address + * @param _amount The amount to burn + */ + function _burnWithCaller(address _caller, address _user, uint256 _amount) internal { + if (_caller != lockbox) { + uint256 _currentLimit = burningCurrentLimitOf(_caller); + if (_currentLimit < _amount) revert IXERC20_NotHighEnoughLimits(); + _useBurnerLimits(_caller, _amount); + } + _burn(_user, _amount); + } + + /** + * @notice Internal function for minting tokens + * + * @param _caller The caller address + * @param _user The user address + * @param _amount The amount to mint + */ + function _mintWithCaller(address _caller, address _user, uint256 _amount) internal { + if (_caller != lockbox) { + uint256 _currentLimit = mintingCurrentLimitOf(_caller); + if (_currentLimit < _amount) revert IXERC20_NotHighEnoughLimits(); + _useMinterLimits(_caller, _amount); + } + _mint(_user, _amount); + } + + function balanceOf(address account) + public + view + override(ERC20, UCEF) + returns (uint256) + { + return UCEF.balanceOf(account); + } + + function totalSupply() + public + view + override(ERC20, UCEF) + returns (uint256) + { + return UCEF.totalSupply(); + } + + function allowance(address owner, address spender) + public + view + override(ERC20, UCEF) + returns (uint256) + { + return UCEF.allowance(owner, spender); + } + + function _update(address from, address to, uint256 value) + internal + override(ERC20, UCEF) + { + UCEF._update(from, to, value); + } + + function _approve(address owner, address spender, uint256 value, bool emitEvent) + internal + override(ERC20, UCEF) + { + UCEF._approve(owner, spender, value, emitEvent); + } + + function _spendAllowance(address owner, address spender, uint256 value) + internal + override(ERC20, UCEF) + { + UCEF._spendAllowance(owner, spender, value); + } +} \ No newline at end of file diff --git a/contracts/token/xUCEFFactory.sol b/contracts/token/xUCEFFactory.sol new file mode 100644 index 0000000..89daf72 --- /dev/null +++ b/contracts/token/xUCEFFactory.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.4 <0.9.0; + +import {XUCEF} from './xUCEF.sol'; +import {IXERC20Factory} from '../interfaces/IXERC20Factory.sol'; +import {XERC20Lockbox} from '../contracts/XERC20Lockbox.sol'; +import {CREATE3} from 'isolmate/utils/CREATE3.sol'; +import {EnumerableSet} from '@openzeppelin/contracts/utils/structs/EnumerableSet.sol'; + +contract XERC20Factory is IXERC20Factory { + using EnumerableSet for EnumerableSet.AddressSet; + + /** + * @notice Address of the xerc20 maps to the address of its lockbox if it has one + */ + mapping(address => address) internal _lockboxRegistry; + + /** + * @notice The set of registered lockboxes + */ + EnumerableSet.AddressSet internal _lockboxRegistryArray; + + /** + * @notice The set of registered XERC20 tokens + */ + EnumerableSet.AddressSet internal _xerc20RegistryArray; + + /** + * @notice Deploys an XERC20 contract using CREATE3 + * @dev _limits and _minters must be the same length + * @param _name The name of the token + * @param _symbol The symbol of the token + * @param _minterLimits The array of limits that you are adding (optional, can be an empty array) + * @param _burnerLimits The array of limits that you are adding (optional, can be an empty array) + * @param _bridges The array of bridges that you are adding (optional, can be an empty array) + * @return _xerc20 The address of the xerc20 + */ + function deployXERC20( + string memory _name, + string memory _symbol, + uint256[] memory _minterLimits, + uint256[] memory _burnerLimits, + address[] memory _bridges + ) external returns (address _xerc20) { + _xerc20 = _deployXERC20(_name, _symbol, _minterLimits, _burnerLimits, _bridges); + + emit XERC20Deployed(_xerc20); + } + + /** + * @notice Deploys an XERC20Lockbox contract using CREATE3 + * + * @dev When deploying a lockbox for the gas token of the chain, then, the base token needs to be address(0) + * @param _xerc20 The address of the xerc20 that you want to deploy a lockbox for + * @param _baseToken The address of the base token that you want to lock + * @param _isNative Whether or not the base token is the native (gas) token of the chain. Eg: MATIC for polygon chain + * @return _lockbox The address of the lockbox + */ + function deployLockbox( + address _xerc20, + address _baseToken, + bool _isNative + ) external returns (address payable _lockbox) { + if ((_baseToken == address(0) && !_isNative) || (_isNative && _baseToken != address(0))) { + revert IXERC20Factory_BadTokenAddress(); + } + + if (XERC20(_xerc20).owner() != msg.sender) revert IXERC20Factory_NotOwner(); + if (_lockboxRegistry[_xerc20] != address(0)) revert IXERC20Factory_LockboxAlreadyDeployed(); + + _lockbox = _deployLockbox(_xerc20, _baseToken, _isNative); + + emit LockboxDeployed(_lockbox); + } + + /** + * @notice Deploys an XERC20 contract using CREATE3 + * @dev _limits and _minters must be the same length + * @param _name The name of the token + * @param _symbol The symbol of the token + * @param _minterLimits The array of limits that you are adding (optional, can be an empty array) + * @param _burnerLimits The array of limits that you are adding (optional, can be an empty array) + * @param _bridges The array of burners that you are adding (optional, can be an empty array) + * @return _xerc20 The address of the xerc20 + */ + function _deployXERC20( + string memory _name, + string memory _symbol, + uint256[] memory _minterLimits, + uint256[] memory _burnerLimits, + address[] memory _bridges + ) internal returns (address _xerc20) { + uint256 _bridgesLength = _bridges.length; + if (_minterLimits.length != _bridgesLength || _burnerLimits.length != _bridgesLength) { + revert IXERC20Factory_InvalidLength(); + } + bytes32 _salt = keccak256(abi.encodePacked(_name, _symbol, msg.sender)); + bytes memory _creation = type(XERC20).creationCode; + bytes memory _bytecode = abi.encodePacked(_creation, abi.encode(_name, _symbol, address(this))); + + _xerc20 = CREATE3.deploy(_salt, _bytecode, 0); + + EnumerableSet.add(_xerc20RegistryArray, _xerc20); + + for (uint256 _i; _i < _bridgesLength; ++_i) { + XERC20(_xerc20).setLimits(_bridges[_i], _minterLimits[_i], _burnerLimits[_i]); + } + + XERC20(_xerc20).transferOwnership(msg.sender); + } + + /** + * @notice Deploys an XERC20Lockbox contract using CREATE3 + * + * @dev When deploying a lockbox for the gas token of the chain, then, the base token needs to be address(0) + * @param _xerc20 The address of the xerc20 that you want to deploy a lockbox for + * @param _baseToken The address of the base token that you want to lock + * @param _isNative Whether or not the base token is the native (gas) token of the chain. Eg: MATIC for polygon chain + * @return _lockbox The address of the lockbox + */ + function _deployLockbox( + address _xerc20, + address _baseToken, + bool _isNative + ) internal returns (address payable _lockbox) { + bytes32 _salt = keccak256(abi.encodePacked(_xerc20, _baseToken, msg.sender)); + bytes memory _creation = type(XERC20Lockbox).creationCode; + bytes memory _bytecode = abi.encodePacked(_creation, abi.encode(_xerc20, _baseToken, _isNative)); + + _lockbox = payable(CREATE3.deploy(_salt, _bytecode, 0)); + + XERC20(_xerc20).setLockbox(address(_lockbox)); + EnumerableSet.add(_lockboxRegistryArray, _lockbox); + _lockboxRegistry[_xerc20] = _lockbox; + } +} \ No newline at end of file diff --git a/contracts/token/xUCEFLockbox.sol b/contracts/token/xUCEFLockbox.sol new file mode 100644 index 0000000..a59fc6c --- /dev/null +++ b/contracts/token/xUCEFLockbox.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.4 <0.9.0; + +import {IXERC20} from 'xerc20/solidity/interfaces/IXERC20.sol'; +import {IERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; +import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; +import {SafeCast} from '@openzeppelin/contracts/utils/math/SafeCast.sol'; +import {IXERC20Lockbox} from 'xerc20/solidity/interfaces/IXERC20Lockbox.sol'; + +contract XERC20Lockbox is IXERC20Lockbox { + using SafeERC20 for IERC20; + using SafeCast for uint256; + + /** + * @notice The XERC20 token of this contract + */ + IXERC20 public immutable XERC20; + + /** + * @notice The ERC20 token of this contract + */ + IERC20 public immutable ERC20; + + /** + * @notice Whether the ERC20 token is the native gas token of this chain + */ + bool public immutable IS_NATIVE; + + /** + * @notice Constructor + * + * @param _xerc20 The address of the XERC20 contract + * @param _erc20 The address of the ERC20 contract + * @param _isNative Whether the ERC20 token is the native gas token of this chain or not + */ + constructor(address _xerc20, address _erc20, bool _isNative) { + XERC20 = IXERC20(_xerc20); + ERC20 = IERC20(_erc20); + IS_NATIVE = _isNative; + } + + /** + * @notice Deposit native tokens into the lockbox + */ + function depositNative() public payable { + if (!IS_NATIVE) revert IXERC20Lockbox_NotNative(); + + _deposit(msg.sender, msg.value); + } + + /** + * @notice Deposit ERC20 tokens into the lockbox + * + * @param _amount The amount of tokens to deposit + */ + function deposit(uint256 _amount) external { + if (IS_NATIVE) revert IXERC20Lockbox_Native(); + + _deposit(msg.sender, _amount); + } + + /** + * @notice Deposit ERC20 tokens into the lockbox, and send the XERC20 to a user + * + * @param _to The user to send the XERC20 to + * @param _amount The amount of tokens to deposit + */ + function depositTo(address _to, uint256 _amount) external { + if (IS_NATIVE) revert IXERC20Lockbox_Native(); + + _deposit(_to, _amount); + } + + /** + * @notice Deposit the native asset into the lockbox, and send the XERC20 to a user + * + * @param _to The user to send the XERC20 to + */ + function depositNativeTo(address _to) public payable { + if (!IS_NATIVE) revert IXERC20Lockbox_NotNative(); + + _deposit(_to, msg.value); + } + + /** + * @notice Withdraw ERC20 tokens from the lockbox + * + * @param _amount The amount of tokens to withdraw + */ + function withdraw(uint256 _amount) external { + _withdraw(msg.sender, _amount); + } + + /** + * @notice Withdraw tokens from the lockbox + * + * @param _to The user to withdraw to + * @param _amount The amount of tokens to withdraw + */ + function withdrawTo(address _to, uint256 _amount) external { + _withdraw(_to, _amount); + } + + /** + * @notice Withdraw tokens from the lockbox + * + * @param _to The user to withdraw to + * @param _amount The amount of tokens to withdraw + */ + function _withdraw(address _to, uint256 _amount) internal { + emit Withdraw(_to, _amount); + + XERC20.burn(msg.sender, _amount); + + if (IS_NATIVE) { + (bool _success,) = payable(_to).call{value: _amount}(''); + if (!_success) revert IXERC20Lockbox_WithdrawFailed(); + } else { + ERC20.safeTransfer(_to, _amount); + } + } + + /** + * @notice Deposit tokens into the lockbox + * + * @param _to The address to send the XERC20 to + * @param _amount The amount of tokens to deposit + */ + function _deposit(address _to, uint256 _amount) internal { + if (!IS_NATIVE) { + ERC20.safeTransferFrom(msg.sender, address(this), _amount); + } + + XERC20.mint(_to, _amount); + emit Deposit(_to, _amount); + } + + /** + * @notice Fallback function to deposit native tokens + */ + receive() external payable { + depositNative(); + } +} \ No newline at end of file diff --git a/package.json b/package.json index aab6faa..c659f61 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,9 @@ "lint:fix": "pnpm lint --fix" }, "devDependencies": { - "@appliedblockchain/silentdatarollup-core": "^0.1.3", - "@appliedblockchain/silentdatarollup-hardhat-plugin": "^0.1.3", + "xerc20": "https://github.com/defi-wonderland/xERC20/archive/refs/tags/v1.0.0.tar.gz", + "@appliedblockchain/silentdatarollup-core": "^1.0.8", + "@appliedblockchain/silentdatarollup-hardhat-plugin": "^1.0.8", "@nomicfoundation/hardhat-chai-matchers": "^2.0.0", "@nomicfoundation/hardhat-ethers": "^3.0.0", "@nomicfoundation/hardhat-ignition": "^0.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42a198e..211b0e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: devDependencies: '@appliedblockchain/silentdatarollup-core': - specifier: ^0.1.3 - version: 0.1.3 + specifier: ^1.0.8 + version: 1.0.8 '@appliedblockchain/silentdatarollup-hardhat-plugin': - specifier: ^0.1.3 - version: 0.1.3(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)) + specifier: ^1.0.8 + version: 1.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)) '@nomicfoundation/hardhat-chai-matchers': specifier: ^2.0.0 version: 2.0.8(@nomicfoundation/hardhat-ethers@3.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(chai@4.5.0)(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)) @@ -28,7 +28,7 @@ importers: version: 1.0.12(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)) '@nomicfoundation/hardhat-toolbox': specifier: ^5.0.0 - version: 5.0.0(@nomicfoundation/hardhat-chai-matchers@2.0.8(@nomicfoundation/hardhat-ethers@3.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(chai@4.5.0)(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/hardhat-ethers@3.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/hardhat-ignition-ethers@0.15.10(@nomicfoundation/hardhat-ethers@3.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/hardhat-ignition@0.15.10(@nomicfoundation/hardhat-verify@2.0.13(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/ignition-core@0.15.10)(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/hardhat-network-helpers@1.0.12(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/hardhat-verify@2.0.13(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@typechain/ethers-v6@0.5.1(ethers@6.13.5)(typechain@8.3.2(typescript@5.8.2))(typescript@5.8.2))(@typechain/hardhat@9.1.0(@typechain/ethers-v6@0.5.1(ethers@6.13.5)(typechain@8.3.2(typescript@5.8.2))(typescript@5.8.2))(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2))(typechain@8.3.2(typescript@5.8.2)))(@types/chai@4.3.20)(@types/mocha@10.0.10)(@types/node@20.17.24)(chai@4.5.0)(ethers@6.13.5)(hardhat-gas-reporter@1.0.10(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2))(solidity-coverage@0.8.14(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typechain@8.3.2(typescript@5.8.2))(typescript@5.8.2) + version: 5.0.0(ad4fc14dc89174d5a4cc7ad8da7a81fc) '@nomicfoundation/hardhat-verify': specifier: ^2.0.0 version: 2.0.13(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)) @@ -101,22 +101,25 @@ importers: typescript: specifier: ^5.0.0 version: 5.8.2 + xerc20: + specifier: https://github.com/defi-wonderland/xERC20/archive/refs/tags/v1.0.0.tar.gz + version: https://github.com/defi-wonderland/xERC20/archive/refs/tags/v1.0.0.tar.gz packages: '@adraffy/ens-normalize@1.10.1': resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} - '@appliedblockchain/silentdatarollup-core@0.1.3': - resolution: {integrity: sha512-bP/BeIk322eR3C54+pvpxfbJOhLPHDGX6M8qdgYqBvYkWYiz7oVwn3QnxnGbLnrYjA/k8Wwa9mC8RXBQkvjfjw==, tarball: https://npm.pkg.github.com/download/@appliedblockchain/silentdatarollup-core/0.1.3/668af20792e9536693a2101256cd99dfdcdd6e73} + '@appliedblockchain/silentdatarollup-core@1.0.8': + resolution: {integrity: sha512-+F4eZqqj/h7rNuX3AvKzk6apg9HROITybxp+yRXKcwR9t3e0gYgXF947cz8nWiS0xD1PX7u7QfjGdiWlBBpiyA==} engines: {node: '>=18.0.0'} - '@appliedblockchain/silentdatarollup-hardhat-plugin@0.1.3': - resolution: {integrity: sha512-EmV1x4QHo9Yd8/nX+6rCBXt0haGzsuvMtEJfrerNNencOpQfZUirEH9t+Jr1Kvl/Py+WKt7/j7fka/eGwAPNoA==, tarball: https://npm.pkg.github.com/download/@appliedblockchain/silentdatarollup-hardhat-plugin/0.1.3/5f055fd89d8c86a4ee9aad8fa8764f7c50c1881b} + '@appliedblockchain/silentdatarollup-hardhat-plugin@1.0.8': + resolution: {integrity: sha512-8PlIqU/5IBp673oDiympzJ0ntySSgQBoNbQkr90BmSDqOPTDYBYxnnSv73LBY1X+aU1VjLIjOP5PLc1GYa5Qhw==} engines: {node: '>=18.0.0'} peerDependencies: - ethers: ^6.13.2 - hardhat: ^2.22.10 + ethers: ^6.0.0 + hardhat: ^2.0.0 '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -2500,6 +2503,10 @@ packages: utf-8-validate: optional: true + xerc20@https://github.com/defi-wonderland/xERC20/archive/refs/tags/v1.0.0.tar.gz: + resolution: {tarball: https://github.com/defi-wonderland/xERC20/archive/refs/tags/v1.0.0.tar.gz} + version: 1.0.1 + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2528,7 +2535,7 @@ snapshots: '@adraffy/ens-normalize@1.10.1': {} - '@appliedblockchain/silentdatarollup-core@0.1.3': + '@appliedblockchain/silentdatarollup-core@1.0.8': dependencies: debug: 4.3.7 ethers: 6.13.2 @@ -2537,9 +2544,9 @@ snapshots: - supports-color - utf-8-validate - '@appliedblockchain/silentdatarollup-hardhat-plugin@0.1.3(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2))': + '@appliedblockchain/silentdatarollup-hardhat-plugin@1.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2))': dependencies: - '@appliedblockchain/silentdatarollup-core': 0.1.3 + '@appliedblockchain/silentdatarollup-core': 1.0.8 '@nomicfoundation/hardhat-ethers': 3.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)) debug: 4.3.7 ethers: 6.13.5 @@ -3023,8 +3030,8 @@ snapshots: ethereumjs-util: 7.1.5 hardhat: 2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2) - ? '@nomicfoundation/hardhat-toolbox@5.0.0(@nomicfoundation/hardhat-chai-matchers@2.0.8(@nomicfoundation/hardhat-ethers@3.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(chai@4.5.0)(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/hardhat-ethers@3.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/hardhat-ignition-ethers@0.15.10(@nomicfoundation/hardhat-ethers@3.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/hardhat-ignition@0.15.10(@nomicfoundation/hardhat-verify@2.0.13(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/ignition-core@0.15.10)(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/hardhat-network-helpers@1.0.12(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/hardhat-verify@2.0.13(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@typechain/ethers-v6@0.5.1(ethers@6.13.5)(typechain@8.3.2(typescript@5.8.2))(typescript@5.8.2))(@typechain/hardhat@9.1.0(@typechain/ethers-v6@0.5.1(ethers@6.13.5)(typechain@8.3.2(typescript@5.8.2))(typescript@5.8.2))(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2))(typechain@8.3.2(typescript@5.8.2)))(@types/chai@4.3.20)(@types/mocha@10.0.10)(@types/node@20.17.24)(chai@4.5.0)(ethers@6.13.5)(hardhat-gas-reporter@1.0.10(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2))(solidity-coverage@0.8.14(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typechain@8.3.2(typescript@5.8.2))(typescript@5.8.2)' - : dependencies: + '@nomicfoundation/hardhat-toolbox@5.0.0(ad4fc14dc89174d5a4cc7ad8da7a81fc)': + dependencies: '@nomicfoundation/hardhat-chai-matchers': 2.0.8(@nomicfoundation/hardhat-ethers@3.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(chai@4.5.0)(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)) '@nomicfoundation/hardhat-ethers': 3.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)) '@nomicfoundation/hardhat-ignition-ethers': 0.15.10(@nomicfoundation/hardhat-ethers@3.0.8(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/hardhat-ignition@0.15.10(@nomicfoundation/hardhat-verify@2.0.13(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)))(@nomicfoundation/ignition-core@0.15.10)(ethers@6.13.5)(hardhat@2.22.19(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2))(typescript@5.8.2)) @@ -5322,6 +5329,8 @@ snapshots: ws@8.18.0: {} + xerc20@https://github.com/defi-wonderland/xERC20/archive/refs/tags/v1.0.0.tar.gz: {} + y18n@5.0.8: {} yargs-parser@20.2.9: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..2afbabd --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +onlyBuiltDependencies: + - keccak + - secp256k1 + - xerc20 From 14cd8893324456b424bc615aab8f8c2464c90dee Mon Sep 17 00:00:00 2001 From: Gabriel Speckhahn <749488+gabspeck@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:48:18 +0000 Subject: [PATCH 02/11] remove unnecessary factory and lockbox contracts --- contracts/token/xUCEFFactory.sol | 136 ----------------------------- contracts/token/xUCEFLockbox.sol | 144 ------------------------------- 2 files changed, 280 deletions(-) delete mode 100644 contracts/token/xUCEFFactory.sol delete mode 100644 contracts/token/xUCEFLockbox.sol diff --git a/contracts/token/xUCEFFactory.sol b/contracts/token/xUCEFFactory.sol deleted file mode 100644 index 89daf72..0000000 --- a/contracts/token/xUCEFFactory.sol +++ /dev/null @@ -1,136 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.4 <0.9.0; - -import {XUCEF} from './xUCEF.sol'; -import {IXERC20Factory} from '../interfaces/IXERC20Factory.sol'; -import {XERC20Lockbox} from '../contracts/XERC20Lockbox.sol'; -import {CREATE3} from 'isolmate/utils/CREATE3.sol'; -import {EnumerableSet} from '@openzeppelin/contracts/utils/structs/EnumerableSet.sol'; - -contract XERC20Factory is IXERC20Factory { - using EnumerableSet for EnumerableSet.AddressSet; - - /** - * @notice Address of the xerc20 maps to the address of its lockbox if it has one - */ - mapping(address => address) internal _lockboxRegistry; - - /** - * @notice The set of registered lockboxes - */ - EnumerableSet.AddressSet internal _lockboxRegistryArray; - - /** - * @notice The set of registered XERC20 tokens - */ - EnumerableSet.AddressSet internal _xerc20RegistryArray; - - /** - * @notice Deploys an XERC20 contract using CREATE3 - * @dev _limits and _minters must be the same length - * @param _name The name of the token - * @param _symbol The symbol of the token - * @param _minterLimits The array of limits that you are adding (optional, can be an empty array) - * @param _burnerLimits The array of limits that you are adding (optional, can be an empty array) - * @param _bridges The array of bridges that you are adding (optional, can be an empty array) - * @return _xerc20 The address of the xerc20 - */ - function deployXERC20( - string memory _name, - string memory _symbol, - uint256[] memory _minterLimits, - uint256[] memory _burnerLimits, - address[] memory _bridges - ) external returns (address _xerc20) { - _xerc20 = _deployXERC20(_name, _symbol, _minterLimits, _burnerLimits, _bridges); - - emit XERC20Deployed(_xerc20); - } - - /** - * @notice Deploys an XERC20Lockbox contract using CREATE3 - * - * @dev When deploying a lockbox for the gas token of the chain, then, the base token needs to be address(0) - * @param _xerc20 The address of the xerc20 that you want to deploy a lockbox for - * @param _baseToken The address of the base token that you want to lock - * @param _isNative Whether or not the base token is the native (gas) token of the chain. Eg: MATIC for polygon chain - * @return _lockbox The address of the lockbox - */ - function deployLockbox( - address _xerc20, - address _baseToken, - bool _isNative - ) external returns (address payable _lockbox) { - if ((_baseToken == address(0) && !_isNative) || (_isNative && _baseToken != address(0))) { - revert IXERC20Factory_BadTokenAddress(); - } - - if (XERC20(_xerc20).owner() != msg.sender) revert IXERC20Factory_NotOwner(); - if (_lockboxRegistry[_xerc20] != address(0)) revert IXERC20Factory_LockboxAlreadyDeployed(); - - _lockbox = _deployLockbox(_xerc20, _baseToken, _isNative); - - emit LockboxDeployed(_lockbox); - } - - /** - * @notice Deploys an XERC20 contract using CREATE3 - * @dev _limits and _minters must be the same length - * @param _name The name of the token - * @param _symbol The symbol of the token - * @param _minterLimits The array of limits that you are adding (optional, can be an empty array) - * @param _burnerLimits The array of limits that you are adding (optional, can be an empty array) - * @param _bridges The array of burners that you are adding (optional, can be an empty array) - * @return _xerc20 The address of the xerc20 - */ - function _deployXERC20( - string memory _name, - string memory _symbol, - uint256[] memory _minterLimits, - uint256[] memory _burnerLimits, - address[] memory _bridges - ) internal returns (address _xerc20) { - uint256 _bridgesLength = _bridges.length; - if (_minterLimits.length != _bridgesLength || _burnerLimits.length != _bridgesLength) { - revert IXERC20Factory_InvalidLength(); - } - bytes32 _salt = keccak256(abi.encodePacked(_name, _symbol, msg.sender)); - bytes memory _creation = type(XERC20).creationCode; - bytes memory _bytecode = abi.encodePacked(_creation, abi.encode(_name, _symbol, address(this))); - - _xerc20 = CREATE3.deploy(_salt, _bytecode, 0); - - EnumerableSet.add(_xerc20RegistryArray, _xerc20); - - for (uint256 _i; _i < _bridgesLength; ++_i) { - XERC20(_xerc20).setLimits(_bridges[_i], _minterLimits[_i], _burnerLimits[_i]); - } - - XERC20(_xerc20).transferOwnership(msg.sender); - } - - /** - * @notice Deploys an XERC20Lockbox contract using CREATE3 - * - * @dev When deploying a lockbox for the gas token of the chain, then, the base token needs to be address(0) - * @param _xerc20 The address of the xerc20 that you want to deploy a lockbox for - * @param _baseToken The address of the base token that you want to lock - * @param _isNative Whether or not the base token is the native (gas) token of the chain. Eg: MATIC for polygon chain - * @return _lockbox The address of the lockbox - */ - function _deployLockbox( - address _xerc20, - address _baseToken, - bool _isNative - ) internal returns (address payable _lockbox) { - bytes32 _salt = keccak256(abi.encodePacked(_xerc20, _baseToken, msg.sender)); - bytes memory _creation = type(XERC20Lockbox).creationCode; - bytes memory _bytecode = abi.encodePacked(_creation, abi.encode(_xerc20, _baseToken, _isNative)); - - _lockbox = payable(CREATE3.deploy(_salt, _bytecode, 0)); - - XERC20(_xerc20).setLockbox(address(_lockbox)); - EnumerableSet.add(_lockboxRegistryArray, _lockbox); - _lockboxRegistry[_xerc20] = _lockbox; - } -} \ No newline at end of file diff --git a/contracts/token/xUCEFLockbox.sol b/contracts/token/xUCEFLockbox.sol deleted file mode 100644 index a59fc6c..0000000 --- a/contracts/token/xUCEFLockbox.sol +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.4 <0.9.0; - -import {IXERC20} from 'xerc20/solidity/interfaces/IXERC20.sol'; -import {IERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; -import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; -import {SafeCast} from '@openzeppelin/contracts/utils/math/SafeCast.sol'; -import {IXERC20Lockbox} from 'xerc20/solidity/interfaces/IXERC20Lockbox.sol'; - -contract XERC20Lockbox is IXERC20Lockbox { - using SafeERC20 for IERC20; - using SafeCast for uint256; - - /** - * @notice The XERC20 token of this contract - */ - IXERC20 public immutable XERC20; - - /** - * @notice The ERC20 token of this contract - */ - IERC20 public immutable ERC20; - - /** - * @notice Whether the ERC20 token is the native gas token of this chain - */ - bool public immutable IS_NATIVE; - - /** - * @notice Constructor - * - * @param _xerc20 The address of the XERC20 contract - * @param _erc20 The address of the ERC20 contract - * @param _isNative Whether the ERC20 token is the native gas token of this chain or not - */ - constructor(address _xerc20, address _erc20, bool _isNative) { - XERC20 = IXERC20(_xerc20); - ERC20 = IERC20(_erc20); - IS_NATIVE = _isNative; - } - - /** - * @notice Deposit native tokens into the lockbox - */ - function depositNative() public payable { - if (!IS_NATIVE) revert IXERC20Lockbox_NotNative(); - - _deposit(msg.sender, msg.value); - } - - /** - * @notice Deposit ERC20 tokens into the lockbox - * - * @param _amount The amount of tokens to deposit - */ - function deposit(uint256 _amount) external { - if (IS_NATIVE) revert IXERC20Lockbox_Native(); - - _deposit(msg.sender, _amount); - } - - /** - * @notice Deposit ERC20 tokens into the lockbox, and send the XERC20 to a user - * - * @param _to The user to send the XERC20 to - * @param _amount The amount of tokens to deposit - */ - function depositTo(address _to, uint256 _amount) external { - if (IS_NATIVE) revert IXERC20Lockbox_Native(); - - _deposit(_to, _amount); - } - - /** - * @notice Deposit the native asset into the lockbox, and send the XERC20 to a user - * - * @param _to The user to send the XERC20 to - */ - function depositNativeTo(address _to) public payable { - if (!IS_NATIVE) revert IXERC20Lockbox_NotNative(); - - _deposit(_to, msg.value); - } - - /** - * @notice Withdraw ERC20 tokens from the lockbox - * - * @param _amount The amount of tokens to withdraw - */ - function withdraw(uint256 _amount) external { - _withdraw(msg.sender, _amount); - } - - /** - * @notice Withdraw tokens from the lockbox - * - * @param _to The user to withdraw to - * @param _amount The amount of tokens to withdraw - */ - function withdrawTo(address _to, uint256 _amount) external { - _withdraw(_to, _amount); - } - - /** - * @notice Withdraw tokens from the lockbox - * - * @param _to The user to withdraw to - * @param _amount The amount of tokens to withdraw - */ - function _withdraw(address _to, uint256 _amount) internal { - emit Withdraw(_to, _amount); - - XERC20.burn(msg.sender, _amount); - - if (IS_NATIVE) { - (bool _success,) = payable(_to).call{value: _amount}(''); - if (!_success) revert IXERC20Lockbox_WithdrawFailed(); - } else { - ERC20.safeTransfer(_to, _amount); - } - } - - /** - * @notice Deposit tokens into the lockbox - * - * @param _to The address to send the XERC20 to - * @param _amount The amount of tokens to deposit - */ - function _deposit(address _to, uint256 _amount) internal { - if (!IS_NATIVE) { - ERC20.safeTransferFrom(msg.sender, address(this), _amount); - } - - XERC20.mint(_to, _amount); - emit Deposit(_to, _amount); - } - - /** - * @notice Fallback function to deposit native tokens - */ - receive() external payable { - depositNative(); - } -} \ No newline at end of file From 9e29c2795b77f2ba48314a0cc26d0d855e9f58d1 Mon Sep 17 00:00:00 2001 From: Gabriel Speckhahn <749488+gabspeck@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:49:06 +0000 Subject: [PATCH 03/11] remove create3 dependency used by deleted contracts and improve deploy module script to make usage of create2 strategy possible --- .env.example | 1 + hardhat.config.ts | 7 +++++++ package.json | 6 +++--- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 2756548..21a98c0 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,4 @@ PRIVATE_KEY= # Deployer account (contract owner) SILENTDATA_RPC_URL= SILENTDATA_CHAIN_ID= +CREATE2_SALT=0xab5d000000000000000000000000000000000000000000000000000000000000 diff --git a/hardhat.config.ts b/hardhat.config.ts index c8fc6cb..7ecad13 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -18,6 +18,13 @@ const config: HardhatUserConfig = { }, }, }, + ignition: { + strategyConfig: { + create2: { + salt: process.env.CREATE2_SALT ?? '0xab5d000000000000000000000000000000000000000000000000000000000000' + } + } + }, networks: { localhost: { url: 'http://127.0.0.1:8545', diff --git a/package.json b/package.json index c659f61..bf06888 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "coverage": "npx hardhat coverage", "clean:ignition": "rm -rf .ignition && rm -rf ./ignition/deployments", "clean:all": "pnpm clean && pnpm clean:ignition", - "deploy:module": "deploy() { npx hardhat ignition deploy ignition/modules/$1.ts --network ${2:-localhost} --deployment-id ${2:-localhost} --reset; }; deploy $@", + "deploy:module": "deploy() { npx hardhat ignition deploy ignition/modules/$1.ts --network ${2:-localhost} --deployment-id ${3:-localhost} --strategy ${4:-basic} --reset; }; deploy $@", "clean": "npx hardhat clean", "format": "prettier --write \"**/*.{js,jsx,ts,tsx}\" --ignore-path .prettierignore", "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx}\" --ignore-path .prettierignore", @@ -17,7 +17,6 @@ "lint:fix": "pnpm lint --fix" }, "devDependencies": { - "xerc20": "https://github.com/defi-wonderland/xERC20/archive/refs/tags/v1.0.0.tar.gz", "@appliedblockchain/silentdatarollup-core": "^1.0.8", "@appliedblockchain/silentdatarollup-hardhat-plugin": "^1.0.8", "@nomicfoundation/hardhat-chai-matchers": "^2.0.0", @@ -48,6 +47,7 @@ "solidity-coverage": "^0.8.1", "ts-node": "^10.9.2", "typechain": "^8.3.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "xerc20": "https://github.com/defi-wonderland/xERC20/archive/refs/tags/v1.0.0.tar.gz" } } From 49d135aaca53096b9da2ae1c6744f67e7b6fba55 Mon Sep 17 00:00:00 2001 From: Gabriel Speckhahn <749488+gabspeck@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:49:43 +0000 Subject: [PATCH 04/11] add XUCEF ignition module --- .gitignore | 5 ++++- ignition/modules/XUCEF.ts | 13 +++++++++++++ ignition/parameters/XUCEF.json.example | 4 ++++ 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 ignition/modules/XUCEF.ts create mode 100644 ignition/parameters/XUCEF.json.example diff --git a/.gitignore b/.gitignore index fbaffa1..6154a6d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,7 @@ dist .vscode -.pnpm-store/ \ No newline at end of file +.pnpm-store/ + +ignition/parameters/* +!ignition/parameters/*.example diff --git a/ignition/modules/XUCEF.ts b/ignition/modules/XUCEF.ts new file mode 100644 index 0000000..fa53b98 --- /dev/null +++ b/ignition/modules/XUCEF.ts @@ -0,0 +1,13 @@ +import { buildModule } from '@nomicfoundation/hardhat-ignition/modules' + +export default buildModule('XUCEFModule', (m) => { + const name = m.getParameter('name', 'XUCEF') + const symbol = m.getParameter('symbol', 'XUCEF') + const deployer = m.getAccount(0) + + const xucef = m.contract('XUCEF', [name, symbol, deployer], { + id: 'XUCEF', + }) + + return { xucef } +}) diff --git a/ignition/parameters/XUCEF.json.example b/ignition/parameters/XUCEF.json.example new file mode 100644 index 0000000..d36de19 --- /dev/null +++ b/ignition/parameters/XUCEF.json.example @@ -0,0 +1,4 @@ +{ + "name": "xUSDC", + "symbol": "XUSDC" +} \ No newline at end of file From 9ad1be48abf7430152e54066872982212096eb82 Mon Sep 17 00:00:00 2001 From: Gabriel Speckhahn <749488+gabspeck@users.noreply.github.com> Date: Fri, 31 Oct 2025 09:52:07 +0000 Subject: [PATCH 05/11] add NatSpec to XUCEF contract --- contracts/token/xUCEF.sol | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/contracts/token/xUCEF.sol b/contracts/token/xUCEF.sol index 25e9e57..f8a2bf4 100644 --- a/contracts/token/xUCEF.sol +++ b/contracts/token/xUCEF.sol @@ -8,6 +8,13 @@ import {ERC20Permit} from '@openzeppelin/contracts/token/ERC20/extensions/ERC20P import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol'; +/** + * @title XUCEF + * @notice Cross-chain extension of `UCEF` that supports rate-limited minting and burning by bridges. + * @dev Implements `IXERC20` semantics. The owner (set to the `FACTORY`) manages per-bridge + * minting and burning limits which refill linearly over `_DURATION`. A `lockbox` may + * bypass limits for custodial operations. + */ contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { /** * @notice The duration it takes for the limits to fully replenish @@ -77,11 +84,11 @@ contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { } /** - * @notice Updates the limits of any bridge - * @dev Can only be called by the owner - * @param _mintingLimit The updated minting limit we are setting to the bridge - * @param _burningLimit The updated burning limit we are setting to the bridge - * @param _bridge The address of the bridge we are setting the limits too + * @notice Updates the minting and burning limits of a bridge + * @dev Only callable by the owner. Reverts with `IXERC20_LimitsTooHigh` if a limit exceeds `type(uint256).max / 2`. + * @param _bridge The bridge address whose limits are being set + * @param _mintingLimit The new minting max limit for the bridge + * @param _burningLimit The new burning max limit for the bridge */ function setLimits(address _bridge, uint256 _mintingLimit, uint256 _burningLimit) external onlyOwner { if (_mintingLimit > (type(uint256).max / 2) || _burningLimit > (type(uint256).max / 2)) { @@ -288,6 +295,7 @@ contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { override(ERC20, UCEF) returns (uint256) { + /// @inheritdoc UCEF return UCEF.balanceOf(account); } @@ -297,6 +305,7 @@ contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { override(ERC20, UCEF) returns (uint256) { + /// @inheritdoc UCEF return UCEF.totalSupply(); } @@ -306,6 +315,7 @@ contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { override(ERC20, UCEF) returns (uint256) { + /// @inheritdoc UCEF return UCEF.allowance(owner, spender); } @@ -313,6 +323,7 @@ contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { internal override(ERC20, UCEF) { + /// @inheritdoc UCEF UCEF._update(from, to, value); } @@ -320,6 +331,7 @@ contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { internal override(ERC20, UCEF) { + /// @inheritdoc UCEF UCEF._approve(owner, spender, value, emitEvent); } @@ -327,6 +339,7 @@ contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { internal override(ERC20, UCEF) { + /// @inheritdoc UCEF UCEF._spendAllowance(owner, spender, value); } } \ No newline at end of file From cc870d9d9aa50eba431e856aa55005fc92ca2c37 Mon Sep 17 00:00:00 2001 From: Gabriel Speckhahn <749488+gabspeck@users.noreply.github.com> Date: Fri, 31 Oct 2025 10:22:23 +0000 Subject: [PATCH 06/11] fix: remove accidental default implementation of _authorizeBalance from UCEF.sol --- contracts/token/UCEF.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/token/UCEF.sol b/contracts/token/UCEF.sol index b295c0e..1bab776 100644 --- a/contracts/token/UCEF.sol +++ b/contracts/token/UCEF.sol @@ -62,7 +62,7 @@ abstract contract UCEF is ERC20 { * @custom:security Implementing contracts should carefully consider their authorization logic * as it directly impacts the privacy of user balances */ - function _authorizeBalance(address account) internal view virtual returns (bool) {} + function _authorizeBalance(address account) internal view virtual returns (bool); /** * @dev Returns the balance of the specified account if authorized From a832447c45bfefdc3dce216d943add1c13fec1e2 Mon Sep 17 00:00:00 2001 From: Gabriel Speckhahn <749488+gabspeck@users.noreply.github.com> Date: Fri, 31 Oct 2025 10:22:55 +0000 Subject: [PATCH 07/11] proper implementation of _authorizeBalance in XUCEF.sol and test suite --- contracts/token/XUCEF.sol | 334 ++++++++++++++++++++++++++++++++++++ contracts/token/xUCEF.sol | 345 -------------------------------------- test/token/XUCEF.test.ts | 313 ++++++++++++++++++++++++++++++++++ 3 files changed, 647 insertions(+), 345 deletions(-) create mode 100644 contracts/token/XUCEF.sol delete mode 100644 contracts/token/xUCEF.sol create mode 100644 test/token/XUCEF.test.ts diff --git a/contracts/token/XUCEF.sol b/contracts/token/XUCEF.sol new file mode 100644 index 0000000..3bdfbee --- /dev/null +++ b/contracts/token/XUCEF.sol @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IXERC20} from 'xerc20/solidity/interfaces/IXERC20.sol'; +import {UCEF} from './UCEF.sol'; +import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; +import {ERC20Permit} from '@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol'; +import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol'; + +/** + * @title XUCEF + * @notice Cross-chain extension of `UCEF` that supports rate-limited minting and burning by bridges. + * @dev Implements `IXERC20` semantics. The owner (set to the `FACTORY`) manages per-bridge + * minting and burning limits which refill linearly over `_DURATION`. A `lockbox` may + * bypass limits for custodial operations. + */ +contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { + /** + * @notice The duration it takes for the limits to fully replenish + */ + uint256 private constant _DURATION = 1 days; + + /** + * @notice The address of the factory which deployed this contract + */ + address public immutable FACTORY; + + /** + * @notice The address of the lockbox contract + */ + address public lockbox; + + /** + * @notice Maps bridge address to bridge configurations + */ + mapping(address => Bridge) public bridges; + + /** + * @notice Constructs the initial config of the XUCEF + * + * @param _name The name of the token + * @param _symbol The symbol of the token + * @param _factory The factory which deployed this contract + */ + constructor( + string memory _name, + string memory _symbol, + address _factory + ) UCEF(_name, _symbol) ERC20Permit(_name) Ownable(_factory) { + FACTORY = _factory; + } + + /** + * @notice Mints tokens for a user + * @dev Can only be called by a bridge + * @param _user The address of the user who needs tokens minted + * @param _amount The amount of tokens being minted + */ + function mint(address _user, uint256 _amount) public { + _mintWithCaller(msg.sender, _user, _amount); + } + + /** + * @notice Burns tokens for a user + * @dev Can only be called by a bridge + * @param _user The address of the user who needs tokens burned + * @param _amount The amount of tokens being burned + */ + function burn(address _user, uint256 _amount) public { + if (msg.sender != _user) { + _spendAllowance(_user, msg.sender, _amount); + } + + _burnWithCaller(msg.sender, _user, _amount); + } + + /** + * @notice Sets the lockbox address + * + * @param _lockbox The address of the lockbox + */ + function setLockbox(address _lockbox) public { + if (msg.sender != FACTORY) revert IXERC20_NotFactory(); + lockbox = _lockbox; + + emit LockboxSet(_lockbox); + } + + /** + * @notice Updates the minting and burning limits of a bridge + * @dev Only callable by the owner. Reverts with `IXERC20_LimitsTooHigh` if a limit exceeds `type(uint256).max / 2`. + * @param _bridge The bridge address whose limits are being set + * @param _mintingLimit The new minting max limit for the bridge + * @param _burningLimit The new burning max limit for the bridge + */ + function setLimits(address _bridge, uint256 _mintingLimit, uint256 _burningLimit) external onlyOwner { + if (_mintingLimit > (type(uint256).max / 2) || _burningLimit > (type(uint256).max / 2)) { + revert IXERC20_LimitsTooHigh(); + } + + _changeMinterLimit(_bridge, _mintingLimit); + _changeBurnerLimit(_bridge, _burningLimit); + emit BridgeLimitsSet(_mintingLimit, _burningLimit, _bridge); + } + + /** + * @notice Returns the max limit of a bridge + * + * @param _bridge the bridge we are viewing the limits of + * @return _limit The limit the bridge has + */ + function mintingMaxLimitOf(address _bridge) public view returns (uint256 _limit) { + _limit = bridges[_bridge].minterParams.maxLimit; + } + + /** + * @notice Returns the max limit of a bridge + * + * @param _bridge the bridge we are viewing the limits of + * @return _limit The limit the bridge has + */ + function burningMaxLimitOf(address _bridge) public view returns (uint256 _limit) { + _limit = bridges[_bridge].burnerParams.maxLimit; + } + + /** + * @notice Returns the current limit of a bridge + * + * @param _bridge the bridge we are viewing the limits of + * @return _limit The limit the bridge has + */ + function mintingCurrentLimitOf(address _bridge) public view returns (uint256 _limit) { + _limit = _getCurrentLimit( + bridges[_bridge].minterParams.currentLimit, + bridges[_bridge].minterParams.maxLimit, + bridges[_bridge].minterParams.timestamp, + bridges[_bridge].minterParams.ratePerSecond + ); + } + + /** + * @notice Returns the current limit of a bridge + * + * @param _bridge the bridge we are viewing the limits of + * @return _limit The limit the bridge has + */ + function burningCurrentLimitOf(address _bridge) public view returns (uint256 _limit) { + _limit = _getCurrentLimit( + bridges[_bridge].burnerParams.currentLimit, + bridges[_bridge].burnerParams.maxLimit, + bridges[_bridge].burnerParams.timestamp, + bridges[_bridge].burnerParams.ratePerSecond + ); + } + + /** + * @notice Uses the limit of any bridge + * @param _bridge The address of the bridge who is being changed + * @param _change The change in the limit + */ + function _useMinterLimits(address _bridge, uint256 _change) internal { + uint256 _currentLimit = mintingCurrentLimitOf(_bridge); + bridges[_bridge].minterParams.timestamp = block.timestamp; + bridges[_bridge].minterParams.currentLimit = _currentLimit - _change; + } + + /** + * @notice Uses the limit of any bridge + * @param _bridge The address of the bridge who is being changed + * @param _change The change in the limit + */ + function _useBurnerLimits(address _bridge, uint256 _change) internal { + uint256 _currentLimit = burningCurrentLimitOf(_bridge); + bridges[_bridge].burnerParams.timestamp = block.timestamp; + bridges[_bridge].burnerParams.currentLimit = _currentLimit - _change; + } + + /** + * @notice Updates the limit of any bridge + * @dev Can only be called by the owner + * @param _bridge The address of the bridge we are setting the limit too + * @param _limit The updated limit we are setting to the bridge + */ + function _changeMinterLimit(address _bridge, uint256 _limit) internal { + uint256 _oldLimit = bridges[_bridge].minterParams.maxLimit; + uint256 _currentLimit = mintingCurrentLimitOf(_bridge); + bridges[_bridge].minterParams.maxLimit = _limit; + + bridges[_bridge].minterParams.currentLimit = _calculateNewCurrentLimit(_limit, _oldLimit, _currentLimit); + + bridges[_bridge].minterParams.ratePerSecond = _limit / _DURATION; + bridges[_bridge].minterParams.timestamp = block.timestamp; + } + + /** + * @notice Updates the limit of any bridge + * @dev Can only be called by the owner + * @param _bridge The address of the bridge we are setting the limit too + * @param _limit The updated limit we are setting to the bridge + */ + function _changeBurnerLimit(address _bridge, uint256 _limit) internal { + uint256 _oldLimit = bridges[_bridge].burnerParams.maxLimit; + uint256 _currentLimit = burningCurrentLimitOf(_bridge); + bridges[_bridge].burnerParams.maxLimit = _limit; + + bridges[_bridge].burnerParams.currentLimit = _calculateNewCurrentLimit(_limit, _oldLimit, _currentLimit); + + bridges[_bridge].burnerParams.ratePerSecond = _limit / _DURATION; + bridges[_bridge].burnerParams.timestamp = block.timestamp; + } + + /** + * @notice Updates the current limit + * + * @param _limit The new limit + * @param _oldLimit The old limit + * @param _currentLimit The current limit + * @return _newCurrentLimit The new current limit + */ + function _calculateNewCurrentLimit( + uint256 _limit, + uint256 _oldLimit, + uint256 _currentLimit + ) internal pure returns (uint256 _newCurrentLimit) { + uint256 _difference; + + if (_oldLimit > _limit) { + _difference = _oldLimit - _limit; + _newCurrentLimit = _currentLimit > _difference ? _currentLimit - _difference : 0; + } else { + _difference = _limit - _oldLimit; + _newCurrentLimit = _currentLimit + _difference; + } + } + + /** + * @notice Gets the current limit + * + * @param _currentLimit The current limit + * @param _maxLimit The max limit + * @param _timestamp The timestamp of the last update + * @param _ratePerSecond The rate per second + * @return _limit The current limit + */ + function _getCurrentLimit( + uint256 _currentLimit, + uint256 _maxLimit, + uint256 _timestamp, + uint256 _ratePerSecond + ) internal view returns (uint256 _limit) { + _limit = _currentLimit; + if (_limit == _maxLimit) { + return _limit; + } else if (_timestamp + _DURATION <= block.timestamp) { + _limit = _maxLimit; + } else if (_timestamp + _DURATION > block.timestamp) { + uint256 _timePassed = block.timestamp - _timestamp; + uint256 _calculatedLimit = _limit + (_timePassed * _ratePerSecond); + _limit = _calculatedLimit > _maxLimit ? _maxLimit : _calculatedLimit; + } + } + + /** + * @notice Internal function for burning tokens + * + * @param _caller The caller address + * @param _user The user address + * @param _amount The amount to burn + */ + function _burnWithCaller(address _caller, address _user, uint256 _amount) internal { + if (_caller != lockbox) { + uint256 _currentLimit = burningCurrentLimitOf(_caller); + if (_currentLimit < _amount) revert IXERC20_NotHighEnoughLimits(); + _useBurnerLimits(_caller, _amount); + } + _burn(_user, _amount); + } + + /** + * @notice Internal function for minting tokens + * + * @param _caller The caller address + * @param _user The user address + * @param _amount The amount to mint + */ + function _mintWithCaller(address _caller, address _user, uint256 _amount) internal { + if (_caller != lockbox) { + uint256 _currentLimit = mintingCurrentLimitOf(_caller); + if (_currentLimit < _amount) revert IXERC20_NotHighEnoughLimits(); + _useMinterLimits(_caller, _amount); + } + _mint(_user, _amount); + } + + function balanceOf(address account) public view override(ERC20, UCEF) returns (uint256) { + /// @inheritdoc UCEF + return UCEF.balanceOf(account); + } + + function totalSupply() public view override(ERC20, UCEF) returns (uint256) { + /// @inheritdoc UCEF + return UCEF.totalSupply(); + } + + function allowance(address owner, address spender) public view override(ERC20, UCEF) returns (uint256) { + /// @inheritdoc UCEF + return UCEF.allowance(owner, spender); + } + + function _update(address from, address to, uint256 value) internal override(ERC20, UCEF) { + /// @inheritdoc UCEF + UCEF._update(from, to, value); + } + + function _approve(address owner, address spender, uint256 value, bool emitEvent) internal override(ERC20, UCEF) { + /// @inheritdoc UCEF + UCEF._approve(owner, spender, value, emitEvent); + } + + function _spendAllowance(address owner, address spender, uint256 value) internal override(ERC20, UCEF) { + /// @inheritdoc UCEF + UCEF._spendAllowance(owner, spender, value); + } + + /** + * @inheritdoc UCEF + * @notice Authorizes balance visibility when the caller is the same as the `account`. + * @param account The address whose balance visibility is being checked. + * @return True if `msg.sender` equals `account`, otherwise false. + */ + function _authorizeBalance(address account) internal view override returns (bool) { + return account == msg.sender; + } +} diff --git a/contracts/token/xUCEF.sol b/contracts/token/xUCEF.sol deleted file mode 100644 index f8a2bf4..0000000 --- a/contracts/token/xUCEF.sol +++ /dev/null @@ -1,345 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import {IXERC20} from 'xerc20/solidity/interfaces/IXERC20.sol'; -import {UCEF} from './UCEF.sol'; -import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; -import {ERC20Permit} from '@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol'; -import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol'; - - -/** - * @title XUCEF - * @notice Cross-chain extension of `UCEF` that supports rate-limited minting and burning by bridges. - * @dev Implements `IXERC20` semantics. The owner (set to the `FACTORY`) manages per-bridge - * minting and burning limits which refill linearly over `_DURATION`. A `lockbox` may - * bypass limits for custodial operations. - */ -contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { - /** - * @notice The duration it takes for the limits to fully replenish - */ - uint256 private constant _DURATION = 1 days; - - /** - * @notice The address of the factory which deployed this contract - */ - address public immutable FACTORY; - - /** - * @notice The address of the lockbox contract - */ - address public lockbox; - - /** - * @notice Maps bridge address to bridge configurations - */ - mapping(address => Bridge) public bridges; - - /** - * @notice Constructs the initial config of the XUCEF - * - * @param _name The name of the token - * @param _symbol The symbol of the token - * @param _factory The factory which deployed this contract - */ - constructor(string memory _name, string memory _symbol, address _factory) UCEF(_name, _symbol) ERC20Permit(_name) Ownable(_factory) { - FACTORY = _factory; - } - - /** - * @notice Mints tokens for a user - * @dev Can only be called by a bridge - * @param _user The address of the user who needs tokens minted - * @param _amount The amount of tokens being minted - */ - function mint(address _user, uint256 _amount) public { - _mintWithCaller(msg.sender, _user, _amount); - } - - /** - * @notice Burns tokens for a user - * @dev Can only be called by a bridge - * @param _user The address of the user who needs tokens burned - * @param _amount The amount of tokens being burned - */ - function burn(address _user, uint256 _amount) public { - if (msg.sender != _user) { - _spendAllowance(_user, msg.sender, _amount); - } - - _burnWithCaller(msg.sender, _user, _amount); - } - - /** - * @notice Sets the lockbox address - * - * @param _lockbox The address of the lockbox - */ - function setLockbox(address _lockbox) public { - if (msg.sender != FACTORY) revert IXERC20_NotFactory(); - lockbox = _lockbox; - - emit LockboxSet(_lockbox); - } - - /** - * @notice Updates the minting and burning limits of a bridge - * @dev Only callable by the owner. Reverts with `IXERC20_LimitsTooHigh` if a limit exceeds `type(uint256).max / 2`. - * @param _bridge The bridge address whose limits are being set - * @param _mintingLimit The new minting max limit for the bridge - * @param _burningLimit The new burning max limit for the bridge - */ - function setLimits(address _bridge, uint256 _mintingLimit, uint256 _burningLimit) external onlyOwner { - if (_mintingLimit > (type(uint256).max / 2) || _burningLimit > (type(uint256).max / 2)) { - revert IXERC20_LimitsTooHigh(); - } - - _changeMinterLimit(_bridge, _mintingLimit); - _changeBurnerLimit(_bridge, _burningLimit); - emit BridgeLimitsSet(_mintingLimit, _burningLimit, _bridge); - } - - /** - * @notice Returns the max limit of a bridge - * - * @param _bridge the bridge we are viewing the limits of - * @return _limit The limit the bridge has - */ - function mintingMaxLimitOf(address _bridge) public view returns (uint256 _limit) { - _limit = bridges[_bridge].minterParams.maxLimit; - } - - /** - * @notice Returns the max limit of a bridge - * - * @param _bridge the bridge we are viewing the limits of - * @return _limit The limit the bridge has - */ - function burningMaxLimitOf(address _bridge) public view returns (uint256 _limit) { - _limit = bridges[_bridge].burnerParams.maxLimit; - } - - /** - * @notice Returns the current limit of a bridge - * - * @param _bridge the bridge we are viewing the limits of - * @return _limit The limit the bridge has - */ - function mintingCurrentLimitOf(address _bridge) public view returns (uint256 _limit) { - _limit = _getCurrentLimit( - bridges[_bridge].minterParams.currentLimit, - bridges[_bridge].minterParams.maxLimit, - bridges[_bridge].minterParams.timestamp, - bridges[_bridge].minterParams.ratePerSecond - ); - } - - /** - * @notice Returns the current limit of a bridge - * - * @param _bridge the bridge we are viewing the limits of - * @return _limit The limit the bridge has - */ - function burningCurrentLimitOf(address _bridge) public view returns (uint256 _limit) { - _limit = _getCurrentLimit( - bridges[_bridge].burnerParams.currentLimit, - bridges[_bridge].burnerParams.maxLimit, - bridges[_bridge].burnerParams.timestamp, - bridges[_bridge].burnerParams.ratePerSecond - ); - } - - /** - * @notice Uses the limit of any bridge - * @param _bridge The address of the bridge who is being changed - * @param _change The change in the limit - */ - function _useMinterLimits(address _bridge, uint256 _change) internal { - uint256 _currentLimit = mintingCurrentLimitOf(_bridge); - bridges[_bridge].minterParams.timestamp = block.timestamp; - bridges[_bridge].minterParams.currentLimit = _currentLimit - _change; - } - - /** - * @notice Uses the limit of any bridge - * @param _bridge The address of the bridge who is being changed - * @param _change The change in the limit - */ - function _useBurnerLimits(address _bridge, uint256 _change) internal { - uint256 _currentLimit = burningCurrentLimitOf(_bridge); - bridges[_bridge].burnerParams.timestamp = block.timestamp; - bridges[_bridge].burnerParams.currentLimit = _currentLimit - _change; - } - - /** - * @notice Updates the limit of any bridge - * @dev Can only be called by the owner - * @param _bridge The address of the bridge we are setting the limit too - * @param _limit The updated limit we are setting to the bridge - */ - function _changeMinterLimit(address _bridge, uint256 _limit) internal { - uint256 _oldLimit = bridges[_bridge].minterParams.maxLimit; - uint256 _currentLimit = mintingCurrentLimitOf(_bridge); - bridges[_bridge].minterParams.maxLimit = _limit; - - bridges[_bridge].minterParams.currentLimit = _calculateNewCurrentLimit(_limit, _oldLimit, _currentLimit); - - bridges[_bridge].minterParams.ratePerSecond = _limit / _DURATION; - bridges[_bridge].minterParams.timestamp = block.timestamp; - } - - /** - * @notice Updates the limit of any bridge - * @dev Can only be called by the owner - * @param _bridge The address of the bridge we are setting the limit too - * @param _limit The updated limit we are setting to the bridge - */ - function _changeBurnerLimit(address _bridge, uint256 _limit) internal { - uint256 _oldLimit = bridges[_bridge].burnerParams.maxLimit; - uint256 _currentLimit = burningCurrentLimitOf(_bridge); - bridges[_bridge].burnerParams.maxLimit = _limit; - - bridges[_bridge].burnerParams.currentLimit = _calculateNewCurrentLimit(_limit, _oldLimit, _currentLimit); - - bridges[_bridge].burnerParams.ratePerSecond = _limit / _DURATION; - bridges[_bridge].burnerParams.timestamp = block.timestamp; - } - - /** - * @notice Updates the current limit - * - * @param _limit The new limit - * @param _oldLimit The old limit - * @param _currentLimit The current limit - * @return _newCurrentLimit The new current limit - */ - function _calculateNewCurrentLimit( - uint256 _limit, - uint256 _oldLimit, - uint256 _currentLimit - ) internal pure returns (uint256 _newCurrentLimit) { - uint256 _difference; - - if (_oldLimit > _limit) { - _difference = _oldLimit - _limit; - _newCurrentLimit = _currentLimit > _difference ? _currentLimit - _difference : 0; - } else { - _difference = _limit - _oldLimit; - _newCurrentLimit = _currentLimit + _difference; - } - } - - /** - * @notice Gets the current limit - * - * @param _currentLimit The current limit - * @param _maxLimit The max limit - * @param _timestamp The timestamp of the last update - * @param _ratePerSecond The rate per second - * @return _limit The current limit - */ - function _getCurrentLimit( - uint256 _currentLimit, - uint256 _maxLimit, - uint256 _timestamp, - uint256 _ratePerSecond - ) internal view returns (uint256 _limit) { - _limit = _currentLimit; - if (_limit == _maxLimit) { - return _limit; - } else if (_timestamp + _DURATION <= block.timestamp) { - _limit = _maxLimit; - } else if (_timestamp + _DURATION > block.timestamp) { - uint256 _timePassed = block.timestamp - _timestamp; - uint256 _calculatedLimit = _limit + (_timePassed * _ratePerSecond); - _limit = _calculatedLimit > _maxLimit ? _maxLimit : _calculatedLimit; - } - } - - /** - * @notice Internal function for burning tokens - * - * @param _caller The caller address - * @param _user The user address - * @param _amount The amount to burn - */ - function _burnWithCaller(address _caller, address _user, uint256 _amount) internal { - if (_caller != lockbox) { - uint256 _currentLimit = burningCurrentLimitOf(_caller); - if (_currentLimit < _amount) revert IXERC20_NotHighEnoughLimits(); - _useBurnerLimits(_caller, _amount); - } - _burn(_user, _amount); - } - - /** - * @notice Internal function for minting tokens - * - * @param _caller The caller address - * @param _user The user address - * @param _amount The amount to mint - */ - function _mintWithCaller(address _caller, address _user, uint256 _amount) internal { - if (_caller != lockbox) { - uint256 _currentLimit = mintingCurrentLimitOf(_caller); - if (_currentLimit < _amount) revert IXERC20_NotHighEnoughLimits(); - _useMinterLimits(_caller, _amount); - } - _mint(_user, _amount); - } - - function balanceOf(address account) - public - view - override(ERC20, UCEF) - returns (uint256) - { - /// @inheritdoc UCEF - return UCEF.balanceOf(account); - } - - function totalSupply() - public - view - override(ERC20, UCEF) - returns (uint256) - { - /// @inheritdoc UCEF - return UCEF.totalSupply(); - } - - function allowance(address owner, address spender) - public - view - override(ERC20, UCEF) - returns (uint256) - { - /// @inheritdoc UCEF - return UCEF.allowance(owner, spender); - } - - function _update(address from, address to, uint256 value) - internal - override(ERC20, UCEF) - { - /// @inheritdoc UCEF - UCEF._update(from, to, value); - } - - function _approve(address owner, address spender, uint256 value, bool emitEvent) - internal - override(ERC20, UCEF) - { - /// @inheritdoc UCEF - UCEF._approve(owner, spender, value, emitEvent); - } - - function _spendAllowance(address owner, address spender, uint256 value) - internal - override(ERC20, UCEF) - { - /// @inheritdoc UCEF - UCEF._spendAllowance(owner, spender, value); - } -} \ No newline at end of file diff --git a/test/token/XUCEF.test.ts b/test/token/XUCEF.test.ts new file mode 100644 index 0000000..03148a5 --- /dev/null +++ b/test/token/XUCEF.test.ts @@ -0,0 +1,313 @@ +import { expect } from 'chai' +import { ethers } from 'hardhat' +import { Signer } from 'ethers' +import { XUCEF } from '../../typechain-types' + +describe('XUCEF', function () { + let factory: Signer + let bridge1: Signer + let bridge2: Signer + let user: Signer + let other: Signer + + let factoryAddress: string + let bridge1Address: string + let bridge2Address: string + let userAddress: string + let otherAddress: string + + let token: XUCEF + + const TOKEN_NAME = 'XUCEF' + const TOKEN_SYMBOL = 'xUCEF' + const ONE_DAY = 24 * 60 * 60 + + async function advanceTime(seconds: number) { + await ethers.provider.send('evm_increaseTime', [seconds]) + await ethers.provider.send('evm_mine', []) + } + + beforeEach(async function () { + ;[factory, bridge1, bridge2, user, other] = await ethers.getSigners() + factoryAddress = await factory.getAddress() + bridge1Address = await bridge1.getAddress() + bridge2Address = await bridge2.getAddress() + userAddress = await user.getAddress() + otherAddress = await other.getAddress() + + const XUCEFFactory = await ethers.getContractFactory('XUCEF') + token = (await XUCEFFactory.connect(factory).deploy(TOKEN_NAME, TOKEN_SYMBOL, factoryAddress)) as unknown as XUCEF + await token.waitForDeployment() + }) + + describe('Deployment', function () { + it('sets name, symbol, FACTORY and owner', async function () { + expect(await token.name()).to.equal(TOKEN_NAME) + expect(await token.symbol()).to.equal(TOKEN_SYMBOL) + expect(await token.FACTORY()).to.equal(factoryAddress) + expect(await token.owner()).to.equal(factoryAddress) + }) + + it('starts with zero limits for unknown bridge', async function () { + expect(await token.mintingMaxLimitOf(bridge1Address)).to.equal(0n) + expect(await token.burningMaxLimitOf(bridge1Address)).to.equal(0n) + expect(await token.mintingCurrentLimitOf(bridge1Address)).to.equal(0n) + expect(await token.burningCurrentLimitOf(bridge1Address)).to.equal(0n) + }) + }) + + describe('Lockbox', function () { + it('only FACTORY can set lockbox and emits event', async function () { + await expect(token.connect(factory).setLockbox(otherAddress)) + .to.emit(token, 'LockboxSet') + .withArgs(otherAddress) + + expect(await token.lockbox()).to.equal(otherAddress) + + await expect(token.connect(other).setLockbox(userAddress)).to.be.reverted + }) + }) + + describe('Owner-managed limits', function () { + it('only owner can set limits; emits BridgeLimitsSet; sets params and rates', async function () { + const mintLimit = ethers.parseEther('1000') + const burnLimit = ethers.parseEther('500') + + await expect(token.connect(factory).setLimits(bridge1Address, mintLimit, burnLimit)) + .to.emit(token, 'BridgeLimitsSet') + .withArgs(mintLimit, burnLimit, bridge1Address) + + expect(await token.mintingMaxLimitOf(bridge1Address)).to.equal(mintLimit) + expect(await token.burningMaxLimitOf(bridge1Address)).to.equal(burnLimit) + expect(await token.mintingCurrentLimitOf(bridge1Address)).to.equal(mintLimit) + expect(await token.burningCurrentLimitOf(bridge1Address)).to.equal(burnLimit) + + const bridgeParams = await token.bridges(bridge1Address) + expect(bridgeParams.minterParams.ratePerSecond).to.equal(mintLimit / BigInt(ONE_DAY)) + expect(bridgeParams.burnerParams.ratePerSecond).to.equal(burnLimit / BigInt(ONE_DAY)) + + await expect(token.connect(other).setLimits(bridge1Address, 1n, 1n)).to.be.reverted + }) + + it('reverts when limits exceed half of uint256', async function () { + const tooHigh = (ethers.MaxUint256 / 2n) + 1n + await expect(token.connect(factory).setLimits(bridge1Address, tooHigh, 1n)).to.be.reverted + await expect(token.connect(factory).setLimits(bridge1Address, 1n, tooHigh)).to.be.reverted + }) + }) + + describe('Minting with limits', function () { + const limit = ethers.parseEther('100') + + beforeEach(async function () { + await token.connect(factory).setLimits(bridge1Address, limit, limit) + }) + + it('reverts when a non-lockbox caller without limits mints', async function () { + await expect(token.connect(other).mint(userAddress, 1n)).to.be.reverted + }) + + it('allows bridge to mint within limit and decreases current limit', async function () { + const amount = ethers.parseEther('40') + + await expect(token.connect(bridge1).mint(userAddress, amount)) + .to.emit(token, 'Transfer') + .withArgs(ethers.ZeroAddress, ethers.ZeroAddress, 0n) + + const current = await token.mintingCurrentLimitOf(bridge1Address) + expect(current).to.equal(limit - amount) + + expect(await token.totalSupply()).to.equal(amount) + }) + + it('refills current limit linearly over time up to max', async function () { + const amount = ethers.parseEther('60') + await token.connect(bridge1).mint(userAddress, amount) + + expect(await token.mintingCurrentLimitOf(bridge1Address)).to.equal(limit - amount) + + await advanceTime(ONE_DAY) + + expect(await token.mintingCurrentLimitOf(bridge1Address)).to.equal(limit) + }) + + it('reverts when attempting to mint above current limit', async function () { + await token.connect(bridge1).mint(userAddress, limit) + await expect(token.connect(bridge1).mint(userAddress, limit)).to.be.reverted + }) + + it('bypasses limits when called by lockbox', async function () { + await token.connect(factory).setLockbox(otherAddress) + + const amount = ethers.parseEther('200') + await expect(token.connect(other).mint(userAddress, amount)) + .to.emit(token, 'Transfer') + .withArgs(ethers.ZeroAddress, ethers.ZeroAddress, 0n) + + const current = await token.mintingCurrentLimitOf(bridge1Address) + expect(current).to.equal(limit) + }) + }) + + describe('Burning with limits', function () { + const limit = ethers.parseEther('100') + + beforeEach(async function () { + await token.connect(factory).setLimits(bridge1Address, limit, limit) + await token.connect(factory).setLockbox(otherAddress) + await token.connect(other).mint(userAddress, ethers.parseEther('90')) + }) + + it('requires allowance when caller is not the user', async function () { + await expect(token.connect(bridge1).burn(userAddress, ethers.parseEther('1'))).to.be.reverted + }) + + it('allows bridge to burn within limit with allowance and reduces current limit', async function () { + const burnAmount = ethers.parseEther('50') + + await token.connect(user).approve(bridge1Address, burnAmount) + + await expect(token.connect(bridge1).burn(userAddress, burnAmount)) + .to.emit(token, 'Transfer') + .withArgs(ethers.ZeroAddress, ethers.ZeroAddress, 0n) + + const current = await token.burningCurrentLimitOf(bridge1Address) + expect(current).to.equal(limit - burnAmount) + + expect(await token.totalSupply()).to.equal(ethers.parseEther('40')) + }) + + it('reverts when attempting to burn above current limit', async function () { + await token.connect(user).approve(bridge1Address, limit + 1n) + await expect(token.connect(bridge1).burn(userAddress, limit + 1n)).to.be.reverted + }) + + it('reverts if user burns without personal burner limit', async function () { + await expect(token.connect(user).burn(userAddress, 1n)).to.be.reverted + }) + + it('bypasses limits when lockbox burns with allowance', async function () { + const burnAmount = ethers.parseEther('10') + await token.connect(user).approve(otherAddress, burnAmount) + + await expect(token.connect(other).burn(userAddress, burnAmount)) + .to.emit(token, 'Transfer') + .withArgs(ethers.ZeroAddress, ethers.ZeroAddress, 0n) + + const current = await token.burningCurrentLimitOf(bridge1Address) + expect(current).to.equal(limit) + }) + }) + + describe('ERC20Permit', function () { + it('sets allowance via permit', async function () { + const value = ethers.parseEther('123') + const latest = await ethers.provider.getBlock('latest') + const deadline = BigInt((latest?.timestamp ?? 0) + 3600) + + const nonce = await token.nonces(await user.getAddress()) + const chainId = (await ethers.provider.getNetwork()).chainId + + const domain = { + name: await token.name(), + version: '1', + chainId, + verifyingContract: await token.getAddress(), + } + + const types = { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + } + + const message = { + owner: userAddress, + spender: otherAddress, + value, + nonce, + deadline, + } + + const signature = await user.signTypedData(domain, types as any, message) + const { r, s, v } = ethers.Signature.from(signature) + + await token.permit(userAddress, otherAddress, value, deadline, v, r, s) + + const allowanceAsSpender = await token.connect(other).allowance(userAddress, otherAddress) + expect(allowanceAsSpender).to.equal(value) + }) + }) + + describe('Ownership', function () { + it('only owner can set limits and new owner after transfer can manage', async function () { + await expect(token.connect(other).setLimits(bridge2Address, 1n, 1n)).to.be.reverted + + await token.connect(factory).transferOwnership(otherAddress) + expect(await token.owner()).to.equal(otherAddress) + + await expect(token.connect(factory).setLimits(bridge2Address, 1n, 1n)).to.be.reverted + + await expect(token.connect(other).setLimits(bridge2Address, 1n, 1n)) + .to.emit(token, 'BridgeLimitsSet') + .withArgs(1n, 1n, bridge2Address) + }) + }) + + describe('UCEF behavior (privacy and allowances)', function () { + const minted = ethers.parseEther('100') + + beforeEach(async function () { + await token.connect(factory).setLockbox(otherAddress) + await token.connect(other).mint(userAddress, minted) + }) + + it('owner can see own balance; others see 0', async function () { + expect(await token.connect(user).balanceOf(userAddress)).to.equal(minted) + expect(await token.connect(factory).balanceOf(userAddress)).to.equal(0n) + expect(await token.connect(bridge1).balanceOf(userAddress)).to.equal(0n) + expect(await token.totalSupply()).to.equal(minted) + }) + + it('allowance can be viewed by owner or spender; third party reverts', async function () { + const allowanceAmount = 123n + await token.connect(user).approve(bridge1Address, allowanceAmount) + + const asOwner = await token.connect(user).allowance(userAddress, bridge1Address) + expect(asOwner).to.equal(allowanceAmount) + + const asSpender = await token.connect(bridge1).allowance(userAddress, bridge1Address) + expect(asSpender).to.equal(allowanceAmount) + + await expect(token.connect(other).allowance(userAddress, bridge1Address)) + .to.be.revertedWithCustomError(token, 'UCEFUnauthorizedBalanceAccess') + .withArgs(otherAddress, userAddress) + }) + + it('approve emits zeroed Approval event and transferFrom reduces allowance', async function () { + const allowanceAmount = ethers.parseEther('10') + await expect(token.connect(user).approve(bridge1Address, allowanceAmount)) + .to.emit(token, 'Approval') + .withArgs(ethers.ZeroAddress, ethers.ZeroAddress, 0n) + + const spend1 = ethers.parseEther('6') + await expect(token.connect(bridge1).transferFrom(userAddress, otherAddress, spend1)) + .to.emit(token, 'Transfer') + .withArgs(ethers.ZeroAddress, ethers.ZeroAddress, 0n) + + const remaining = await token.connect(bridge1).allowance(userAddress, bridge1Address) + expect(remaining).to.equal(allowanceAmount - spend1) + + await expect(token.connect(bridge1).transferFrom(userAddress, otherAddress, allowanceAmount)) + .to.be.reverted + + expect(await token.totalSupply()).to.equal(minted) + }) + }) +}) + + From 855deeb84d554f56b6ffb07f2eb70a6ea80cc8af Mon Sep 17 00:00:00 2001 From: Gabriel Speckhahn <749488+gabspeck@users.noreply.github.com> Date: Fri, 31 Oct 2025 10:37:20 +0000 Subject: [PATCH 08/11] revert deployment-id parameter change --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bf06888..1c60183 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "coverage": "npx hardhat coverage", "clean:ignition": "rm -rf .ignition && rm -rf ./ignition/deployments", "clean:all": "pnpm clean && pnpm clean:ignition", - "deploy:module": "deploy() { npx hardhat ignition deploy ignition/modules/$1.ts --network ${2:-localhost} --deployment-id ${3:-localhost} --strategy ${4:-basic} --reset; }; deploy $@", + "deploy:module": "deploy() { npx hardhat ignition deploy ignition/modules/$1.ts --network ${2:-localhost} --deployment-id ${2:-localhost} --strategy ${3:-basic} --reset; }; deploy $@", "clean": "npx hardhat clean", "format": "prettier --write \"**/*.{js,jsx,ts,tsx}\" --ignore-path .prettierignore", "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx}\" --ignore-path .prettierignore", From 3e31724256dd4e98bd92bc6d65204553f6b6f899 Mon Sep 17 00:00:00 2001 From: Gabriel Speckhahn <749488+gabspeck@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:55:35 +0000 Subject: [PATCH 09/11] use UCEFOwned to revert instead of returning 0 on unauthorized balance --- contracts/token/XUCEF.sol | 14 ++------------ test/token/XUCEF.test.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/contracts/token/XUCEF.sol b/contracts/token/XUCEF.sol index 3bdfbee..b127456 100644 --- a/contracts/token/XUCEF.sol +++ b/contracts/token/XUCEF.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.24; import {IXERC20} from 'xerc20/solidity/interfaces/IXERC20.sol'; -import {UCEF} from './UCEF.sol'; +import {UCEFOwned, UCEF} from '../extensions/UCEFOwned.sol'; import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; import {ERC20Permit} from '@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol'; import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol'; @@ -14,7 +14,7 @@ import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol'; * minting and burning limits which refill linearly over `_DURATION`. A `lockbox` may * bypass limits for custodial operations. */ -contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { +contract XUCEF is UCEFOwned, Ownable, IXERC20, ERC20Permit { /** * @notice The duration it takes for the limits to fully replenish */ @@ -321,14 +321,4 @@ contract XUCEF is UCEF, Ownable, IXERC20, ERC20Permit { /// @inheritdoc UCEF UCEF._spendAllowance(owner, spender, value); } - - /** - * @inheritdoc UCEF - * @notice Authorizes balance visibility when the caller is the same as the `account`. - * @param account The address whose balance visibility is being checked. - * @return True if `msg.sender` equals `account`, otherwise false. - */ - function _authorizeBalance(address account) internal view override returns (bool) { - return account == msg.sender; - } } diff --git a/test/token/XUCEF.test.ts b/test/token/XUCEF.test.ts index 03148a5..3cdac84 100644 --- a/test/token/XUCEF.test.ts +++ b/test/token/XUCEF.test.ts @@ -266,10 +266,14 @@ describe('XUCEF', function () { await token.connect(other).mint(userAddress, minted) }) - it('owner can see own balance; others see 0', async function () { + it('owner can see own balance; others revert', async function () { expect(await token.connect(user).balanceOf(userAddress)).to.equal(minted) - expect(await token.connect(factory).balanceOf(userAddress)).to.equal(0n) - expect(await token.connect(bridge1).balanceOf(userAddress)).to.equal(0n) + await expect(token.connect(factory).balanceOf(userAddress)) + .to.be.revertedWithCustomError(token, 'UCEFUnauthorizedBalanceAccess') + .withArgs(factoryAddress, userAddress) + await expect(token.connect(bridge1).balanceOf(userAddress)) + .to.be.revertedWithCustomError(token, 'UCEFUnauthorizedBalanceAccess') + .withArgs(bridge1Address, userAddress) expect(await token.totalSupply()).to.equal(minted) }) From 4a289db8b3fefa57e1db20e5818de5e0ec6ee22a Mon Sep 17 00:00:00 2001 From: Gabriel Speckhahn <749488+gabspeck@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:01:25 +0000 Subject: [PATCH 10/11] add debugging tasks --- hardhat.config.ts | 2 ++ tasks/call.ts | 47 ++++++++++++++++++++++++++++++++++++++ tasks/set-xerc20-limits.ts | 28 +++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 tasks/call.ts create mode 100644 tasks/set-xerc20-limits.ts diff --git a/hardhat.config.ts b/hardhat.config.ts index 7ecad13..dbf023c 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -4,6 +4,8 @@ import { config as Config } from 'dotenv' import '@nomicfoundation/hardhat-ignition' import '@appliedblockchain/silentdatarollup-hardhat-plugin' import { SignatureType } from '@appliedblockchain/silentdatarollup-core' +import './tasks/set-xerc20-limits' +import './tasks/call' // Load .env file Config() diff --git a/tasks/call.ts b/tasks/call.ts new file mode 100644 index 0000000..0203055 --- /dev/null +++ b/tasks/call.ts @@ -0,0 +1,47 @@ +import { task } from 'hardhat/config' + +task('call', 'Execute a read-only call with cast-style params') + .addPositionalParam('to', 'The destination of the call') + .addPositionalParam('sig', 'The function signature to call') + .addVariadicPositionalParam('args', 'The arguments of the function to call', []) + .setAction(async (args, hre) => { + const { to, sig, args: fnArgs } = args as { + to: string + sig: string + args: string[] + } + + const normalizedSig = sig.trim().startsWith('function ') + ? sig.trim() + : `function ${sig.trim()}` + const iface = new hre.ethers.Interface([normalizedSig]) + let fragment: any = null + iface.forEachFunction((f) => { + if (!fragment) fragment = f + }) + if (!fragment) { + throw new Error('Invalid function signature') + } + const data = iface.encodeFunctionData(fragment, fnArgs) + + const result = await hre.ethers.provider.call({ to, data }) + + if (fragment.outputs && fragment.outputs.length > 0) { + const decoded = iface.decodeFunctionResult(fragment, result) + const normalize = (v: unknown): unknown => { + if (typeof v === 'bigint') return v.toString() + if (Array.isArray(v)) return v.map(normalize) + return v + } + const normalized = Array.from(decoded).map(normalize) + if (normalized.length === 1) { + console.log(normalized[0]) + } else { + console.log(normalized) + } + } else { + console.log(result) + } + }) + + diff --git a/tasks/set-xerc20-limits.ts b/tasks/set-xerc20-limits.ts new file mode 100644 index 0000000..baa15ff --- /dev/null +++ b/tasks/set-xerc20-limits.ts @@ -0,0 +1,28 @@ +import { task, types } from 'hardhat/config' + +task('xerc20:set-limits', 'Set minting and burning limits for a bridge on an XERC20 token') + .addParam('token', 'Token contract address') + .addParam('bridge', 'Bridge address') + .addParam('mint', 'Minting limit in human units (e.g. 1000.5)', undefined, types.string) + .addParam('burn', 'Burning limit in human units (e.g. 1000.5)', undefined, types.string) + .setAction(async (args, hre) => { + const { token, bridge, mint, burn } = args as { + token: string + bridge: string + mint: string + burn: string + } + + const [signer] = await hre.ethers.getSigners() + const contract = await hre.ethers.getContractAt('XUCEF', token, signer) + const decimals = Number(await contract.decimals()) + const mintingLimit = hre.ethers.parseUnits(mint, decimals) + const burningLimit = hre.ethers.parseUnits(burn, decimals) + + const tx = await contract.setLimits(bridge, mintingLimit, burningLimit) + console.log(`submitted: ${tx.hash}`) + const receipt = await tx.wait() + console.log(`confirmed in block ${receipt.blockNumber}`) + }) + + From 6a13a3a21986494f571f028dd674b9ddd6a0835a Mon Sep 17 00:00:00 2001 From: Gabriel Speckhahn <749488+gabspeck@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:02:14 +0000 Subject: [PATCH 11/11] add support for custom decimals and fix parameter file example --- contracts/token/XUCEF.sol | 8 ++++++++ ignition/modules/XUCEF.ts | 3 ++- ignition/parameters/XUCEF.json.example | 7 +++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/contracts/token/XUCEF.sol b/contracts/token/XUCEF.sol index b127456..bb19104 100644 --- a/contracts/token/XUCEF.sol +++ b/contracts/token/XUCEF.sol @@ -35,6 +35,8 @@ contract XUCEF is UCEFOwned, Ownable, IXERC20, ERC20Permit { */ mapping(address => Bridge) public bridges; + uint8 private immutable _decimals; + /** * @notice Constructs the initial config of the XUCEF * @@ -45,9 +47,11 @@ contract XUCEF is UCEFOwned, Ownable, IXERC20, ERC20Permit { constructor( string memory _name, string memory _symbol, + uint8 __decimals, address _factory ) UCEF(_name, _symbol) ERC20Permit(_name) Ownable(_factory) { FACTORY = _factory; + _decimals = __decimals; } /** @@ -307,6 +311,10 @@ contract XUCEF is UCEFOwned, Ownable, IXERC20, ERC20Permit { return UCEF.allowance(owner, spender); } + function decimals() public view override returns (uint8) { + return _decimals; + } + function _update(address from, address to, uint256 value) internal override(ERC20, UCEF) { /// @inheritdoc UCEF UCEF._update(from, to, value); diff --git a/ignition/modules/XUCEF.ts b/ignition/modules/XUCEF.ts index fa53b98..8a20e75 100644 --- a/ignition/modules/XUCEF.ts +++ b/ignition/modules/XUCEF.ts @@ -3,9 +3,10 @@ import { buildModule } from '@nomicfoundation/hardhat-ignition/modules' export default buildModule('XUCEFModule', (m) => { const name = m.getParameter('name', 'XUCEF') const symbol = m.getParameter('symbol', 'XUCEF') + const decimals = m.getParameter('decimals', 18) const deployer = m.getAccount(0) - const xucef = m.contract('XUCEF', [name, symbol, deployer], { + const xucef = m.contract('XUCEF', [name, symbol, decimals, deployer], { id: 'XUCEF', }) diff --git a/ignition/parameters/XUCEF.json.example b/ignition/parameters/XUCEF.json.example index d36de19..d81f193 100644 --- a/ignition/parameters/XUCEF.json.example +++ b/ignition/parameters/XUCEF.json.example @@ -1,4 +1,7 @@ { - "name": "xUSDC", - "symbol": "XUSDC" + "XUCEFModule": { + "name": "xUSDC", + "symbol": "XUSDC", + "decimals": 6 + } } \ No newline at end of file