diff --git a/contracts/TestContracts.sol b/contracts/TestContracts.sol index cbaccaf3..b185aee4 100644 --- a/contracts/TestContracts.sol +++ b/contracts/TestContracts.sol @@ -3,8 +3,11 @@ pragma solidity 0.8.11; import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./handlers/ERCHandlerHelpers.sol"; import "./interfaces/IERC20Plus.sol"; +import "./interfaces/ISocialNetworkController.sol"; + contract NoArgument { event NoArgumentCalled(); @@ -237,3 +240,25 @@ contract TestDeposit { emit TestExecute(depositor, num, addresses[1], message); } } + +contract SocialNetworkControllerMock { + uint256 public constant HEART_BTC = 369; + uint256 public bitcoinStaked = 0; + address public _socialNetworkBitcoin; + + event Stake(address indexed user, uint256 amount); + + function setSocialNetworkBitcoinAddress(address socialNetworkBitcoin) public { + _socialNetworkBitcoin = socialNetworkBitcoin; + } + + function mint(address recipient, uint256 amount) public { + IERC20Plus(_socialNetworkBitcoin).mint(recipient, amount); + } + + function stakeBTC(uint256 amount, address recipient) external { + uint256 mintAmount = amount * HEART_BTC; + bitcoinStaked += amount; + emit Stake(recipient, mintAmount); + } +} diff --git a/contracts/adapters/SocialNetworkAdapter.sol b/contracts/adapters/SocialNetworkAdapter.sol new file mode 100644 index 00000000..9c4a4ed4 --- /dev/null +++ b/contracts/adapters/SocialNetworkAdapter.sol @@ -0,0 +1,53 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.11; + +import "../interfaces/IBridge.sol"; +import "../interfaces/IERCHandler.sol"; +import "../interfaces/ISocialNetworkController.sol"; +import "../interfaces/ISocialNetworkBitcoin.sol"; +import "../interfaces/ISocialNetworkPercentageFeeHandler.sol"; +import "../handlers/fee/SocialNetworkPercentageFeeHandler.sol"; + + +contract SocialNetworkAdapter { + + address public immutable _permissionlessHandler; + ISocialNetworkController public immutable _socialNetworkController; + ISocialNetworkPercentageFeeHandler public immutable _feeHandler; + + mapping(string => mapping(address => uint256)) public _btcToEthDepositorToStakedAmount; + + + function _onlyPermissionlessHandler() private view { + require(msg.sender == _permissionlessHandler, "sender must be GMP contract"); + } + + modifier onlyPermissionlessHandler() { + _onlyPermissionlessHandler(); + _; + } + + constructor ( + address permissionlessHandler, + ISocialNetworkPercentageFeeHandler feeHandler, + ISocialNetworkController socialNetworkController + ) { + _permissionlessHandler = permissionlessHandler; + _socialNetworkController = socialNetworkController; + _feeHandler = feeHandler; + } + + event TestExecute(address depositor, uint256 depositAmount, string btcDepositorAddress); + + function stakeBTC (address ethDepositorAddress, bytes calldata data) external onlyPermissionlessHandler { + (uint256 amount, string memory btcDepositorAddress) = abi.decode(data, (uint256, string)); + + (uint256 fee) = _feeHandler.calculateFee(amount); + uint256 stakedAmount = amount - fee; + + _btcToEthDepositorToStakedAmount[btcDepositorAddress][ethDepositorAddress] = stakedAmount; + _socialNetworkController.mint(address(_feeHandler), fee); + _socialNetworkController.stakeBTC(amount, ethDepositorAddress); + } +} diff --git a/contracts/handlers/fee/SocialNetworkPercentageFeeHandler.sol b/contracts/handlers/fee/SocialNetworkPercentageFeeHandler.sol new file mode 100644 index 00000000..25d0c10a --- /dev/null +++ b/contracts/handlers/fee/SocialNetworkPercentageFeeHandler.sol @@ -0,0 +1,129 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.11; + +import "../../utils/AccessControl.sol"; +import {ERC20Safe} from "../../ERC20Safe.sol"; + +/** + @title Handles deposit fees. + @author ChainSafe Systems. + @notice This contract is intended to be used with the Bridge contract. + */ +contract SocialNetworkPercentageFeeHandler is ERC20Safe, AccessControl { + uint32 public constant HUNDRED_PERCENT = 1e8; + uint256 public _fee; + address public _socialNetworkBitcoin; + + struct Bounds { + uint128 lowerBound; // min fee in token amount + uint128 upperBound; // max fee in token amount + } + + Bounds public _feeBounds; + + event FeeChanged(uint256 newFee); + event FeeBoundsChanged(uint256 newLowerBound, uint256 newUpperBound); + /** + @notice This event is emitted when the fee is distributed to an address. + @param recipient Address that receives the distributed fee. + @param amount Amount that is distributed. + */ + event FeeDistributed( + address recipient, + uint256 amount + ); + + modifier onlyAdmin() { + require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "sender doesn't have admin role"); + _; + } + + + constructor () { + _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + + /** + @notice Calculates fee for deposit. + @param depositAmount Additional data to be passed to the fee handler. + @return fee Returns the fee amount. + */ + function calculateFee(uint256 depositAmount) external view returns(uint256 fee) { + return _calculateFee(depositAmount); + } + + function _calculateFee(uint256 depositAmount) internal view returns(uint256 fee) { + Bounds memory bounds = _feeBounds; + + 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; + } + + // Admin functions + + + /** + @notice Sets Social Network Bitcoin address.. + @notice Only callable by admin. + @param socialNetworkBitcoin Value {_socialNetworkBitcoin} that will be set. + */ + function setSocialNetworkBitcoinAddress(address socialNetworkBitcoin) external onlyAdmin { + _socialNetworkBitcoin = socialNetworkBitcoin; + } + + /** + @notice Sets new value for lower and upper fee bounds, both are in token amount. + @notice Only callable by admin. + @param newLowerBound Value {_newLowerBound} will be updated to. + @param newUpperBound Value {_newUpperBound} will be updated to. + */ + function changeFeeBounds(uint128 newLowerBound, uint128 newUpperBound) external onlyAdmin { + require(newUpperBound == 0 || (newUpperBound > newLowerBound), "Upper bound must be larger than lower bound or 0"); + Bounds memory existingBounds = _feeBounds; + require(existingBounds.lowerBound != newLowerBound || + existingBounds.upperBound != newUpperBound, + "Current bounds are equal to new bounds" + ); + + Bounds memory newBounds = Bounds(newLowerBound, newUpperBound); + _feeBounds = newBounds; + + emit FeeBoundsChanged(newLowerBound, newUpperBound); + } + + /** + @notice Only callable by admin. + @param newFee Value to which fee will be updated to for the provided {destinantionDomainID} and {resourceID}. + */ + function changeFee(uint256 newFee) external onlyAdmin { + require(_fee != newFee, "Current fee is equal to new fee"); + _fee = newFee; + emit FeeChanged(newFee); + } + + /** + @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 addrs Array of addresses to transfer {amounts} to. + @param amounts Array of amounts to transfer to {addrs}. + */ + function transferERC20Fee(address[] calldata addrs, uint[] calldata amounts) external onlyAdmin { + require(addrs.length == amounts.length, "addrs[], amounts[]: diff length"); + for (uint256 i = 0; i < addrs.length; i++) { + releaseERC20(_socialNetworkBitcoin, addrs[i], amounts[i]); + emit FeeDistributed(addrs[i], amounts[i]); + } + } +} diff --git a/contracts/interfaces/ISocialNetworkBitcoin.sol b/contracts/interfaces/ISocialNetworkBitcoin.sol new file mode 100644 index 00000000..4196edbb --- /dev/null +++ b/contracts/interfaces/ISocialNetworkBitcoin.sol @@ -0,0 +1,11 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.11; + +/** + @title Interface for SocialNetwork adapter. + @author ChainSafe Systems. + */ +interface ISocialNetworkBitcoin { + function mint(address to, uint256 amount) external; +} diff --git a/contracts/interfaces/ISocialNetworkController.sol b/contracts/interfaces/ISocialNetworkController.sol new file mode 100644 index 00000000..3e4b4673 --- /dev/null +++ b/contracts/interfaces/ISocialNetworkController.sol @@ -0,0 +1,13 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.11; + +/** + @title Interface for SocialNetwork adapter. + @author ChainSafe Systems. + */ +interface ISocialNetworkController { + function _socialNetworkBitcoin() external returns (address); + function stakeBTC (uint256 amount, address ethDepositorAddress) external; + function mint(address recipient, uint256 amount) external; +} diff --git a/contracts/interfaces/ISocialNetworkPercentageFeeHandler.sol b/contracts/interfaces/ISocialNetworkPercentageFeeHandler.sol new file mode 100644 index 00000000..0ef13e7a --- /dev/null +++ b/contracts/interfaces/ISocialNetworkPercentageFeeHandler.sol @@ -0,0 +1,12 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.11; + + +/** + @title Interface for SocialNetwork adapter. + @author ChainSafe Systems. + */ +interface ISocialNetworkPercentageFeeHandler { + function calculateFee (uint256 depositAmount) external returns(uint256 fee); +} diff --git a/test/adapters/socialNetwork/executeProposal.js b/test/adapters/socialNetwork/executeProposal.js new file mode 100644 index 00000000..b2e9969a --- /dev/null +++ b/test/adapters/socialNetwork/executeProposal.js @@ -0,0 +1,199 @@ +// 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 PermissionlessGenericHandlerContract = artifacts.require( + "PermissionlessGenericHandler" +); +const SocialAdapterContract = artifacts.require("SocialNetworkAdapter"); +const SocialNetworkPercentageFeeHandlerContract = artifacts.require("SocialNetworkPercentageFeeHandler"); +const SocialNetworkControllerMockContract = artifacts.require("SocialNetworkControllerMock"); + +contract( + "PermissionlessGenericHandler - Social network - [execute proposal]", + async (accounts) => { + const originDomainID = 1; + const destinationDomainID = 2; + const expectedDepositNonce = 1; + + const ethDepositorAddress = accounts[1]; + const relayer1Address = accounts[2]; + + const destinationMaxFee = 900000; + const depositAmount = 500; + const feeBps = 60000; // BPS + const fee = 120; + const lowerBound = 100; + const upperBound = 300; + + + let BridgeInstance; + let SocialNetworkAdapterInstance; + let SocialNetworkControllerMockInstance; + let ERC20MintableInstance; + let SocialNetworkPercentageFeeHandlerInstance; + let PermissionlessGenericHandlerInstance; + + let resourceID; + let depositFunctionSignature; + + beforeEach(async () => { + await Promise.all([ + (BridgeInstance = await Helpers.deployBridge( + destinationDomainID, + accounts[0] + )), + (ERC20MintableInstance = ERC20MintableContract.new( + "ERC20Token", + "ERC20TOK" + ).then((instance) => (ERC20MintableInstance = instance))), + ]); + + resourceID = Helpers.createResourceID( + ERC20MintableInstance.address, + originDomainID + ); + + PermissionlessGenericHandlerInstance = + await PermissionlessGenericHandlerContract.new(BridgeInstance.address); + + SocialNetworkPercentageFeeHandlerInstance = await SocialNetworkPercentageFeeHandlerContract.new(); + await SocialNetworkPercentageFeeHandlerInstance.setSocialNetworkBitcoinAddress(ERC20MintableInstance.address) + + SocialNetworkControllerMockInstance = await SocialNetworkControllerMockContract.new(); + SocialNetworkAdapterInstance = await SocialAdapterContract.new( + PermissionlessGenericHandlerInstance.address, + SocialNetworkPercentageFeeHandlerInstance.address, + SocialNetworkControllerMockInstance.address, + ); + + await Promise.all([ + ERC20MintableInstance.grantRole( + await ERC20MintableInstance.MINTER_ROLE(), + SocialNetworkControllerMockInstance.address + ), + ERC20MintableInstance.mint(ethDepositorAddress, depositAmount + fee), + ERC20MintableInstance.approve(SocialNetworkPercentageFeeHandlerInstance.address, fee, { + from: ethDepositorAddress, + }), + BridgeInstance.adminChangeFeeHandler(SocialNetworkPercentageFeeHandlerInstance.address), + ]); + + await SocialNetworkPercentageFeeHandlerInstance.changeFee(feeBps); + await SocialNetworkPercentageFeeHandlerInstance.changeFeeBounds(lowerBound, upperBound); + + depositFunctionSignature = Helpers.getFunctionSignature( + SocialNetworkAdapterInstance, + "stakeBTC" + ); + + const PermissionlessGenericHandlerSetResourceData = + Helpers.constructGenericHandlerSetResourceData( + depositFunctionSignature, + Helpers.blankFunctionDepositorOffset, + Helpers.blankFunctionSig + ); + await BridgeInstance.adminSetResource( + PermissionlessGenericHandlerInstance.address, + resourceID, + SocialNetworkAdapterInstance.address, + PermissionlessGenericHandlerSetResourceData + ); + + // set MPC address to unpause the Bridge + await BridgeInstance.endKeygen(Helpers.mpcAddress); + }); + + it("[sanity] should fail if stakeBTC is not called from Permissionless Generic handler", async () => { + await TruffleAssert.reverts( + SocialNetworkAdapterInstance.stakeBTC(ethDepositorAddress, "0x"), + "sender must be GMP contract" + ); + }); + + it("call with packed depositData should be successful", async () => { + const btcDepositorAddress = "btcDepositorAddress" + const executionData = Helpers.abiEncode(["uint", "string"], [depositAmount, btcDepositorAddress]); + + // this mocks prepareDepositData helper function from origin adapter + // this logic is now on implemented on relayers + const preparedExecutionData = + "0x" + + Helpers.abiEncode( + ["address", "bytes"], [Ethers.constants.AddressZero, executionData] + ).slice(66); + + await SocialNetworkControllerMockInstance.setSocialNetworkBitcoinAddress(ERC20MintableInstance.address); + + const depositFunctionSignature = Helpers.getFunctionSignature( + SocialNetworkAdapterInstance, + "stakeBTC" + ); + const depositData = Helpers.createPermissionlessGenericDepositData( + depositFunctionSignature, + SocialNetworkAdapterInstance.address, + destinationMaxFee, + ethDepositorAddress, + preparedExecutionData + ); + + const proposal = { + originDomainID: originDomainID, + depositNonce: expectedDepositNonce, + data: depositData, + resourceID: resourceID, + }; + const proposalSignedData = await Helpers.signTypedProposal( + BridgeInstance.address, + [proposal] + ); + + // relayer1 executes the proposal + const executeTx = await BridgeInstance.executeProposal(proposal, proposalSignedData, { + from: relayer1Address, + }); + + const internalTx = await TruffleAssert.createTransactionResult( + SocialNetworkControllerMockInstance, + executeTx.tx + ); + + // check that ProposalExecution event is emitted + TruffleAssert.eventEmitted(executeTx, "ProposalExecution", (event) => { + return ( + event.originDomainID.toNumber() === originDomainID && + event.depositNonce.toNumber() === expectedDepositNonce + ); + }); + + // check that TestExecute event is emitted + TruffleAssert.eventEmitted(internalTx, "Stake", (event) => { + return ( + event.user === ethDepositorAddress && + // this is for Social network internal logic + // 369 Social Network Bitcoin (HEART) for every Bitcoin (SAT) deposited + event.amount.toNumber() === depositAmount * 369 + ); + }); + + // check that deposit amount is mapped to belonging address + assert.equal( + (await SocialNetworkAdapterInstance._btcToEthDepositorToStakedAmount.call( + btcDepositorAddress, + ethDepositorAddress + )).toString(), + depositAmount - lowerBound + ) + + // check that fee token amount is minted to fee handler + assert.equal( + (await ERC20MintableInstance.balanceOf(SocialNetworkPercentageFeeHandlerInstance.address)), + lowerBound + ) + }); + } +); diff --git a/test/adapters/socialNetwork/socialNetworkpercentage/admin.js b/test/adapters/socialNetwork/socialNetworkpercentage/admin.js new file mode 100644 index 00000000..0b3b0265 --- /dev/null +++ b/test/adapters/socialNetwork/socialNetworkpercentage/admin.js @@ -0,0 +1,113 @@ +// 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 originDomainID = 1; + const destinationDomainID = 2; + 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( + 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 + ); + + ADMIN_ROLE = await PercentageFeeHandlerInstance.DEFAULT_ADMIN_ROLE(); + + resourceID = Helpers.createResourceID(ERC20MintableInstance.address, originDomainID); + }); + + it("should set fee property", async () => { + const fee = 60000; + assert.equal(await PercentageFeeHandlerInstance._domainResourceIDToFee(destinationDomainID, resourceID), "0"); + await PercentageFeeHandlerInstance.changeFee(destinationDomainID, resourceID, fee); + assert.equal(await PercentageFeeHandlerInstance._domainResourceIDToFee(destinationDomainID, resourceID), fee); + }); + + it("should require admin role to change fee property", async () => { + const fee = 600; + await assertOnlyAdmin(PercentageFeeHandlerInstance.changeFee, destinationDomainID, resourceID, 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/adapters/socialNetwork/socialNetworkpercentage/calculateFee.js b/test/adapters/socialNetwork/socialNetworkpercentage/calculateFee.js new file mode 100644 index 00000000..b338e3b7 --- /dev/null +++ b/test/adapters/socialNetwork/socialNetworkpercentage/calculateFee.js @@ -0,0 +1,155 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only + +const Helpers = require("../../../helpers"); + +const ERC20MintableContract = artifacts.require("ERC20PresetMinterPauser"); +const PermissionlessGenericHandlerContract = artifacts.require( + "PermissionlessGenericHandler" +); +const SocialAdapterContract = artifacts.require("SocialNetworkAdapter"); +const SocialNetworkPercentageFeeHandlerContract = artifacts.require("SocialNetworkPercentageFeeHandler"); +const SocialNetworkControllerMockContract = artifacts.require("SocialNetworkControllerMock"); + +contract("SocialNetworkPercentageFeeHandler - [calculateFee]", async (accounts) => { + const originDomainID = 1; + const destinationDomainID = 2; + + let BridgeInstance; + let SocialNetworkAdapterInstance; + let SocialNetworkControllerMockInstance; + let ERC20MintableInstance; + let SocialNetworkPercentageFeeHandlerInstance; + let PermissionlessGenericHandlerInstance; + + let resourceID; + let depositFunctionSignature; + + beforeEach(async () => { + await Promise.all([ + (BridgeInstance = await Helpers.deployBridge( + destinationDomainID, + accounts[0] + )), + ERC20MintableContract.new("token", "TOK").then( + (instance) => (ERC20MintableInstance = instance) + ), + ]); + SocialNetworkPercentageFeeHandlerInstance = await SocialNetworkPercentageFeeHandlerContract.new(); + await SocialNetworkPercentageFeeHandlerInstance.setSocialNetworkBitcoinAddress(ERC20MintableInstance.address) + + PermissionlessGenericHandlerInstance = + await PermissionlessGenericHandlerContract.new(BridgeInstance.address); + + SocialNetworkControllerMockInstance = await SocialNetworkControllerMockContract.new(); + SocialNetworkAdapterInstance = await SocialAdapterContract.new( + PermissionlessGenericHandlerInstance.address, + SocialNetworkPercentageFeeHandlerInstance.address, + SocialNetworkControllerMockInstance.address, + ); + + depositFunctionSignature = Helpers.getFunctionSignature( + SocialNetworkAdapterInstance, + "stakeBTC" + ); + + resourceID = Helpers.createResourceID( + ERC20MintableInstance.address, + originDomainID + ); + + const PermissionlessGenericHandlerSetResourceData = + Helpers.constructGenericHandlerSetResourceData( + depositFunctionSignature, + Helpers.blankFunctionDepositorOffset, + Helpers.blankFunctionSig + ); + await BridgeInstance.adminSetResource( + PermissionlessGenericHandlerInstance.address, + resourceID, + SocialNetworkAdapterInstance.address, + PermissionlessGenericHandlerSetResourceData + ); + }); + + it(`should return percentage of token amount for fee if bounds + are set [lowerBound > 0, upperBound > 0]`, async () => { + const depositAmount = 100000000; + + // current fee is set to 0 + let fee = await SocialNetworkPercentageFeeHandlerInstance.calculateFee.call( + depositAmount, + ); + + assert.equal(fee.toString(), "0"); + // Change fee to 1 BPS () + await SocialNetworkPercentageFeeHandlerInstance.changeFee(10000); + await SocialNetworkPercentageFeeHandlerInstance.changeFeeBounds(100, 300000); + fee = await SocialNetworkPercentageFeeHandlerInstance.calculateFee.call( + depositAmount, + ); + assert.equal(fee.toString(), "10000"); + }); + + it(`should return percentage of token amount for fee if bounds + are not set [lowerBound = 0, upperBound = 0]`, async () => { + const depositAmount = 100000000; + + // current fee is set to 0 + let fee = await SocialNetworkPercentageFeeHandlerInstance.calculateFee.call( + depositAmount, + ); + + assert.equal(fee.toString(), "0"); + // Change fee to 1 BPS () + await SocialNetworkPercentageFeeHandlerInstance.changeFee(10000); + fee = await SocialNetworkPercentageFeeHandlerInstance.calculateFee.call( + depositAmount, + ); + assert.equal(fee.toString(), "10000"); + }); + + it("should return lower bound token amount for fee [lowerBound > 0, upperBound > 0]", async () => { + const depositAmount = 10000; + await SocialNetworkPercentageFeeHandlerInstance.changeFeeBounds(100, 300); + await SocialNetworkPercentageFeeHandlerInstance.changeFee(10000); + + fee = await SocialNetworkPercentageFeeHandlerInstance.calculateFee.call( + depositAmount, + ); + assert.equal(fee.toString(), "100"); + }); + + it("should return lower bound token amount for fee [lowerBound > 0, upperBound = 0]", async () => { + const depositAmount = 10000; + await SocialNetworkPercentageFeeHandlerInstance.changeFeeBounds(100, 0); + await SocialNetworkPercentageFeeHandlerInstance.changeFee(10000); + + fee = await SocialNetworkPercentageFeeHandlerInstance.calculateFee.call( + depositAmount, + ); + assert.equal(fee.toString(), "100"); + }); + + it("should return upper bound token amount for fee [lowerBound = 0, upperBound > 0]", async () => { + const depositAmount = 100000000; + await SocialNetworkPercentageFeeHandlerInstance.changeFeeBounds(0, 300); + await SocialNetworkPercentageFeeHandlerInstance.changeFee(10000); + + fee = await SocialNetworkPercentageFeeHandlerInstance.calculateFee.call( + depositAmount, + ); + assert.equal(fee.toString(), "300"); + }); + + it("should return percentage of token amount for fee [lowerBound = 0, upperBound > 0]", async () => { + const depositAmount = 100000; + await SocialNetworkPercentageFeeHandlerInstance.changeFeeBounds(0, 300); + await SocialNetworkPercentageFeeHandlerInstance.changeFee(10000); + + fee = await SocialNetworkPercentageFeeHandlerInstance.calculateFee.call( + depositAmount, + ); + assert.equal(fee.toString(), "10"); + }); +}); diff --git a/test/adapters/socialNetwork/socialNetworkpercentage/changeFee.js b/test/adapters/socialNetwork/socialNetworkpercentage/changeFee.js new file mode 100644 index 00000000..d17e7257 --- /dev/null +++ b/test/adapters/socialNetwork/socialNetworkpercentage/changeFee.js @@ -0,0 +1,177 @@ +// 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 PermissionlessGenericHandlerContract = artifacts.require( + "PermissionlessGenericHandler" +); +const SocialAdapterContract = artifacts.require("SocialNetworkAdapter"); +const SocialNetworkPercentageFeeHandlerContract = artifacts.require("SocialNetworkPercentageFeeHandler"); +const SocialNetworkControllerMockContract = artifacts.require("SocialNetworkControllerMock"); + + +contract("SocialNetworkPercentageFeeHandler - [change fee and bounds]", async (accounts) => { + const originDomainID = 1; + const destinationDomainID = 2; + const nonAdmin = accounts[1]; + + let BridgeInstance; + let SocialNetworkAdapterInstance; + let SocialNetworkControllerMockInstance; + let ERC20MintableInstance; + let SocialNetworkPercentageFeeHandlerInstance; + let PermissionlessGenericHandlerInstance; + + let resourceID; + let depositFunctionSignature; + + const assertOnlyAdmin = (method, ...params) => { + return TruffleAssert.reverts( + method(...params, {from: nonAdmin}), + "sender doesn't have admin role" + ); + }; + + beforeEach(async () => { + await Promise.all([ + (BridgeInstance = await Helpers.deployBridge( + destinationDomainID, + accounts[0] + )), + ERC20MintableContract.new("token", "TOK").then( + (instance) => (ERC20MintableInstance = instance) + ), + ]); + SocialNetworkPercentageFeeHandlerInstance = await SocialNetworkPercentageFeeHandlerContract.new(); + await SocialNetworkPercentageFeeHandlerInstance.setSocialNetworkBitcoinAddress(ERC20MintableInstance.address) + + PermissionlessGenericHandlerInstance = + await PermissionlessGenericHandlerContract.new(BridgeInstance.address); + + SocialNetworkControllerMockInstance = await SocialNetworkControllerMockContract.new(); + SocialNetworkAdapterInstance = await SocialAdapterContract.new( + PermissionlessGenericHandlerInstance.address, + SocialNetworkPercentageFeeHandlerInstance.address, + SocialNetworkControllerMockInstance.address, + ); + + depositFunctionSignature = Helpers.getFunctionSignature( + SocialNetworkAdapterInstance, + "stakeBTC" + ); + + resourceID = Helpers.createResourceID( + ERC20MintableInstance.address, + originDomainID + ); + + const PermissionlessGenericHandlerSetResourceData = + Helpers.constructGenericHandlerSetResourceData( + depositFunctionSignature, + Helpers.blankFunctionDepositorOffset, + Helpers.blankFunctionSig + ); + await BridgeInstance.adminSetResource( + PermissionlessGenericHandlerInstance.address, + resourceID, + SocialNetworkAdapterInstance.address, + PermissionlessGenericHandlerSetResourceData + ); + }); + + it("[sanity] contract should be deployed successfully", async () => { + TruffleAssert.passes( + await SocialNetworkPercentageFeeHandlerContract.new() + ); + }); + + it("should set fee", async () => { + const PercentageFeeHandlerInstance = await SocialNetworkPercentageFeeHandlerContract.new(); + 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 SocialNetworkPercentageFeeHandlerContract.new(); + 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 SocialNetworkPercentageFeeHandlerContract.new(); + await assertOnlyAdmin(PercentageFeeHandlerInstance.changeFee, 1); + }); + + it("should set fee bounds", async () => { + const PercentageFeeHandlerInstance = await SocialNetworkPercentageFeeHandlerContract.new(); + const tx = await PercentageFeeHandlerInstance.changeFeeBounds(50, 100); + TruffleAssert.eventEmitted( + tx, + "FeeBoundsChanged", + (event) => { + return event.newLowerBound.toString() === "50" && + event.newUpperBound.toString() === "100" + } + ); + const newLowerBound = (await PercentageFeeHandlerInstance._feeBounds.call()).lowerBound + const newUpperBound = (await PercentageFeeHandlerInstance._feeBounds.call()).upperBound + assert.equal(newLowerBound.toString(), "50"); + assert.equal(newUpperBound.toString(), "100"); + }); + + it("should not set the same fee bounds", async () => { + const PercentageFeeHandlerInstance = await SocialNetworkPercentageFeeHandlerContract.new(); + await PercentageFeeHandlerInstance.changeFeeBounds(25, 50) + await TruffleAssert.reverts( + PercentageFeeHandlerInstance.changeFeeBounds(25, 50), + "Current bounds are equal to new bounds" + ); + }); + + it("should fail to set lower bound larger than upper bound ", async () => { + const PercentageFeeHandlerInstance = await SocialNetworkPercentageFeeHandlerContract.new(); + await TruffleAssert.reverts( + PercentageFeeHandlerInstance.changeFeeBounds(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 SocialNetworkPercentageFeeHandlerContract.new(); + await PercentageFeeHandlerInstance.changeFeeBounds(25, 50); + await PercentageFeeHandlerInstance.changeFeeBounds(newLowerBound, 50); + const currentLowerBound = (await PercentageFeeHandlerInstance._feeBounds.call()).lowerBound; + assert.equal(currentLowerBound, newLowerBound); + }); + + it("should set only upper bound", async () => { + const newUpperBound = 100; + const PercentageFeeHandlerInstance = await SocialNetworkPercentageFeeHandlerContract.new(); + await PercentageFeeHandlerInstance.changeFeeBounds(25, 50); + await PercentageFeeHandlerInstance.changeFeeBounds(25, newUpperBound); + const currentUpperBound = (await PercentageFeeHandlerInstance._feeBounds.call()).upperBound; + assert.equal(newUpperBound, currentUpperBound); + }); + + it("should require admin role to change fee bunds", async () => { + const PercentageFeeHandlerInstance = await SocialNetworkPercentageFeeHandlerContract.new(); + await assertOnlyAdmin(PercentageFeeHandlerInstance.changeFeeBounds, 50, 100); + }); +}); diff --git a/test/adapters/socialNetwork/socialNetworkpercentage/distributeFee.js b/test/adapters/socialNetwork/socialNetworkpercentage/distributeFee.js new file mode 100644 index 00000000..bca2bcae --- /dev/null +++ b/test/adapters/socialNetwork/socialNetworkpercentage/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(destinationDomainID, resourceID, 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" + ); + }); +});