diff --git a/packages/smart-contracts/scripts-create2/compute-one-address.ts b/packages/smart-contracts/scripts-create2/compute-one-address.ts index 2996f1ca96..84eca479b2 100644 --- a/packages/smart-contracts/scripts-create2/compute-one-address.ts +++ b/packages/smart-contracts/scripts-create2/compute-one-address.ts @@ -64,7 +64,8 @@ export const computeCreate2DeploymentAddressesFromList = async ( case 'BatchConversionPayments': case 'ERC20SwapToPay': case 'ERC20SwapToConversion': - case 'ERC20TransferableReceivable': { + case 'ERC20TransferableReceivable': + case 'SingleRequestProxyFactory': { try { const constructorArgs = getConstructorArgs(contract, chain); address = await computeCreate2DeploymentAddress({ contract, constructorArgs }, hre); diff --git a/packages/smart-contracts/scripts-create2/constructor-args.ts b/packages/smart-contracts/scripts-create2/constructor-args.ts index 042da3569c..808b10df2e 100644 --- a/packages/smart-contracts/scripts-create2/constructor-args.ts +++ b/packages/smart-contracts/scripts-create2/constructor-args.ts @@ -67,6 +67,17 @@ export const getConstructorArgs = ( const erc20FeeProxyAddress = erc20FeeProxy.getAddress(network); return ['Request Network Transferable Receivable', 'tREC', erc20FeeProxyAddress]; } + case 'SingleRequestProxyFactory': { + if (!network) { + throw new Error('SingleRequestProxyFactory requires network parameter'); + } + const erc20FeeProxy = artifacts.erc20FeeProxyArtifact; + const erc20FeeProxyAddress = erc20FeeProxy.getAddress(network); + const ethereumFeeProxy = artifacts.ethereumFeeProxyArtifact; + const ethereumFeeProxyAddress = ethereumFeeProxy.getAddress(network); + + return [ethereumFeeProxyAddress, erc20FeeProxyAddress]; + } default: return []; } diff --git a/packages/smart-contracts/scripts-create2/transfer-ownership.ts b/packages/smart-contracts/scripts-create2/transfer-ownership.ts index 0824c6bc88..52880e6947 100644 --- a/packages/smart-contracts/scripts-create2/transfer-ownership.ts +++ b/packages/smart-contracts/scripts-create2/transfer-ownership.ts @@ -12,7 +12,8 @@ export const transferOwnership = async ( case 'Erc20ConversionProxy': case 'BatchConversionPayments': case 'ERC20SwapToPay': - case 'ERC20SwapToConversion': { + case 'ERC20SwapToConversion': + case 'SingleRequestProxyFactory': { await updateOwner({ contract, hre, signWithEoa }); break; } diff --git a/packages/smart-contracts/scripts-create2/utils.ts b/packages/smart-contracts/scripts-create2/utils.ts index df387afe71..90db473d07 100644 --- a/packages/smart-contracts/scripts-create2/utils.ts +++ b/packages/smart-contracts/scripts-create2/utils.ts @@ -20,6 +20,7 @@ export const create2ContractDeploymentList = [ 'BatchConversionPayments', 'ERC20EscrowToPay', 'ERC20TransferableReceivable', + 'SingleRequestProxyFactory', ]; /** diff --git a/packages/smart-contracts/src/contracts/ERC20SingleRequestProxy.sol b/packages/smart-contracts/src/contracts/ERC20SingleRequestProxy.sol new file mode 100644 index 0000000000..c2cf1b534c --- /dev/null +++ b/packages/smart-contracts/src/contracts/ERC20SingleRequestProxy.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import './interfaces/ERC20FeeProxy.sol'; +import './lib/SafeERC20.sol'; + +/** + * @title ERC20SingleRequestProxy + * @notice This contract is used to send a single request to a payee with a fee sent to a third address for ERC20 + */ + +contract ERC20SingleRequestProxy { + address public payee; + address public tokenAddress; + address public feeAddress; + uint256 public feeAmount; + bytes public paymentReference; + IERC20FeeProxy public erc20FeeProxy; + + constructor( + address _payee, + address _tokenAddress, + address _feeAddress, + uint256 _feeAmount, + bytes memory _paymentReference, + address _erc20FeeProxy + ) { + payee = _payee; + tokenAddress = _tokenAddress; + feeAddress = _feeAddress; + feeAmount = _feeAmount; + paymentReference = _paymentReference; + erc20FeeProxy = IERC20FeeProxy(_erc20FeeProxy); + } + + receive() external payable { + require(msg.value == 0, 'This function is only for triggering the transfer'); + _processPayment(); + } + + function triggerERC20Payment() external { + _processPayment(); + } + + function _processPayment() internal { + IERC20 token = IERC20(tokenAddress); + uint256 balance = token.balanceOf(address(this)); + uint256 paymentAmount = balance; + if (feeAmount > 0 && feeAddress != address(0)) { + require(balance > feeAmount, 'Insufficient balance to cover fee'); + paymentAmount = balance - feeAmount; + } + + require(SafeERC20.safeApprove(token, address(erc20FeeProxy), balance), 'Approval failed'); + + erc20FeeProxy.transferFromWithReferenceAndFee( + tokenAddress, + payee, + paymentAmount, + paymentReference, + feeAmount, + feeAddress + ); + } + + /** + * @notice Rescues any trapped funds by sending them to the payee + * @dev Can be called by anyone, but funds are always sent to the payee + */ + function rescueERC20Funds(address _tokenAddress) external { + require(_tokenAddress != address(0), 'Invalid token address'); + IERC20 token = IERC20(_tokenAddress); + uint256 balance = token.balanceOf(address(this)); + require(balance > 0, 'No funds to rescue'); + bool success = SafeERC20.safeTransfer(token, payee, balance); + require(success, 'ERC20 rescue failed'); + } + + /** + * @notice Rescues any trapped funds by sending them to the payee + * @dev Can be called by anyone, but funds are always sent to the payee + */ + function rescueNativeFunds() external { + uint256 balance = address(this).balance; + require(balance > 0, 'No funds to rescue'); + + (bool success, ) = payable(payee).call{value: balance}(''); + require(success, 'Rescue failed'); + } +} diff --git a/packages/smart-contracts/src/contracts/EthereumSingleRequestProxy.sol b/packages/smart-contracts/src/contracts/EthereumSingleRequestProxy.sol new file mode 100644 index 0000000000..03d8752da6 --- /dev/null +++ b/packages/smart-contracts/src/contracts/EthereumSingleRequestProxy.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import './interfaces/EthereumFeeProxy.sol'; +import '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import './lib/SafeERC20.sol'; + +/** + * @title EthereumSingleRequestProxy + * @notice This contract is used to send a single request to a payee with a fee sent to a third address + */ +contract EthereumSingleRequestProxy { + address public payee; + bytes public paymentReference; + address public feeAddress; + uint256 public feeAmount; + IEthereumFeeProxy public ethereumFeeProxy; + + address private originalSender; + + /** + * @dev Custom reentrancy guard. + * Similar to OpenZeppelin's ReentrancyGuard, but allows reentrancy from ethereumFeeProxy. + * This enables controlled callbacks from ethereumFeeProxy while protecting against other reentrancy attacks. + */ + uint256 private constant _NOT_ENTERED = 1; + uint256 private constant _ENTERED = 2; + uint256 private _status; + + constructor( + address _payee, + bytes memory _paymentReference, + address _ethereumFeeProxy, + address _feeAddress, + uint256 _feeAmount + ) { + payee = _payee; + paymentReference = _paymentReference; + feeAddress = _feeAddress; + feeAmount = _feeAmount; + ethereumFeeProxy = IEthereumFeeProxy(_ethereumFeeProxy); + _status = _NOT_ENTERED; + } + + /** + * @dev Modified nonReentrant guard. + * Prevents reentrancy except for calls from ethereumFeeProxy. + */ + modifier nonReentrant() { + if (msg.sender != address(ethereumFeeProxy)) { + require(_status != _ENTERED, 'ReentrancyGuard: reentrant call'); + _status = _ENTERED; + } + _; + if (msg.sender != address(ethereumFeeProxy)) { + _status = _NOT_ENTERED; + } + } + + receive() external payable nonReentrant { + if (msg.sender == address(ethereumFeeProxy)) { + // Funds are being sent back from EthereumFeeProxy + require(originalSender != address(0), 'No original sender stored'); + + // Forward the funds to the original sender + (bool forwardSuccess, ) = payable(originalSender).call{value: msg.value}(''); + require(forwardSuccess, 'Forwarding to original sender failed'); + + // Clear the stored original sender + originalSender = address(0); + } else { + require(originalSender == address(0), 'Another request is in progress'); + + originalSender = msg.sender; + + bytes memory data = abi.encodeWithSignature( + 'transferWithReferenceAndFee(address,bytes,uint256,address)', + payable(payee), + paymentReference, + feeAmount, + payable(feeAddress) + ); + + (bool callSuccess, ) = address(ethereumFeeProxy).call{value: msg.value}(data); + require(callSuccess, 'Call to EthereumFeeProxy failed'); + } + } + + /** + * @notice Rescues any trapped funds by sending them to the payee + * @dev Can be called by anyone, but funds are always sent to the payee + */ + function rescueNativeFunds() external nonReentrant { + uint256 balance = address(this).balance; + require(balance > 0, 'No funds to rescue'); + + (bool success, ) = payable(payee).call{value: balance}(''); + require(success, 'Rescue failed'); + } + + /** + * @notice Rescues any trapped ERC20 funds by sending them to the payee + * @dev Can be called by anyone, but funds are always sent to the payee + * @param _tokenAddress The address of the ERC20 token to rescue + */ + function rescueERC20Funds(address _tokenAddress) external nonReentrant { + require(_tokenAddress != address(0), 'Invalid token address'); + IERC20 token = IERC20(_tokenAddress); + uint256 balance = token.balanceOf(address(this)); + require(balance > 0, 'No funds to rescue'); + bool success = SafeERC20.safeTransfer(token, payee, balance); + require(success, 'Rescue failed'); + } +} diff --git a/packages/smart-contracts/src/contracts/SingleRequestProxyFactory.sol b/packages/smart-contracts/src/contracts/SingleRequestProxyFactory.sol new file mode 100644 index 0000000000..9d621ab375 --- /dev/null +++ b/packages/smart-contracts/src/contracts/SingleRequestProxyFactory.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import '@openzeppelin/contracts/access/Ownable.sol'; +import './ERC20SingleRequestProxy.sol'; +import './EthereumSingleRequestProxy.sol'; + +/** + * @title SingleRequestProxyFactory + * @notice This contract is used to create SingleRequestProxy instances + */ +contract SingleRequestProxyFactory is Ownable { + /// @notice The address of the EthereumFeeProxy contract + /// @dev This proxy is used for handling Ethereum-based fee transactions + address public ethereumFeeProxy; + + /// @notice The address of the ERC20FeeProxy contract + /// @dev This proxy is used for handling ERC20-based fee transactions + address public erc20FeeProxy; + + event EthereumSingleRequestProxyCreated( + address indexed proxyAddress, + address indexed payee, + bytes indexed paymentReference + ); + + event ERC20SingleRequestProxyCreated( + address indexed proxyAddress, + address indexed payee, + address tokenAddress, + bytes indexed paymentReference + ); + + event ERC20FeeProxyUpdated(address indexed newERC20FeeProxy); + event EthereumFeeProxyUpdated(address indexed newEthereumFeeProxy); + + constructor(address _ethereumFeeProxy, address _erc20FeeProxy) { + require(_ethereumFeeProxy != address(0), 'EthereumFeeProxy address cannot be zero'); + require(_erc20FeeProxy != address(0), 'ERC20FeeProxy address cannot be zero'); + ethereumFeeProxy = _ethereumFeeProxy; + erc20FeeProxy = _erc20FeeProxy; + } + + /** + * @notice Creates a new EthereumSingleRequestProxy instance + * @param _payee The address of the payee + * @param _paymentReference The payment reference + * @param _feeAddress The address of the fee recipient + * @param _feeAmount The fee amount + * @return The address of the newly created proxy + */ + function createEthereumSingleRequestProxy( + address _payee, + bytes memory _paymentReference, + address _feeAddress, + uint256 _feeAmount + ) external returns (address) { + EthereumSingleRequestProxy proxy = new EthereumSingleRequestProxy( + _payee, + _paymentReference, + ethereumFeeProxy, + _feeAddress, + _feeAmount + ); + emit EthereumSingleRequestProxyCreated(address(proxy), _payee, _paymentReference); + return address(proxy); + } + + /** + * @notice Creates a new ERC20SingleRequestProxy instance + * @param _payee The address of the payee + * @param _tokenAddress The address of the token + * @param _paymentReference The payment reference + * @param _feeAddress The address of the fee recipient + * @param _feeAmount The fee amount + * @return The address of the newly created proxy + */ + function createERC20SingleRequestProxy( + address _payee, + address _tokenAddress, + bytes memory _paymentReference, + address _feeAddress, + uint256 _feeAmount + ) external returns (address) { + ERC20SingleRequestProxy proxy = new ERC20SingleRequestProxy( + _payee, + _tokenAddress, + _feeAddress, + _feeAmount, + _paymentReference, + erc20FeeProxy + ); + + emit ERC20SingleRequestProxyCreated(address(proxy), _payee, _tokenAddress, _paymentReference); + return address(proxy); + } + + /** + * @notice Updates the ERC20FeeProxy address + * @param _newERC20FeeProxy The new ERC20FeeProxy address + */ + function setERC20FeeProxy(address _newERC20FeeProxy) external onlyOwner { + require(_newERC20FeeProxy != address(0), 'ERC20FeeProxy address cannot be zero'); + erc20FeeProxy = _newERC20FeeProxy; + emit ERC20FeeProxyUpdated(_newERC20FeeProxy); + } + + /** + * @notice Updates the EthereumFeeProxy address + * @param _newEthereumFeeProxy The new EthereumFeeProxy address + */ + function setEthereumFeeProxy(address _newEthereumFeeProxy) external onlyOwner { + require(_newEthereumFeeProxy != address(0), 'EthereumFeeProxy address cannot be zero'); + ethereumFeeProxy = _newEthereumFeeProxy; + emit EthereumFeeProxyUpdated(_newEthereumFeeProxy); + } +} diff --git a/packages/smart-contracts/src/contracts/test/EthereumFeeProxyMock.sol b/packages/smart-contracts/src/contracts/test/EthereumFeeProxyMock.sol new file mode 100644 index 0000000000..46d8064fc0 --- /dev/null +++ b/packages/smart-contracts/src/contracts/test/EthereumFeeProxyMock.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract MockEthereumFeeProxy { + function transferWithReferenceAndFee( + address payable _to, + bytes calldata _paymentReference, + uint256 _feeAmount, + address payable _feeAddress + ) external payable { + // Do nothing, just accept the funds + } + + function sendFundsBack(address payable _to, uint256 _amount) external { + (bool success, ) = _to.call{value: _amount}(''); + require(success, 'Failed to send funds back'); + } + + receive() external payable {} +} diff --git a/packages/smart-contracts/src/contracts/test/ForceSend.sol b/packages/smart-contracts/src/contracts/test/ForceSend.sol new file mode 100644 index 0000000000..5d552c7338 --- /dev/null +++ b/packages/smart-contracts/src/contracts/test/ForceSend.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract ForceSend { + function forceSend(address payable recipient) public payable { + selfdestruct(recipient); + } +} diff --git a/packages/smart-contracts/src/contracts/test/TestToken.sol b/packages/smart-contracts/src/contracts/test/TestToken.sol new file mode 100644 index 0000000000..f971756647 --- /dev/null +++ b/packages/smart-contracts/src/contracts/test/TestToken.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +// Compatible with OpenZeppelin Contracts ^5.0.0 +pragma solidity ^0.8.9; + +import '@openzeppelin/contracts/token/ERC20/ERC20.sol'; +import '@openzeppelin/contracts/access/Ownable.sol'; +import '@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol'; + +contract TestToken is ERC20, Ownable, ERC20Permit { + constructor(address initialOwner) ERC20('TestToken', 'TTK') Ownable() ERC20Permit('TestToken') {} + + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } +} diff --git a/packages/smart-contracts/src/contracts/test/UsdtFake.sol b/packages/smart-contracts/src/contracts/test/UsdtFake.sol index 169952ceaf..8f53885094 100644 --- a/packages/smart-contracts/src/contracts/test/UsdtFake.sol +++ b/packages/smart-contracts/src/contracts/test/UsdtFake.sol @@ -2,7 +2,66 @@ pragma solidity ^0.8.0; contract UsdtFake { + mapping(address => uint256) private _balances; + mapping(address => mapping(address => uint256)) private _allowances; + uint256 private _totalSupply; + function decimals() external pure returns (uint8) { return 6; } + + function totalSupply() external view returns (uint256) { + return _totalSupply; + } + + function balanceOf(address account) external view returns (uint256) { + return _balances[account]; + } + + // Non-standard: no return value + function transfer(address recipient, uint256 amount) external { + _transfer(msg.sender, recipient, amount); + } + + function allowance(address owner, address spender) external view returns (uint256) { + return _allowances[owner][spender]; + } + + // Non-standard: no return value + function approve(address spender, uint256 amount) external { + _approve(msg.sender, spender, amount); + } + + // Non-standard: no return value + function transferFrom(address sender, address recipient, uint256 amount) external { + _transfer(sender, recipient, amount); + uint256 currentAllowance = _allowances[sender][msg.sender]; + require(currentAllowance >= amount, 'ERC20: transfer amount exceeds allowance'); + unchecked { + _approve(sender, msg.sender, currentAllowance - amount); + } + } + + function _transfer(address sender, address recipient, uint256 amount) internal { + require(sender != address(0), 'ERC20: transfer from the zero address'); + require(recipient != address(0), 'ERC20: transfer to the zero address'); + uint256 senderBalance = _balances[sender]; + require(senderBalance >= amount, 'ERC20: transfer amount exceeds balance'); + unchecked { + _balances[sender] = senderBalance - amount; + } + _balances[recipient] += amount; + } + + function _approve(address owner, address spender, uint256 amount) internal { + require(owner != address(0), 'ERC20: approve from the zero address'); + require(spender != address(0), 'ERC20: approve to the zero address'); + _allowances[owner][spender] = amount; + } + + // For testing purposes + function mint(address account, uint256 amount) external { + _totalSupply += amount; + _balances[account] += amount; + } } diff --git a/packages/smart-contracts/test/contracts/ERC20SingleRequestProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20SingleRequestProxy.test.ts new file mode 100644 index 0000000000..8384641791 --- /dev/null +++ b/packages/smart-contracts/test/contracts/ERC20SingleRequestProxy.test.ts @@ -0,0 +1,293 @@ +import '@nomiclabs/hardhat-ethers'; +import { BytesLike, Signer } from 'ethers'; +import { ethers } from 'hardhat'; +import { expect } from 'chai'; +import { + TestToken__factory, + TestToken, + ERC20SingleRequestProxy__factory, + ERC20SingleRequestProxy, + ERC20FeeProxy, + ERC20FeeProxy__factory, + UsdtFake, + UsdtFake__factory, +} from '../../src/types'; +import { BigNumber as BN } from 'ethers'; + +const BASE_DECIMAL = BN.from(10).pow(BN.from(18)); +const USDT_DECIMAL = BN.from(10).pow(BN.from(6)); + +describe('contract: ERC20SingleRequestProxy', () => { + let deployer: Signer; + let user1: Signer, user1Addr: string; + let user2: Signer, user2Addr: string; + let feeRecipient: Signer, feeRecipientAddr: string; + + let testToken: TestToken; + let erc20SingleRequestProxy: ERC20SingleRequestProxy; + let erc20FeeProxy: ERC20FeeProxy; + let usdtFake: UsdtFake; + + const paymentReference: BytesLike = '0xd0bc835c22f49e7e'; + const feeAmount: BN = BN.from(10).mul(BASE_DECIMAL); + + before(async function () { + [deployer, user1, user2, feeRecipient] = await ethers.getSigners(); + user1Addr = await user1.getAddress(); + user2Addr = await user2.getAddress(); + feeRecipientAddr = await feeRecipient.getAddress(); + }); + + beforeEach(async function () { + const deployerAddr = await deployer.getAddress(); + testToken = await new TestToken__factory(deployer).deploy(deployerAddr); + await testToken.mint(deployerAddr, BN.from(1000000).mul(BASE_DECIMAL)); + + erc20FeeProxy = await new ERC20FeeProxy__factory(deployer).deploy(); + erc20SingleRequestProxy = await new ERC20SingleRequestProxy__factory(deployer).deploy( + user2Addr, + testToken.address, + feeRecipientAddr, + feeAmount, + paymentReference, + erc20FeeProxy.address, + ); + + await testToken.transfer(user1Addr, BN.from(10000).mul(BASE_DECIMAL)); + await testToken + .connect(user1) + .approve(erc20SingleRequestProxy.address, ethers.constants.MaxUint256); + + // Deploy UsdtFake + usdtFake = await new UsdtFake__factory(deployer).deploy(); + await usdtFake.mint(deployerAddr, BN.from(1000000).mul(USDT_DECIMAL)); + }); + + it('should be deployed', async () => { + expect(erc20SingleRequestProxy.address).to.not.equal(ethers.constants.AddressZero); + }); + + it('should set the correct initial values', async () => { + expect(await erc20SingleRequestProxy.payee()).to.equal(user2Addr); + expect(await erc20SingleRequestProxy.tokenAddress()).to.equal(testToken.address); + expect(await erc20SingleRequestProxy.feeAddress()).to.equal(feeRecipientAddr); + expect(await erc20SingleRequestProxy.feeAmount()).to.equal(feeAmount); + expect(await erc20SingleRequestProxy.paymentReference()).to.equal(paymentReference); + expect(await erc20SingleRequestProxy.erc20FeeProxy()).to.equal(erc20FeeProxy.address); + }); + + it('should process a payment correctly via receive', async () => { + const paymentAmount = BN.from(100).mul(BASE_DECIMAL); + const totalAmount = paymentAmount.add(feeAmount); + + await testToken.connect(user1).transfer(erc20SingleRequestProxy.address, totalAmount); + + const erc20SingleRequestProxyBalanceBefore = await testToken.balanceOf( + erc20SingleRequestProxy.address, + ); + expect(erc20SingleRequestProxyBalanceBefore).to.equal(totalAmount); + + await expect( + user1.sendTransaction({ + to: erc20SingleRequestProxy.address, + value: 0, + }), + ) + .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') + .withArgs( + testToken.address, + user2Addr, + paymentAmount, + paymentReference, + feeAmount, + feeRecipientAddr, + ); + + const erc20SingleRequestProxyBalanceAfter = await testToken.balanceOf( + erc20SingleRequestProxy.address, + ); + const user2BalanceAfter = await testToken.balanceOf(user2Addr); + const feeRecipientBalanceAfter = await testToken.balanceOf(feeRecipientAddr); + + expect(erc20SingleRequestProxyBalanceAfter).to.equal(0); + expect(user2BalanceAfter).to.equal(paymentAmount); + expect(feeRecipientBalanceAfter).to.equal(feeAmount); + }); + + it('should process a payment correctly via triggerERC20Payment', async () => { + const paymentAmount = BN.from(100).mul(BASE_DECIMAL); + const totalAmount = paymentAmount.add(feeAmount); + + await testToken.connect(user1).transfer(erc20SingleRequestProxy.address, totalAmount); + + const erc20SingleRequestProxyBalanceBefore = await testToken.balanceOf( + erc20SingleRequestProxy.address, + ); + expect(erc20SingleRequestProxyBalanceBefore).to.equal(totalAmount); + + await expect(erc20SingleRequestProxy.triggerERC20Payment()) + .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') + .withArgs( + testToken.address, + user2Addr, + paymentAmount, + paymentReference, + feeAmount, + feeRecipientAddr, + ); + + const erc20SingleRequestProxyBalanceAfter = await testToken.balanceOf( + erc20SingleRequestProxy.address, + ); + const user2BalanceAfter = await testToken.balanceOf(user2Addr); + const feeRecipientBalanceAfter = await testToken.balanceOf(feeRecipientAddr); + + expect(erc20SingleRequestProxyBalanceAfter).to.equal(0); + expect(user2BalanceAfter).to.equal(paymentAmount); + expect(feeRecipientBalanceAfter).to.equal(feeAmount); + }); + + it.skip('should process a partial payment correctly', async () => { + // Smart contract does not keep track of the payment amount, it accepts any amount of tokens + }); + + it('should process a payment with a non-standard ERC20', async () => { + const usdtFeeAmount = BN.from(10).mul(USDT_DECIMAL); + const usdtProxy = await new ERC20SingleRequestProxy__factory(deployer).deploy( + user2Addr, + usdtFake.address, + feeRecipientAddr, + usdtFeeAmount, + paymentReference, + erc20FeeProxy.address, + ); + + const paymentAmount = BN.from(50).mul(USDT_DECIMAL); + const totalAmount = paymentAmount.add(usdtFeeAmount); + + await usdtFake.mint(user1Addr, BN.from(1000).mul(USDT_DECIMAL)); + + await usdtFake.connect(user1).transfer(usdtProxy.address, totalAmount); + + const usdtProxyBalanceBefore = await usdtFake.balanceOf(usdtProxy.address); + expect(usdtProxyBalanceBefore).to.equal(totalAmount); + + await expect( + user1.sendTransaction({ + to: usdtProxy.address, + value: 0, + }), + ) + .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') + .withArgs( + usdtFake.address, + user2Addr, + paymentAmount, + paymentReference, + usdtFeeAmount, + feeRecipientAddr, + ); + + const usdtProxyBalanceAfter = await usdtFake.balanceOf(usdtProxy.address); + const user2BalanceAfter = await usdtFake.balanceOf(user2Addr); + const feeRecipientBalanceAfter = await usdtFake.balanceOf(feeRecipientAddr); + + expect(usdtProxyBalanceAfter).to.equal(0); + expect(user2BalanceAfter).to.equal(paymentAmount); + expect(feeRecipientBalanceAfter).to.equal(usdtFeeAmount); + }); + + it('should revert if called with non-zero value', async () => { + await expect( + user1.sendTransaction({ + to: erc20SingleRequestProxy.address, + value: 1, + }), + ).to.be.revertedWith('This function is only for triggering the transfer'); + }); + + it('should handle zero fee amount correctly', async () => { + const zeroFeeProxy = await new ERC20SingleRequestProxy__factory(deployer).deploy( + user2Addr, + testToken.address, + feeRecipientAddr, + 0, + paymentReference, + erc20FeeProxy.address, + ); + + const paymentAmount = BN.from(100).mul(BASE_DECIMAL); + await testToken.connect(user1).transfer(zeroFeeProxy.address, paymentAmount); + + await expect( + user1.sendTransaction({ + to: zeroFeeProxy.address, + value: 0, + }), + ) + .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') + .withArgs( + testToken.address, + user2Addr, + paymentAmount, + ethers.utils.keccak256(paymentReference), + 0, + feeRecipientAddr, + ); + + expect(await testToken.balanceOf(zeroFeeProxy.address)).to.equal(0); + expect(await testToken.balanceOf(user2Addr)).to.equal(paymentAmount); + expect(await testToken.balanceOf(feeRecipientAddr)).to.equal(0); + }); + + it('should revert if there are not enough tokens', async () => { + const insufficientAmount = BN.from(1).mul(BASE_DECIMAL); + await testToken.connect(user1).transfer(erc20SingleRequestProxy.address, insufficientAmount); + + await expect( + user1.sendTransaction({ + to: erc20SingleRequestProxy.address, + value: 0, + }), + ).to.be.reverted; + }); + + it('should rescue ERC20 tokens', async () => { + const rescueAmount = BN.from(100).mul(BASE_DECIMAL); + + // Transfer tokens directly to the contract + await testToken.transfer(erc20SingleRequestProxy.address, rescueAmount); + + const contractBalanceBefore = await testToken.balanceOf(erc20SingleRequestProxy.address); + expect(contractBalanceBefore).to.equal(rescueAmount); + + const payeeBalanceBefore = await testToken.balanceOf(user2Addr); + + await erc20SingleRequestProxy.rescueERC20Funds(testToken.address); + + const contractBalanceAfter = await testToken.balanceOf(erc20SingleRequestProxy.address); + expect(contractBalanceAfter).to.equal(0); + + const payeeBalanceAfter = await testToken.balanceOf(user2Addr); + expect(payeeBalanceAfter.sub(payeeBalanceBefore)).to.equal(rescueAmount); + }); + + it('should rescue native funds', async () => { + const paymentAmount = ethers.utils.parseEther('1'); + const totalAmount = paymentAmount.add(feeAmount); + + const ForceSendFactory = await ethers.getContractFactory('ForceSend'); + const forceSend = await ForceSendFactory.deploy(); + await forceSend.deployed(); + + await forceSend.forceSend(erc20SingleRequestProxy.address, { value: totalAmount }); + + const contractBalanceBefore = await ethers.provider.getBalance(erc20SingleRequestProxy.address); + expect(contractBalanceBefore).to.gt(0); + + await erc20SingleRequestProxy.rescueNativeFunds(); + + const contractBalanceAfter = await ethers.provider.getBalance(erc20SingleRequestProxy.address); + expect(contractBalanceAfter).to.equal(0); + }); +}); diff --git a/packages/smart-contracts/test/contracts/EthereumSingleRequestProxy.test.ts b/packages/smart-contracts/test/contracts/EthereumSingleRequestProxy.test.ts new file mode 100644 index 0000000000..7cb7982e59 --- /dev/null +++ b/packages/smart-contracts/test/contracts/EthereumSingleRequestProxy.test.ts @@ -0,0 +1,168 @@ +import { ethers } from 'hardhat'; +import { expect } from 'chai'; +import { BigNumber, Signer } from 'ethers'; +import { EthereumSingleRequestProxy, EthereumFeeProxy, TestToken__factory } from '../../src/types'; +import { BigNumber as BN } from 'ethers'; +describe('contract : EthereumSingleRequestProxy', () => { + let ethereumSingleRequestProxy: EthereumSingleRequestProxy; + let ethereumFeeProxy: EthereumFeeProxy; + let owner: Signer; + let payee: Signer; + let feeRecipient: Signer; + let payeeAddress: string; + let feeRecipientAddress: string; + + const paymentReference: string = ethers.utils.formatBytes32String('payment_reference'); + const feeAmount: BN = ethers.utils.parseEther('0.1'); + + beforeEach(async () => { + [owner, payee, feeRecipient] = await ethers.getSigners(); + payeeAddress = await payee.getAddress(); + feeRecipientAddress = await feeRecipient.getAddress(); + + const ethereumFeeProxyFactory = await ethers.getContractFactory('EthereumFeeProxy'); + ethereumFeeProxy = await ethereumFeeProxyFactory.deploy(); + await ethereumFeeProxy.deployed(); + + const ethereumSingleRequestProxyFactory = await ethers.getContractFactory( + 'EthereumSingleRequestProxy', + ); + ethereumSingleRequestProxy = await ethereumSingleRequestProxyFactory.deploy( + payeeAddress, + paymentReference, + ethereumFeeProxy.address, + feeRecipientAddress, + feeAmount, + ); + await ethereumSingleRequestProxy.deployed(); + }); + + it('should be deployed', async () => { + expect(ethereumSingleRequestProxy.address).to.not.equal(ethers.constants.AddressZero); + }); + + it('should set the correct initial values', async () => { + expect(await ethereumSingleRequestProxy.payee()).to.equal(payeeAddress); + expect(await ethereumSingleRequestProxy.paymentReference()).to.equal(paymentReference); + expect(await ethereumSingleRequestProxy.ethereumFeeProxy()).to.equal(ethereumFeeProxy.address); + expect(await ethereumSingleRequestProxy.feeAddress()).to.equal(feeRecipientAddress); + expect(await ethereumSingleRequestProxy.feeAmount()).to.equal(feeAmount); + }); + + it('should process a payment correctly and emit event', async () => { + const paymentAmount = ethers.utils.parseEther('1'); + const totalAmount = BigNumber.from(paymentAmount).add(BigNumber.from(feeAmount)); + + await expect( + await owner.sendTransaction({ + to: ethereumSingleRequestProxy.address, + value: totalAmount, + }), + ).to.changeEtherBalances( + [owner, payee, feeRecipient], + [totalAmount.mul(-1), paymentAmount, feeAmount], + ); + + await expect( + owner.sendTransaction({ + to: ethereumSingleRequestProxy.address, + value: totalAmount, + }), + ) + .to.emit(ethereumFeeProxy, 'TransferWithReferenceAndFee') + .withArgs(payeeAddress, paymentAmount, paymentReference, feeAmount, feeRecipientAddress); + + expect(await ethers.provider.getBalance(ethereumSingleRequestProxy.address)).to.equal(0); + expect(await ethers.provider.getBalance(ethereumFeeProxy.address)).to.equal(0); + }); + + it('should handle funds sent back from EthereumFeeProxy', async () => { + const MockEthereumFeeProxyFactory = await ethers.getContractFactory('MockEthereumFeeProxy'); + const mockEthereumFeeProxy = await MockEthereumFeeProxyFactory.deploy(); + await mockEthereumFeeProxy.deployed(); + + const newEthereumSingleRequestProxyFactory = await ethers.getContractFactory( + 'EthereumSingleRequestProxy', + ); + const newEthereumSingleRequestProxy = await newEthereumSingleRequestProxyFactory.deploy( + payeeAddress, + paymentReference, + mockEthereumFeeProxy.address, + feeRecipientAddress, + feeAmount, + ); + await newEthereumSingleRequestProxy.deployed(); + + const paymentAmount = ethers.utils.parseEther('1'); + const totalAmount = paymentAmount.add(feeAmount); + + await owner.sendTransaction({ + to: newEthereumSingleRequestProxy.address, + value: totalAmount, + }); + + expect(await ethers.provider.getBalance(newEthereumSingleRequestProxy.address)).to.equal(0); + expect(await ethers.provider.getBalance(mockEthereumFeeProxy.address)).to.equal(totalAmount); + + await expect(() => + mockEthereumFeeProxy.sendFundsBack(newEthereumSingleRequestProxy.address, totalAmount), + ).to.changeEtherBalances( + [owner, newEthereumSingleRequestProxy, mockEthereumFeeProxy], + [totalAmount, 0, totalAmount.mul(-1)], + ); + + expect(await ethers.provider.getBalance(newEthereumSingleRequestProxy.address)).to.equal(0); + expect(await ethers.provider.getBalance(mockEthereumFeeProxy.address)).to.equal(0); + }); + + it('should rescue native funds', async () => { + const paymentAmount = ethers.utils.parseEther('1'); + const totalAmount = paymentAmount.add(feeAmount); + + const ForceSendFactory = await ethers.getContractFactory('ForceSend'); + const forceSend = await ForceSendFactory.deploy(); + await forceSend.deployed(); + + await forceSend.forceSend(ethereumSingleRequestProxy.address, { value: totalAmount }); + + const balanceAfterForceSend = await ethers.provider.getBalance( + ethereumSingleRequestProxy.address, + ); + expect(balanceAfterForceSend).to.be.gt(0); + expect(balanceAfterForceSend).to.equal(totalAmount); + + const initialPayeeBalance = await ethers.provider.getBalance(payeeAddress); + + await ethereumSingleRequestProxy.rescueNativeFunds(); + + expect(await ethers.provider.getBalance(ethereumSingleRequestProxy.address)).to.equal(0); + + const finalPayeeBalance = await ethers.provider.getBalance(payeeAddress); + expect(finalPayeeBalance.sub(initialPayeeBalance)).to.equal(balanceAfterForceSend); + }); + + it('should rescue ERC20 funds', async () => { + const rescueAmount = BN.from(100).mul(18); + const [deployer] = await ethers.getSigners(); + + const deployerAddr = await deployer.getAddress(); + const testToken = await new TestToken__factory(deployer).deploy(deployerAddr); + await testToken.mint(deployerAddr, BN.from(1000000).mul(18)); + + await testToken.transfer(ethereumSingleRequestProxy.address, rescueAmount); + + const contractBalanceBefore = await testToken.balanceOf(ethereumSingleRequestProxy.address); + + const initialPayeeBalance = await testToken.balanceOf(payeeAddress); + expect(initialPayeeBalance).to.equal(0); + expect(contractBalanceBefore).to.equal(rescueAmount); + + await ethereumSingleRequestProxy.rescueERC20Funds(testToken.address); + + const contractBalanceAfter = await testToken.balanceOf(ethereumSingleRequestProxy.address); + ethereumSingleRequestProxy.address, expect(contractBalanceAfter).to.equal(0); + + const finalPayeeBalance = await testToken.balanceOf(payeeAddress); + expect(finalPayeeBalance.sub(initialPayeeBalance)).to.equal(rescueAmount); + }); +}); diff --git a/packages/smart-contracts/test/contracts/SingleRequestProxyFactory.test.ts b/packages/smart-contracts/test/contracts/SingleRequestProxyFactory.test.ts new file mode 100644 index 0000000000..cf3630e461 --- /dev/null +++ b/packages/smart-contracts/test/contracts/SingleRequestProxyFactory.test.ts @@ -0,0 +1,204 @@ +import { ethers } from 'hardhat'; +import { expect } from 'chai'; +import { Signer } from 'ethers'; +import { + SingleRequestProxyFactory, + EthereumFeeProxy, + ERC20FeeProxy, + EthereumSingleRequestProxy, + ERC20SingleRequestProxy, + TestToken, +} from '../../src/types'; + +describe('contract: SingleRequestProxyFactory', () => { + let singleRequestProxyFactory: SingleRequestProxyFactory; + let ethereumFeeProxy: EthereumFeeProxy; + let erc20FeeProxy: ERC20FeeProxy; + let testToken: TestToken; + let owner: Signer; + let user: Signer; + let payee: Signer; + let feeRecipient: Signer; + let ownerAddress: string; + let userAddress: string; + let payeeAddress: string; + let feeRecipientAddress: string; + + const paymentReference: string = ethers.utils.formatBytes32String('payment_reference'); + const feeAmount: string = ethers.utils.parseEther('0.1').toString(); + + beforeEach(async () => { + [owner, user, payee, feeRecipient] = await ethers.getSigners(); + ownerAddress = await owner.getAddress(); + userAddress = await user.getAddress(); + payeeAddress = await payee.getAddress(); + feeRecipientAddress = await feeRecipient.getAddress(); + + const EthereumFeeProxyFactory = await ethers.getContractFactory('EthereumFeeProxy'); + ethereumFeeProxy = await EthereumFeeProxyFactory.deploy(); + await ethereumFeeProxy.deployed(); + + const ERC20FeeProxyFactory = await ethers.getContractFactory('ERC20FeeProxy'); + erc20FeeProxy = await ERC20FeeProxyFactory.deploy(); + await erc20FeeProxy.deployed(); + + const SingleRequestProxyFactoryFactory = await ethers.getContractFactory( + 'SingleRequestProxyFactory', + ); + singleRequestProxyFactory = await SingleRequestProxyFactoryFactory.deploy( + ethereumFeeProxy.address, + erc20FeeProxy.address, + ); + await singleRequestProxyFactory.deployed(); + + const TestTokenFactory = await ethers.getContractFactory('TestToken'); + testToken = await TestTokenFactory.deploy(ownerAddress); + await testToken.deployed(); + }); + + it('should be deployed with correct initial values', async () => { + expect(singleRequestProxyFactory.address).to.not.equal(ethers.constants.AddressZero); + expect(await singleRequestProxyFactory.ethereumFeeProxy()).to.equal(ethereumFeeProxy.address); + expect(await singleRequestProxyFactory.erc20FeeProxy()).to.equal(erc20FeeProxy.address); + expect(await singleRequestProxyFactory.owner()).to.equal(ownerAddress); + }); + + it('should create a new EthereumSingleRequestProxy and emit event', async () => { + const tx = await singleRequestProxyFactory.createEthereumSingleRequestProxy( + payeeAddress, + paymentReference, + feeRecipientAddress, + feeAmount, + ); + + const receipt = await tx.wait(); + + expect(receipt.events).to.exist; + expect(receipt.events).to.have.length(1); + expect(receipt.events?.[0]?.event).to.equal('EthereumSingleRequestProxyCreated'); + + const proxyAddress = receipt.events?.[0]?.args?.[0]; + + expect(proxyAddress).to.not.equal(ethers.constants.AddressZero); + expect(proxyAddress).to.be.properAddress; + + // Check if the event was emitted with correct parameters + await expect(tx) + .to.emit(singleRequestProxyFactory, 'EthereumSingleRequestProxyCreated') + .withArgs(proxyAddress, payeeAddress, paymentReference); + + const proxy = (await ethers.getContractAt( + 'EthereumSingleRequestProxy', + proxyAddress, + )) as EthereumSingleRequestProxy; + expect(await proxy.payee()).to.equal(payeeAddress); + expect(await proxy.paymentReference()).to.equal(paymentReference); + expect(await proxy.ethereumFeeProxy()).to.equal(ethereumFeeProxy.address); + expect(await proxy.feeAddress()).to.equal(feeRecipientAddress); + expect(await proxy.feeAmount()).to.equal(feeAmount); + }); + + it('should create a new ERC20SingleRequestProxy and emit event', async () => { + const tx = await singleRequestProxyFactory.createERC20SingleRequestProxy( + payeeAddress, + testToken.address, + paymentReference, + feeRecipientAddress, + feeAmount, + ); + + const receipt = await tx.wait(); + + expect(receipt.events).to.exist; + expect(receipt.events).to.have.length(1); + expect(receipt.events?.[0]?.event).to.equal('ERC20SingleRequestProxyCreated'); + + const proxyAddress = receipt.events?.[0]?.args?.[0]; + + expect(proxyAddress).to.not.equal(ethers.constants.AddressZero); + expect(proxyAddress).to.be.properAddress; + + // Check if the event was emitted with correct parameters + await expect(tx) + .to.emit(singleRequestProxyFactory, 'ERC20SingleRequestProxyCreated') + .withArgs(proxyAddress, payeeAddress, testToken.address, paymentReference); + + const proxy = (await ethers.getContractAt( + 'ERC20SingleRequestProxy', + proxyAddress, + )) as ERC20SingleRequestProxy; + expect(await proxy.payee()).to.equal(payeeAddress); + expect(await proxy.tokenAddress()).to.equal(testToken.address); + expect(await proxy.paymentReference()).to.equal(paymentReference); + expect(await proxy.erc20FeeProxy()).to.equal(erc20FeeProxy.address); + expect(await proxy.feeAddress()).to.equal(feeRecipientAddress); + expect(await proxy.feeAmount()).to.equal(feeAmount); + }); + + it('should update ERC20FeeProxy address when called by owner', async () => { + const newERC20FeeProxy = await (await ethers.getContractFactory('ERC20FeeProxy')).deploy(); + await newERC20FeeProxy.deployed(); + + await singleRequestProxyFactory.setERC20FeeProxy(newERC20FeeProxy.address); + expect(await singleRequestProxyFactory.erc20FeeProxy()).to.equal(newERC20FeeProxy.address); + }); + + it('should revert when non-owner tries to set ERC20FeeProxy address', async () => { + const newERC20FeeProxy = await (await ethers.getContractFactory('ERC20FeeProxy')).deploy(); + await newERC20FeeProxy.deployed(); + + await expect( + singleRequestProxyFactory.connect(user).setERC20FeeProxy(newERC20FeeProxy.address), + ).to.be.revertedWith('Ownable: caller is not the owner'); + }); + + it('should update EthereumFeeProxy address when called by owner', async () => { + const newEthereumFeeProxy = await ( + await ethers.getContractFactory('EthereumFeeProxy') + ).deploy(); + await newEthereumFeeProxy.deployed(); + + await singleRequestProxyFactory.setEthereumFeeProxy(newEthereumFeeProxy.address); + expect(await singleRequestProxyFactory.ethereumFeeProxy()).to.equal( + newEthereumFeeProxy.address, + ); + }); + + it('should revert when non-owner tries to set EthereumFeeProxy address', async () => { + const newEthereumFeeProxy = await ( + await ethers.getContractFactory('EthereumFeeProxy') + ).deploy(); + await newEthereumFeeProxy.deployed(); + + await expect( + singleRequestProxyFactory.connect(user).setEthereumFeeProxy(newEthereumFeeProxy.address), + ).to.be.revertedWith('Ownable: caller is not the owner'); + }); + + it('should allow owner to transfer ownership', async () => { + await singleRequestProxyFactory.transferOwnership(userAddress); + expect(await singleRequestProxyFactory.owner()).to.equal(userAddress); + }); + + it('should allow new owner to renounce ownership', async () => { + await expect(singleRequestProxyFactory.transferOwnership(userAddress)) + .to.emit(singleRequestProxyFactory, 'OwnershipTransferred') + .withArgs(ownerAddress, userAddress); + await expect(singleRequestProxyFactory.connect(user).renounceOwnership()) + .to.emit(singleRequestProxyFactory, 'OwnershipTransferred') + .withArgs(userAddress, ethers.constants.AddressZero); + expect(await singleRequestProxyFactory.owner()).to.equal(ethers.constants.AddressZero); + }); + + it('should revert when non-owner tries to transfer ownership', async () => { + await expect( + singleRequestProxyFactory.connect(user).transferOwnership(userAddress), + ).to.be.revertedWith('Ownable: caller is not the owner'); + }); + + it('should revert when non-owner tries to renounce ownership', async () => { + await expect(singleRequestProxyFactory.connect(user).renounceOwnership()).to.be.revertedWith( + 'Ownable: caller is not the owner', + ); + }); +});