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/.gitignore b/.gitignore index cececed..6154a6d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,9 @@ dist .env.test.local .env.production.local -.vscode \ No newline at end of file +.vscode + +.pnpm-store/ + +ignition/parameters/* +!ignition/parameters/*.example 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 diff --git a/contracts/token/XUCEF.sol b/contracts/token/XUCEF.sol new file mode 100644 index 0000000..bb19104 --- /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 {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'; + +/** + * @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 UCEFOwned, 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; + + uint8 private immutable _decimals; + + /** + * @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, + uint8 __decimals, + address _factory + ) UCEF(_name, _symbol) ERC20Permit(_name) Ownable(_factory) { + FACTORY = _factory; + _decimals = __decimals; + } + + /** + * @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 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); + } + + 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); + } +} diff --git a/hardhat.config.ts b/hardhat.config.ts index c8fc6cb..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() @@ -18,6 +20,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/ignition/modules/XUCEF.ts b/ignition/modules/XUCEF.ts new file mode 100644 index 0000000..8a20e75 --- /dev/null +++ b/ignition/modules/XUCEF.ts @@ -0,0 +1,14 @@ +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, decimals, 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..d81f193 --- /dev/null +++ b/ignition/parameters/XUCEF.json.example @@ -0,0 +1,7 @@ +{ + "XUCEFModule": { + "name": "xUSDC", + "symbol": "XUSDC", + "decimals": 6 + } +} \ No newline at end of file diff --git a/package.json b/package.json index aab6faa..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 ${2:-localhost} --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", @@ -17,8 +17,8 @@ "lint:fix": "pnpm lint --fix" }, "devDependencies": { - "@appliedblockchain/silentdatarollup-core": "^0.1.3", - "@appliedblockchain/silentdatarollup-hardhat-plugin": "^0.1.3", + "@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", @@ -47,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" } } 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 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}`) + }) + + diff --git a/test/token/XUCEF.test.ts b/test/token/XUCEF.test.ts new file mode 100644 index 0000000..3cdac84 --- /dev/null +++ b/test/token/XUCEF.test.ts @@ -0,0 +1,317 @@ +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 revert', async function () { + expect(await token.connect(user).balanceOf(userAddress)).to.equal(minted) + 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) + }) + + 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) + }) + }) +}) + +