diff --git a/contracts/handlers/fee/BasicFeeHandler.sol b/contracts/handlers/fee/BasicFeeHandler.sol index e6f86a1b..e5a77735 100644 --- a/contracts/handlers/fee/BasicFeeHandler.sol +++ b/contracts/handlers/fee/BasicFeeHandler.sol @@ -70,7 +70,7 @@ contract BasicFeeHandler is IFeeHandler, AccessControl { @param depositData Additional data to be passed to specified handler. @param feeData Additional data to be passed to the fee handler. */ - function collectFee(address sender, uint8 fromDomainID, uint8 destinationDomainID, bytes32 resourceID, bytes calldata depositData, bytes calldata feeData) payable external onlyBridgeOrRouter { + function collectFee(address sender, uint8 fromDomainID, uint8 destinationDomainID, bytes32 resourceID, bytes calldata depositData, bytes calldata feeData) virtual payable external onlyBridgeOrRouter { if (msg.value != _fee) revert IncorrectFeeSupplied(msg.value); emit FeeCollected(sender, fromDomainID, destinationDomainID, resourceID, _fee, address(0)); } @@ -85,7 +85,7 @@ contract BasicFeeHandler is IFeeHandler, AccessControl { @param feeData Additional data to be passed to the fee handler. @return Returns the fee amount. */ - function calculateFee(address sender, uint8 fromDomainID, uint8 destinationDomainID, bytes32 resourceID, bytes calldata depositData, bytes calldata feeData) external view returns(uint256, address) { + function calculateFee(address sender, uint8 fromDomainID, uint8 destinationDomainID, bytes32 resourceID, bytes calldata depositData, bytes calldata feeData) virtual external view returns(uint256, address) { return (_fee, address(0)); } @@ -114,6 +114,4 @@ contract BasicFeeHandler is IFeeHandler, AccessControl { emit FeeDistributed(address(0), addrs[i], amounts[i]); } } - - } diff --git a/contracts/handlers/fee/PercentageERC20FeeHandlerEVM.sol b/contracts/handlers/fee/PercentageERC20FeeHandlerEVM.sol new file mode 100644 index 00000000..49eaffdd --- /dev/null +++ b/contracts/handlers/fee/PercentageERC20FeeHandlerEVM.sol @@ -0,0 +1,133 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.11; + +import "../../interfaces/IBridge.sol"; +import "../../interfaces/IERCHandler.sol"; +import "../../ERC20Safe.sol"; +import { BasicFeeHandler } from "./BasicFeeHandler.sol"; + +/** + @title Handles deposit fees. + @author ChainSafe Systems. + @notice This contract is intended to be used with the Bridge contract. + */ +contract PercentageERC20FeeHandlerEVM is BasicFeeHandler, ERC20Safe { + uint32 public constant HUNDRED_PERCENT = 1e8; + + /** + @notice _fee inherited from BasicFeeHandler in this implementation is + in BPS and should be multiplied by 10000 to avoid precision loss + */ + struct Bounds { + uint128 lowerBound; // min fee in token amount + uint128 upperBound; // max fee in token amount + } + + mapping(bytes32 => Bounds) public _resourceIDToFeeBounds; + + event FeeBoundsChanged(uint256 newLowerBound, uint256 newUpperBound); + + /** + @param bridgeAddress Contract address of previously deployed Bridge. + @param feeHandlerRouterAddress Contract address of previously deployed FeeHandlerRouter. + */ + constructor( + address bridgeAddress, + address feeHandlerRouterAddress + ) BasicFeeHandler(bridgeAddress, feeHandlerRouterAddress) {} + + // Admin functions + + /** + @notice Calculates fee for deposit. + @param sender Sender of the deposit. + @param fromDomainID ID of the source chain. + @param destinationDomainID ID of chain deposit will be bridged to. + @param resourceID ResourceID to be used when making deposits. + @param depositData Additional data about the deposit. + @param feeData Additional data to be passed to the fee handler. + @return fee Returns the fee amount. + @return tokenAddress Returns the address of the token to be used for fee. + */ + function calculateFee(address sender, uint8 fromDomainID, uint8 destinationDomainID, bytes32 resourceID, bytes calldata depositData, bytes calldata feeData) external view override returns(uint256 fee, address tokenAddress) { + return _calculateFee(sender, fromDomainID, destinationDomainID, resourceID, depositData, feeData); + } + + function _calculateFee(address sender, uint8 fromDomainID, uint8 destinationDomainID, bytes32 resourceID, bytes calldata depositData, bytes calldata feeData) internal view returns(uint256 fee, address tokenAddress) { + address tokenHandler = IBridge(_bridgeAddress)._resourceIDToHandlerAddress(resourceID); + tokenAddress = IERCHandler(tokenHandler)._resourceIDToTokenContractAddress(resourceID); + Bounds memory bounds = _resourceIDToFeeBounds[resourceID]; + + (uint256 depositAmount) = abi.decode(depositData, (uint256)); + + fee = depositAmount * _fee / HUNDRED_PERCENT; // 10000 for BPS and 10000 to avoid precision loss + + if (fee < bounds.lowerBound) { + fee = bounds.lowerBound; + } + + // if upper bound is not set, fee is % of token amount + else if (fee > bounds.upperBound && bounds.upperBound > 0) { + fee = bounds.upperBound; + } + + return (fee, tokenAddress); + } + + /** + @notice Collects fee for deposit. + @param sender Sender of the deposit. + @param fromDomainID ID of the source chain. + @param destinationDomainID ID of chain deposit will be bridged to. + @param resourceID ResourceID to be used when making deposits. + @param depositData Additional data about the deposit. + @param feeData Additional data to be passed to the fee handler. + */ + function collectFee(address sender, uint8 fromDomainID, uint8 destinationDomainID, bytes32 resourceID, bytes calldata depositData, bytes calldata feeData) payable external override onlyBridgeOrRouter { + require(msg.value == 0, "collectFee: msg.value != 0"); + + (uint256 fee, address tokenAddress) = _calculateFee(sender, fromDomainID, destinationDomainID, resourceID, depositData, feeData); + lockERC20(tokenAddress, sender, address(this), fee); + + emit FeeCollected(sender, fromDomainID, destinationDomainID, resourceID, fee, tokenAddress); + } + + /** + @notice Sets new value for lower and upper fee bounds, both are in token amount. + @notice Only callable by admin. + @param resourceID ResourceID for which new fee bounds will be set. + @param newLowerBound Value {_newLowerBound} will be updated to. + @param newUpperBound Value {_newUpperBound} will be updated to. + */ + function changeFeeBounds(bytes32 resourceID, uint128 newLowerBound, uint128 newUpperBound) external onlyAdmin { + require(newUpperBound == 0 || (newUpperBound > newLowerBound), "Upper bound must be larger than lower bound or 0"); + Bounds memory existingBounds = _resourceIDToFeeBounds[resourceID]; + require(existingBounds.lowerBound != newLowerBound || + existingBounds.upperBound != newUpperBound, + "Current bounds are equal to new bounds" + ); + + Bounds memory newBounds = Bounds(newLowerBound, newUpperBound); + _resourceIDToFeeBounds[resourceID] = newBounds; + + emit FeeBoundsChanged(newLowerBound, newUpperBound); + } + + /** + @notice Transfers tokens from the contract to the specified addresses. The parameters addrs and amounts are mapped 1-1. + This means that the address at index 0 for addrs will receive the amount of tokens from amounts at index 0. + @param resourceID ResourceID of the token. + @param addrs Array of addresses to transfer {amounts} to. + @param amounts Array of amounts to transfer to {addrs}. + */ + function transferERC20Fee(bytes32 resourceID, address[] calldata addrs, uint[] calldata amounts) external onlyAdmin { + require(addrs.length == amounts.length, "addrs[], amounts[]: diff length"); + address tokenHandler = IBridge(_bridgeAddress)._resourceIDToHandlerAddress(resourceID); + address tokenAddress = IERCHandler(tokenHandler)._resourceIDToTokenContractAddress(resourceID); + for (uint256 i = 0; i < addrs.length; i++) { + releaseERC20(tokenAddress, addrs[i], amounts[i]); + emit FeeDistributed(tokenAddress, addrs[i], amounts[i]); + } + } +} diff --git a/migrations/2_deploy_contracts.js b/migrations/2_deploy_contracts.js index 7dd6af12..b8e93513 100644 --- a/migrations/2_deploy_contracts.js +++ b/migrations/2_deploy_contracts.js @@ -19,6 +19,7 @@ const PermissionedGenericHandlerContract = artifacts.require( const FeeRouterContract = artifacts.require("FeeHandlerRouter"); const BasicFeeHandlerContract = artifacts.require("BasicFeeHandler"); const DynamicFeeHandlerContract = artifacts.require("DynamicERC20FeeHandlerEVM"); +const PercentageFeeHandler = artifacts.require("PercentageERC20FeeHandlerEVM"); module.exports = async function (deployer, network) { const networksConfig = Utils.getNetworksConfig(); @@ -76,6 +77,11 @@ module.exports = async function (deployer, network) { bridgeInstance.address, feeRouterInstance.address ); + const percentageFeeHandlerInstance = await deployer.deploy( + PercentageFeeHandler, + bridgeInstance.address, + feeRouterInstance.address + ) // setup fee router and fee handlers await bridgeInstance.adminChangeFeeHandler(feeRouterInstance.address); @@ -89,6 +95,9 @@ module.exports = async function (deployer, network) { await basicFeeHandlerInstance.changeFee( Ethers.utils.parseEther(currentNetworkConfig.fee.basic.fee).toString() ); + await percentageFeeHandlerInstance.changeFee( + currentNetworkConfig.fee.percentage.fee + ) console.table({ "Deployer Address": deployerAddress, @@ -101,6 +110,7 @@ module.exports = async function (deployer, network) { "FeeRouterContract Address": feeRouterInstance.address, "BasicFeeHandler Address": basicFeeHandlerInstance.address, "DynamicFeeHandler Address": dynamicFeeHandlerInstance.address, + "PercentageFeeHandler Address": percentageFeeHandlerInstance.address }); // setup erc20 tokens @@ -116,8 +126,14 @@ module.exports = async function (deployer, network) { feeRouterInstance, dynamicFeeHandlerInstance, basicFeeHandlerInstance, + percentageFeeHandlerInstance, erc20 ); + await percentageFeeHandlerInstance.changeFeeBounds( + erc20.resourceID, + Ethers.utils.parseEther(currentNetworkConfig.fee.percentage.lowerBound).toString(), + Ethers.utils.parseEther(currentNetworkConfig.fee.percentage.upperBound).toString() + ) console.log( "-------------------------------------------------------------------------------" @@ -143,6 +159,7 @@ module.exports = async function (deployer, network) { feeRouterInstance, dynamicFeeHandlerInstance, basicFeeHandlerInstance, + percentageFeeHandlerInstance, erc721 ); @@ -168,6 +185,7 @@ module.exports = async function (deployer, network) { feeRouterInstance, dynamicFeeHandlerInstance, basicFeeHandlerInstance, + percentageFeeHandlerInstance, generic ); diff --git a/migrations/3_deploy_permissionlessGenericHandler.js b/migrations/3_deploy_permissionlessGenericHandler.js index aa71506c..dac35a04 100644 --- a/migrations/3_deploy_permissionlessGenericHandler.js +++ b/migrations/3_deploy_permissionlessGenericHandler.js @@ -11,6 +11,7 @@ const PermissionlessGenericHandlerContract = artifacts.require( const FeeRouterContract = artifacts.require("FeeHandlerRouter"); const BasicFeeHandlerContract = artifacts.require("BasicFeeHandler"); const DynamicFeeHandlerContract = artifacts.require("DynamicERC20FeeHandlerEVM"); +const PercentageFeeHandler = artifacts.require("PercentageERC20FeeHandlerEVM"); module.exports = async function (deployer, network) { const networksConfig = Utils.getNetworksConfig(); @@ -23,6 +24,7 @@ module.exports = async function (deployer, network) { const bridgeInstance = await BridgeContract.deployed(); const feeRouterInstance = await FeeRouterContract.deployed(); const basicFeeHandlerInstance = await BasicFeeHandlerContract.deployed(); + const percentageFeeHandlerInstance = await PercentageFeeHandler.deployed(); const dynamicFeeHandlerInstance = await DynamicFeeHandlerContract.deployed(); @@ -71,6 +73,7 @@ module.exports = async function (deployer, network) { feeRouterInstance, dynamicFeeHandlerInstance, basicFeeHandlerInstance, + percentageFeeHandlerInstance, currentNetworkConfig.permissionlessGeneric ); } diff --git a/migrations/4_deploy_xc20_contracts.js b/migrations/4_deploy_xc20_contracts.js index c0de1b00..acb95248 100644 --- a/migrations/4_deploy_xc20_contracts.js +++ b/migrations/4_deploy_xc20_contracts.js @@ -8,6 +8,8 @@ const XC20HandlerContract = artifacts.require("XC20Handler"); const FeeRouterContract = artifacts.require("FeeHandlerRouter"); const BasicFeeHandlerContract = artifacts.require("BasicFeeHandler"); const DynamicFeeHandlerContract = artifacts.require("DynamicERC20FeeHandlerEVM"); +const PercentageFeeHandler = artifacts.require("PercentageERC20FeeHandlerEVM"); + module.exports = async function (deployer, network) { // trim suffix from network name and fetch current network config @@ -29,6 +31,7 @@ module.exports = async function (deployer, network) { const basicFeeHandlerInstance = await BasicFeeHandlerContract.deployed(); const dynamicFeeHandlerInstance = await DynamicFeeHandlerContract.deployed(); + const percentageFeeHandlerInstance = await PercentageFeeHandler.deployed(); // deploy XC20 contracts await deployer.deploy(XC20HandlerContract, bridgeInstance.address); @@ -42,6 +45,7 @@ module.exports = async function (deployer, network) { feeRouterInstance, dynamicFeeHandlerInstance, basicFeeHandlerInstance, + percentageFeeHandlerInstance, xc20 ); diff --git a/migrations/local.json b/migrations/local.json index 91dd1f7f..5e480264 100644 --- a/migrations/local.json +++ b/migrations/local.json @@ -10,6 +10,11 @@ }, "basic": { "fee": "0.001" + }, + "percentage":{ + "fee": "10000", + "lowerBound": "10", + "upperBound": "30" } }, "access": { diff --git a/migrations/utils.js b/migrations/utils.js index 1d91c410..3eb32e39 100644 --- a/migrations/utils.js +++ b/migrations/utils.js @@ -31,6 +31,7 @@ async function setupFee( feeRouterInstance, dynamicFeeHandlerInstance, basicFeeHandlerInstance, + percentageFeeHandlerInstance, token ) { for await (const network of Object.values(networksConfig)) { @@ -40,12 +41,18 @@ async function setupFee( token.resourceID, dynamicFeeHandlerInstance.address ); - } else { + } else if (token.feeType == "basic") { await feeRouterInstance.adminSetResourceHandler( network.domainID, token.resourceID, basicFeeHandlerInstance.address ); + } else { + await feeRouterInstance.adminSetResourceHandler( + network.domainID, + token.resourceID, + percentageFeeHandlerInstance.address + ) } } } diff --git a/package.json b/package.json index 36460193..1a37de32 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build/contracts/PermissionlessGenericHandler.json", "build/contracts/BasicFeeHandler.json", "build/contracts/DynamicERC20FeeHandlerEVM.json", + "build/contracts/PercentageFeeHandlerEVM.json", "contracts/interfaces" ], "directories": { diff --git a/test/handlers/fee/percentage/admin.js b/test/handlers/fee/percentage/admin.js new file mode 100644 index 00000000..8bea29d2 --- /dev/null +++ b/test/handlers/fee/percentage/admin.js @@ -0,0 +1,112 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only + +const TruffleAssert = require("truffle-assertions"); + +const Helpers = require("../../../helpers"); + +const PercentageFeeHandlerContract = artifacts.require("PercentageERC20FeeHandlerEVM"); +const FeeHandlerRouterContract = artifacts.require("FeeHandlerRouter"); +const ERC20MintableContract = artifacts.require("ERC20PresetMinterPauser"); + + +contract("PercentageFeeHandler - [admin]", async (accounts) => { + const domainID = 1; + const initialRelayers = accounts.slice(0, 3); + const currentFeeHandlerAdmin = accounts[0]; + + const assertOnlyAdmin = (method, ...params) => { + return TruffleAssert.reverts( + method(...params, {from: initialRelayers[1]}), + "sender doesn't have admin role" + ); + }; + + let BridgeInstance; + let PercentageFeeHandlerInstance; + let ERC20MintableInstance; + let ADMIN_ROLE; + let resourceID; + + beforeEach(async () => { + await Promise.all([ + (BridgeInstance = await Helpers.deployBridge( + domainID, + accounts[0] + )), + ERC20MintableContract.new("token", "TOK").then( + (instance) => (ERC20MintableInstance = instance) + ), + ]); + + FeeHandlerRouterInstance = await FeeHandlerRouterContract.new( + BridgeInstance.address + ); + PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + + ADMIN_ROLE = await PercentageFeeHandlerInstance.DEFAULT_ADMIN_ROLE(); + + resourceID = Helpers.createResourceID(ERC20MintableInstance.address, domainID); + }); + + it("should set fee property", async () => { + const fee = 60000; + assert.equal(await PercentageFeeHandlerInstance._fee.call(), "0"); + await PercentageFeeHandlerInstance.changeFee(fee); + assert.equal(await PercentageFeeHandlerInstance._fee.call(), fee); + }); + + it("should require admin role to change fee property", async () => { + const fee = 600; + await assertOnlyAdmin(PercentageFeeHandlerInstance.changeFee, fee); + }); + + it("should set fee bounds", async () => { + const newLowerBound = "100"; + const newUpperBound = "300"; + assert.equal((await PercentageFeeHandlerInstance._resourceIDToFeeBounds.call(resourceID)).lowerBound, "0"); + assert.equal((await PercentageFeeHandlerInstance._resourceIDToFeeBounds.call(resourceID)).upperBound, "0"); + await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, newLowerBound, newUpperBound); + assert.equal( + (await PercentageFeeHandlerInstance._resourceIDToFeeBounds.call(resourceID)).lowerBound.toString(), + newLowerBound + ); + assert.equal( + (await PercentageFeeHandlerInstance._resourceIDToFeeBounds.call(resourceID)).upperBound.toString(), + newUpperBound + ); + }); + + it("should require admin role to change fee bounds", async () => { + const lowerBound = 100; + const upperBound = 300; + await assertOnlyAdmin(PercentageFeeHandlerInstance.changeFeeBounds, resourceID, lowerBound, upperBound); + }); + + it("PercentageFeeHandler admin should be changed to expectedPercentageFeeHandlerAdmin", async () => { + const expectedPercentageFeeHandlerAdmin = accounts[1]; + + // check current admin + assert.isTrue( + await PercentageFeeHandlerInstance.hasRole(ADMIN_ROLE, currentFeeHandlerAdmin) + ); + + await TruffleAssert.passes( + PercentageFeeHandlerInstance.renounceAdmin(expectedPercentageFeeHandlerAdmin) + ); + assert.isTrue( + await PercentageFeeHandlerInstance.hasRole( + ADMIN_ROLE, + expectedPercentageFeeHandlerAdmin + ) + ); + + // check that former admin is no longer admin + assert.isFalse( + await PercentageFeeHandlerInstance.hasRole(ADMIN_ROLE, currentFeeHandlerAdmin) + ); + }); +}); diff --git a/test/handlers/fee/percentage/calculateFee.js b/test/handlers/fee/percentage/calculateFee.js new file mode 100644 index 00000000..1dfbd364 --- /dev/null +++ b/test/handlers/fee/percentage/calculateFee.js @@ -0,0 +1,192 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only + +const Helpers = require("../../../helpers"); + +const ERC20MintableContract = artifacts.require("ERC20PresetMinterPauser"); +const ERC20HandlerContract = artifacts.require("ERC20Handler"); +const PercentageFeeHandlerContract = artifacts.require("PercentageERC20FeeHandlerEVM"); +const FeeHandlerRouterContract = artifacts.require("FeeHandlerRouter"); + +contract("PercentageFeeHandler - [calculateFee]", async (accounts) => { + const originDomainID = 1; + const destinationDomainID = 2; + const relayer = accounts[0]; + const recipientAddress = accounts[1]; + const feeData = "0x0"; + const emptySetResourceData = "0x"; + + let BridgeInstance; + let PercentageFeeHandlerInstance; + let resourceID; + let ERC20MintableInstance; + let FeeHandlerRouterInstance; + + beforeEach(async () => { + await Promise.all([ + (BridgeInstance = await Helpers.deployBridge( + destinationDomainID, + accounts[0] + )), + ERC20MintableContract.new("token", "TOK").then( + (instance) => (ERC20MintableInstance = instance) + ), + ]); + + ERC20HandlerInstance = await ERC20HandlerContract.new( + BridgeInstance.address + ); + FeeHandlerRouterInstance = await FeeHandlerRouterContract.new( + BridgeInstance.address + ); + PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + + resourceID = Helpers.createResourceID( + ERC20MintableInstance.address, + originDomainID + ); + initialResourceIDs = [resourceID]; + initialContractAddresses = [ERC20MintableInstance.address]; + + burnableContractAddresses = []; + + await Promise.all([ + BridgeInstance.adminSetResource( + ERC20HandlerInstance.address, + resourceID, + ERC20MintableInstance.address, + emptySetResourceData + ), + BridgeInstance.adminChangeFeeHandler(FeeHandlerRouterInstance.address), + FeeHandlerRouterInstance.adminSetResourceHandler( + destinationDomainID, + resourceID, + PercentageFeeHandlerInstance.address + ), + ]); + }); + + it(`should return percentage of token amount for fee if bounds + are set [lowerBound > 0, upperBound > 0]`, async () => { + const depositData = Helpers.createERCDepositData(100000000, 20, recipientAddress); + + // current fee is set to 0 + let res = await FeeHandlerRouterInstance.calculateFee.call( + relayer, + originDomainID, + destinationDomainID, + resourceID, + depositData, + feeData + ); + + assert.equal(res[0].toString(), "0"); + // Change fee to 1 BPS () + await PercentageFeeHandlerInstance.changeFee(10000); + await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, 100, 300000); + res = await FeeHandlerRouterInstance.calculateFee.call( + relayer, + originDomainID, + destinationDomainID, + resourceID, + depositData, + feeData + ); + assert.equal(res[0].toString(), "10000"); + }); + + it(`should return percentage of token amount for fee if bounds + are not set [lowerBound = 0, upperBound = 0]`, async () => { + const depositData = Helpers.createERCDepositData(100000000, 20, recipientAddress); + + // current fee is set to 0 + let res = await FeeHandlerRouterInstance.calculateFee.call( + relayer, + originDomainID, + destinationDomainID, + resourceID, + depositData, + feeData + ); + + assert.equal(res[0].toString(), "0"); + // Change fee to 1 BPS () + await PercentageFeeHandlerInstance.changeFee(10000); + res = await FeeHandlerRouterInstance.calculateFee.call( + relayer, + originDomainID, + destinationDomainID, + resourceID, + depositData, + feeData + ); + assert.equal(res[0].toString(), "10000"); + }); + + it("should return lower bound token amount for fee [lowerBound > 0, upperBound > 0]", async () => { + const depositData = Helpers.createERCDepositData(10000, 20, recipientAddress); + await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, 100, 300); + await PercentageFeeHandlerInstance.changeFee(10000); + + res = await FeeHandlerRouterInstance.calculateFee.call( + relayer, + originDomainID, + destinationDomainID, + resourceID, + depositData, + feeData + ); + assert.equal(res[0].toString(), "100"); + }); + + it("should return lower bound token amount for fee [lowerBound > 0, upperBound = 0]", async () => { + const depositData = Helpers.createERCDepositData(10000, 20, recipientAddress); + await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, 100, 0); + await PercentageFeeHandlerInstance.changeFee(10000); + + res = await FeeHandlerRouterInstance.calculateFee.call( + relayer, + originDomainID, + destinationDomainID, + resourceID, + depositData, + feeData + ); + assert.equal(res[0].toString(), "100"); + }); + + it("should return upper bound token amount for fee [lowerBound = 0, upperBound > 0]", async () => { + const depositData = Helpers.createERCDepositData(100000000, 20, recipientAddress); + await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, 0, 300); + await PercentageFeeHandlerInstance.changeFee(10000); + + res = await FeeHandlerRouterInstance.calculateFee.call( + relayer, + originDomainID, + destinationDomainID, + resourceID, + depositData, + feeData + ); + assert.equal(res[0].toString(), "300"); + }); + + it("should return percentage of token amount for fee [lowerBound = 0, upperBound > 0]", async () => { + const depositData = Helpers.createERCDepositData(100000, 20, recipientAddress); + await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, 0, 300); + await PercentageFeeHandlerInstance.changeFee(10000); + + res = await FeeHandlerRouterInstance.calculateFee.call( + relayer, + originDomainID, + destinationDomainID, + resourceID, + depositData, + feeData + ); + assert.equal(res[0].toString(), "10"); + }); +}); diff --git a/test/handlers/fee/percentage/changeFee.js b/test/handlers/fee/percentage/changeFee.js new file mode 100644 index 00000000..f57488f4 --- /dev/null +++ b/test/handlers/fee/percentage/changeFee.js @@ -0,0 +1,170 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only + +const TruffleAssert = require("truffle-assertions"); +const Ethers = require("ethers"); + +const Helpers = require("../../../helpers"); + +const PercentageFeeHandlerContract = artifacts.require("PercentageERC20FeeHandlerEVM"); +const FeeHandlerRouterContract = artifacts.require("FeeHandlerRouter"); +const ERC20MintableContract = artifacts.require("ERC20PresetMinterPauser"); + + +contract("PercentageFeeHandler - [change fee and bounds]", async (accounts) => { + const domainID = 1; + const nonAdmin = accounts[1]; + + let resourceID; + + const assertOnlyAdmin = (method, ...params) => { + return TruffleAssert.reverts( + method(...params, {from: nonAdmin}), + "sender doesn't have admin role" + ); + }; + + let BridgeInstance; + + beforeEach(async () => { + await Promise.all([ + (BridgeInstance = await Helpers.deployBridge( + domainID, + accounts[0] + )), + ERC20MintableContract.new("token", "TOK").then( + (instance) => (ERC20MintableInstance = instance) + ), + ]); + + FeeHandlerRouterInstance = await FeeHandlerRouterContract.new( + BridgeInstance.address + ); + + resourceID = Helpers.createResourceID( + ERC20MintableInstance.address, + domainID + ); + }); + + it("[sanity] contract should be deployed successfully", async () => { + TruffleAssert.passes( + await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ) + ); + }); + + it("should set fee", async () => { + const PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + const fee = Ethers.utils.parseUnits("25"); + const tx = await PercentageFeeHandlerInstance.changeFee(fee); + TruffleAssert.eventEmitted( + tx, + "FeeChanged", + (event) => { + return Ethers.utils.formatUnits(event.newFee.toString()) === "25.0" + } + ); + const newFee = await PercentageFeeHandlerInstance._fee.call(); + assert.equal(Ethers.utils.formatUnits(newFee.toString()), "25.0"); + }); + + it("should not set the same fee", async () => { + const PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + await TruffleAssert.reverts( + PercentageFeeHandlerInstance.changeFee(0), + "Current fee is equal to new fee" + ); + }); + + it("should require admin role to change fee", async () => { + const PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + await assertOnlyAdmin(PercentageFeeHandlerInstance.changeFee, 1); + }); + + it("should set fee bounds", async () => { + const PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + const tx = await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, 50, 100); + TruffleAssert.eventEmitted( + tx, + "FeeBoundsChanged", + (event) => { + return event.newLowerBound.toString() === "50" && + event.newUpperBound.toString() === "100" + } + ); + const newLowerBound = (await PercentageFeeHandlerInstance._resourceIDToFeeBounds.call(resourceID)).lowerBound + const newUpperBound = (await PercentageFeeHandlerInstance._resourceIDToFeeBounds.call(resourceID)).upperBound + assert.equal(newLowerBound.toString(), "50"); + assert.equal(newUpperBound.toString(), "100"); + }); + + it("should not set the same fee bounds", async () => { + const PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, 25, 50) + await TruffleAssert.reverts( + PercentageFeeHandlerInstance.changeFeeBounds(resourceID, 25, 50), + "Current bounds are equal to new bounds" + ); + }); + + it("should fail to set lower bound larger than upper bound ", async () => { + const PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + await TruffleAssert.reverts( + PercentageFeeHandlerInstance.changeFeeBounds(resourceID, 50, 25), + "Upper bound must be larger than lower bound or 0" + ); + }); + + it("should set only lower bound", async () => { + const newLowerBound = 30; + const PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, 25, 50); + await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, newLowerBound, 50); + const currentLowerBound = (await PercentageFeeHandlerInstance._resourceIDToFeeBounds.call(resourceID)).lowerBound; + assert.equal(currentLowerBound, newLowerBound); + }); + + it("should set only upper bound", async () => { + const newUpperBound = 100; + const PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, 25, 50); + await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, 25, newUpperBound); + const currentUpperBound = (await PercentageFeeHandlerInstance._resourceIDToFeeBounds.call(resourceID)).upperBound; + assert.equal(newUpperBound, currentUpperBound); + }); + + it("should require admin role to change fee bunds", async () => { + const PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + await assertOnlyAdmin(PercentageFeeHandlerInstance.changeFeeBounds, resourceID, 50, 100); + }); +}); diff --git a/test/handlers/fee/percentage/collectFee.js b/test/handlers/fee/percentage/collectFee.js new file mode 100644 index 00000000..1e6d5d7f --- /dev/null +++ b/test/handlers/fee/percentage/collectFee.js @@ -0,0 +1,291 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only + +const TruffleAssert = require("truffle-assertions"); +const Ethers = require("ethers"); + +const Helpers = require("../../../helpers"); + +const ERC20MintableContract = artifacts.require("ERC20PresetMinterPauser"); +const ERC20HandlerContract = artifacts.require("ERC20Handler"); +const PercentageFeeHandlerContract = artifacts.require("PercentageERC20FeeHandlerEVM"); +const FeeHandlerRouterContract = artifacts.require("FeeHandlerRouter"); + +contract("PercentageFeeHandler - [collectFee]", async (accounts) => { + const originDomainID = 1; + const destinationDomainID = 2; + const recipientAddress = accounts[2]; + const tokenAmount = Ethers.utils.parseEther("200000"); + const depositorAddress = accounts[1]; + + const emptySetResourceData = "0x"; + const feeData = "0x"; + const feeBps = 60000; // BPS + const fee = Ethers.utils.parseEther("120"); + const lowerBound = Ethers.utils.parseEther("100"); + const upperBound = Ethers.utils.parseEther("300"); + + + let BridgeInstance; + let PercentageFeeHandlerInstance; + let resourceID; + let depositData; + + let FeeHandlerRouterInstance; + let ERC20HandlerInstance; + let ERC20MintableInstance; + + + beforeEach(async () => { + await Promise.all([ + (BridgeInstance = await Helpers.deployBridge( + originDomainID, + accounts[0] + )), + (ERC20MintableInstance = ERC20MintableContract.new( + "ERC20Token", + "ERC20TOK" + ).then((instance) => (ERC20MintableInstance = instance))), + ]); + + ERC20HandlerInstance = await ERC20HandlerContract.new( + BridgeInstance.address + ); + FeeHandlerRouterInstance = await FeeHandlerRouterContract.new( + BridgeInstance.address + ); + PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + + resourceID = Helpers.createResourceID( + ERC20MintableInstance.address, + originDomainID + ); + + await PercentageFeeHandlerInstance.changeFee(feeBps); + await PercentageFeeHandlerInstance.changeFeeBounds(resourceID, lowerBound, upperBound); + + await Promise.all([ + BridgeInstance.adminSetResource( + ERC20HandlerInstance.address, + resourceID, + ERC20MintableInstance.address, + emptySetResourceData + ), + ERC20MintableInstance.mint(depositorAddress, tokenAmount + fee), + ERC20MintableInstance.approve(ERC20HandlerInstance.address, tokenAmount, { + from: depositorAddress, + }), + ERC20MintableInstance.approve(PercentageFeeHandlerInstance.address, fee, { + from: depositorAddress, + }), + BridgeInstance.adminChangeFeeHandler(FeeHandlerRouterInstance.address), + FeeHandlerRouterInstance.adminSetResourceHandler( + destinationDomainID, + resourceID, + PercentageFeeHandlerInstance.address + ), + ]); + + depositData = Helpers.createERCDepositData( + tokenAmount, + 20, + recipientAddress + ); + + // set MPC address to unpause the Bridge + await BridgeInstance.endKeygen(Helpers.mpcAddress); + }); + + it("should collect fee in tokens", async () => { + const balanceBefore = ( + await ERC20MintableInstance.balanceOf( + PercentageFeeHandlerInstance.address + ) + ).toString(); + + const depositTx = await BridgeInstance.deposit( + destinationDomainID, + resourceID, + depositData, + feeData, + { + from: depositorAddress, + } + ); + TruffleAssert.eventEmitted(depositTx, "Deposit", (event) => { + return ( + event.destinationDomainID.toNumber() === destinationDomainID && + event.resourceID === resourceID.toLowerCase() + ); + }); + const internalTx = await TruffleAssert.createTransactionResult( + PercentageFeeHandlerInstance, + depositTx.tx + ); + TruffleAssert.eventEmitted(internalTx, "FeeCollected", (event) => { + return ( + event.sender === depositorAddress && + event.fromDomainID.toNumber() === originDomainID && + event.destinationDomainID.toNumber() === destinationDomainID && + event.resourceID === resourceID.toLowerCase() && + event.fee.toString() === fee.toString() && + event.tokenAddress === ERC20MintableInstance.address + ); + }); + const balanceAfter = ( + await ERC20MintableInstance.balanceOf( + PercentageFeeHandlerInstance.address + ) + ).toString(); + assert.equal(balanceAfter, fee.add(balanceBefore).toString()); + }); + + it("deposit should revert if msg.value != 0", async () => { + await TruffleAssert.reverts( + BridgeInstance.deposit( + destinationDomainID, + resourceID, + depositData, + feeData, + { + from: depositorAddress, + value: Ethers.utils.parseEther("0.5").toString(), + } + ), + "msg.value != 0" + ); + }); + + it("deposit should revert if fee collection fails", async () => { + const depositData = Helpers.createERCDepositData( + tokenAmount, + 20, + recipientAddress + ); + + await ERC20MintableInstance.approve( + PercentageFeeHandlerInstance.address, + 0, + {from: depositorAddress} + ); + await TruffleAssert.reverts( + BridgeInstance.deposit( + destinationDomainID, + resourceID, + depositData, + feeData, + { + from: depositorAddress, + value: Ethers.utils.parseEther("0.5").toString(), + } + ) + ); + }); + + it("deposit should revert if not called by router on PercentageFeeHandler contract", async () => { + const depositData = Helpers.createERCDepositData( + tokenAmount, + 20, + recipientAddress + ); + await ERC20MintableInstance.approve( + PercentageFeeHandlerInstance.address, + 0, + {from: depositorAddress} + ); + await TruffleAssert.reverts( + PercentageFeeHandlerInstance.collectFee( + depositorAddress, + originDomainID, + destinationDomainID, + resourceID, + depositData, + feeData, + { + from: depositorAddress, + value: Ethers.utils.parseEther("0.5").toString(), + } + ), + "sender must be bridge or fee router contract" + ); + }); + + it("deposit should revert if not called by bridge on FeeHandlerRouter contract", async () => { + const depositData = Helpers.createERCDepositData( + tokenAmount, + 20, + recipientAddress + ); + await ERC20MintableInstance.approve( + PercentageFeeHandlerInstance.address, + 0, + {from: depositorAddress} + ); + await TruffleAssert.reverts( + FeeHandlerRouterInstance.collectFee( + depositorAddress, + originDomainID, + destinationDomainID, + resourceID, + depositData, + feeData, + { + from: depositorAddress, + value: Ethers.utils.parseEther("0.5").toString(), + } + ), + "sender must be bridge contract" + ); + }); + + it("should successfully change fee handler from FeeRouter to PercentageFeeHandler and collect fee", async () => { + await BridgeInstance.adminChangeFeeHandler( + PercentageFeeHandlerInstance.address + ); + + const balanceBefore = ( + await ERC20MintableInstance.balanceOf( + PercentageFeeHandlerInstance.address + ) + ).toString(); + + const depositTx = await BridgeInstance.deposit( + destinationDomainID, + resourceID, + depositData, + feeData, + { + from: depositorAddress, + } + ); + TruffleAssert.eventEmitted(depositTx, "Deposit", (event) => { + return ( + event.destinationDomainID.toNumber() === destinationDomainID && + event.resourceID === resourceID.toLowerCase() + ); + }); + const internalTx = await TruffleAssert.createTransactionResult( + PercentageFeeHandlerInstance, + depositTx.tx + ); + TruffleAssert.eventEmitted(internalTx, "FeeCollected", (event) => { + return ( + event.sender === depositorAddress && + event.fromDomainID.toNumber() === originDomainID && + event.destinationDomainID.toNumber() === destinationDomainID && + event.resourceID === resourceID.toLowerCase() && + event.fee.toString() === fee.toString() && + event.tokenAddress === ERC20MintableInstance.address + ); + }); + const balanceAfter = ( + await ERC20MintableInstance.balanceOf( + PercentageFeeHandlerInstance.address + ) + ).toString(); + assert.equal(balanceAfter, fee.add(balanceBefore).toString()); + }); +}); diff --git a/test/handlers/fee/percentage/distributeFee.js b/test/handlers/fee/percentage/distributeFee.js new file mode 100644 index 00000000..7af086ad --- /dev/null +++ b/test/handlers/fee/percentage/distributeFee.js @@ -0,0 +1,239 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only +const TruffleAssert = require("truffle-assertions"); + +const Helpers = require("../../../helpers"); +const Ethers = require("ethers"); + +const ERC20MintableContract = artifacts.require("ERC20PresetMinterPauser"); +const ERC20HandlerContract = artifacts.require("ERC20Handler"); +const PercentageFeeHandlerContract = artifacts.require("PercentageERC20FeeHandlerEVM"); +const FeeHandlerRouterContract = artifacts.require("FeeHandlerRouter"); + +contract("PercentageFeeHandler - [distributeFee]", async (accounts) => { + const originDomainID = 1; + const destinationDomainID = 2; + const depositorAddress = accounts[1]; + const recipientAddress = accounts[2]; + + const depositAmount = 100000; + const feeData = "0x"; + const emptySetResourceData = "0x"; + const feeAmount = 30; + const feeBps = 30000; // 3 BPS + const payout = Ethers.BigNumber.from("10"); + + let BridgeInstance; + let ERC20MintableInstance; + let ERC20HandlerInstance; + let PercentageFeeHandlerInstance; + let FeeHandlerRouterInstance; + + let resourceID; + let depositData; + + const assertOnlyAdmin = (method, ...params) => { + return TruffleAssert.reverts( + method(...params, {from: accounts[1]}), + "sender doesn't have admin role" + ); + }; + + beforeEach(async () => { + await Promise.all([ + (BridgeInstance = await Helpers.deployBridge( + originDomainID, + accounts[0] + )), + ERC20MintableContract.new("token", "TOK").then( + (instance) => (ERC20MintableInstance = instance) + ), + FeeHandlerRouterInstance = await FeeHandlerRouterContract.new( + BridgeInstance.address + ), + PercentageFeeHandlerInstance = await PercentageFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ) + ]); + + resourceID = Helpers.createResourceID( + ERC20MintableInstance.address, + originDomainID + ); + + ERC20HandlerInstance = await ERC20HandlerContract.new( + BridgeInstance.address + ); + + await Promise.all([ + BridgeInstance.adminSetResource( + ERC20HandlerInstance.address, + resourceID, + ERC20MintableInstance.address, + emptySetResourceData + ), + ERC20MintableInstance.mint(depositorAddress, depositAmount + feeAmount), + ERC20MintableInstance.approve(ERC20HandlerInstance.address, depositAmount, { + from: depositorAddress, + }), + ERC20MintableInstance.approve( + PercentageFeeHandlerInstance.address, + depositAmount, + {from: depositorAddress} + ), + BridgeInstance.adminChangeFeeHandler(FeeHandlerRouterInstance.address), + FeeHandlerRouterInstance.adminSetResourceHandler( + destinationDomainID, + resourceID, + PercentageFeeHandlerInstance.address + ), + PercentageFeeHandlerInstance.changeFee(feeBps) + ]); + + depositData = Helpers.createERCDepositData( + depositAmount, + 20, + recipientAddress + ); + + // set MPC address to unpause the Bridge + await BridgeInstance.endKeygen(Helpers.mpcAddress); + }); + + it("should distribute fees", async () => { + // check the balance is 0 + const b1Before = ( + await ERC20MintableInstance.balanceOf(accounts[3]) + ).toString(); + const b2Before = ( + await ERC20MintableInstance.balanceOf(accounts[4]) + ).toString(); + + await TruffleAssert.passes( + BridgeInstance.deposit( + destinationDomainID, + resourceID, + depositData, + feeData, + { + from: depositorAddress, + } + ) + ); + const balance = await ERC20MintableInstance.balanceOf( + PercentageFeeHandlerInstance.address + ); + assert.equal(balance, feeAmount); + + // Transfer the funds + const tx = await PercentageFeeHandlerInstance.transferERC20Fee( + resourceID, + [accounts[3], accounts[4]], + [payout, payout] + ); + TruffleAssert.eventEmitted(tx, "FeeDistributed", (event) => { + return ( + event.tokenAddress === ERC20MintableInstance.address && + event.recipient === accounts[3] && + event.amount.toString() === payout.toString() + ); + }); + TruffleAssert.eventEmitted(tx, "FeeDistributed", (event) => { + return ( + event.tokenAddress === ERC20MintableInstance.address && + event.recipient === accounts[4] && + event.amount.toString() === payout.toString() + ); + }); + b1 = await ERC20MintableInstance.balanceOf(accounts[3]); + b2 = await ERC20MintableInstance.balanceOf(accounts[4]); + assert.equal(b1.toString(), payout.add(b1Before).toString()); + assert.equal(b2.toString(), payout.add(b2Before).toString()); + }); + + it("should not distribute fees with other resourceID", async () => { + await TruffleAssert.passes( + BridgeInstance.deposit( + destinationDomainID, + resourceID, + depositData, + feeData, + { + from: depositorAddress, + } + ) + ); + const balance = await ERC20MintableInstance.balanceOf( + PercentageFeeHandlerInstance.address + ); + assert.equal(balance, feeAmount); + + // Incorrect resourceID + resourceID = Helpers.createResourceID( + PercentageFeeHandlerInstance.address, + originDomainID + ); + + // Transfer the funds: fails + await TruffleAssert.reverts( + PercentageFeeHandlerInstance.transferERC20Fee( + resourceID, + [accounts[3], accounts[4]], + [payout, payout] + ) + ); + }); + + it("should require admin role to distribute fee", async () => { + await TruffleAssert.passes( + BridgeInstance.deposit( + destinationDomainID, + resourceID, + depositData, + feeData, + { + from: depositorAddress, + } + ) + ); + const balance = await ERC20MintableInstance.balanceOf( + PercentageFeeHandlerInstance.address + ); + assert.equal(balance, feeAmount); + + await assertOnlyAdmin( + PercentageFeeHandlerInstance.transferERC20Fee, + resourceID, + [accounts[3], accounts[4]], + [payout.toNumber(), payout.toNumber()] + ); + }); + + it("should revert if addrs and amounts arrays have different length", async () => { + await TruffleAssert.passes( + BridgeInstance.deposit( + destinationDomainID, + resourceID, + depositData, + feeData, + { + from: depositorAddress, + } + ) + ); + const balance = await ERC20MintableInstance.balanceOf( + PercentageFeeHandlerInstance.address + ); + assert.equal(balance, feeAmount); + + await TruffleAssert.reverts( + PercentageFeeHandlerInstance.transferERC20Fee( + resourceID, + [accounts[3], accounts[4]], + [payout, payout, payout] + ), + "addrs[], amounts[]: diff length" + ); + }); +});