From 1dca9d64be6b24b052f98e354c2bd20a1b8c2a97 Mon Sep 17 00:00:00 2001 From: Prosper Date: Sun, 22 Sep 2024 18:40:27 +0100 Subject: [PATCH] feat: added escrow functionality --- contracts/Gateway.sol | 50 +++ contracts/interfaces/IGateway.sol | 35 ++ test/gateway/gateway.escrow.test.js | 527 ++++++++++++++++++++++++++++ test/utils/utils.manager.js | 1 + 4 files changed, 613 insertions(+) create mode 100644 test/gateway/gateway.escrow.test.js diff --git a/contracts/Gateway.sol b/contracts/Gateway.sol index 05cff65..0b516fa 100644 --- a/contracts/Gateway.sol +++ b/contracts/Gateway.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.18; import '@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol'; +import '@openzeppelin/contracts/utils/cryptography/ECDSA.sol'; import {GatewaySettingManager} from './GatewaySettingManager.sol'; import {IGateway, IERC20} from './interfaces/IGateway.sol'; @@ -11,6 +12,7 @@ import {IGateway, IERC20} from './interfaces/IGateway.sol'; * @notice This contract serves as a gateway for creating orders and managing settlements. */ contract Gateway is IGateway, GatewaySettingManager, PausableUpgradeable { + using ECDSA for bytes32; struct fee { uint256 protocolFee; uint256 liquidityProviderAmount; @@ -20,6 +22,7 @@ contract Gateway is IGateway, GatewaySettingManager, PausableUpgradeable { mapping(address => uint256) private _nonce; uint256[50] private __gap; mapping(address => mapping(address => uint256)) private balance; + mapping(bytes32 => bool) private processedOrders; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -247,6 +250,48 @@ contract Gateway is IGateway, GatewaySettingManager, PausableUpgradeable { return true; } + /** @dev See {deposit-IGateway}. */ + function escrow( + bytes32 _orderId, + bytes memory _signature, + address _provider, + address _senderAddress, + address _token, + uint256 _amount + ) external onlyAggregator isValidAmount(_amount) { + require(!processedOrders[_orderId], "Order already processed"); + require(_provider != address(0), "Invalid provider address"); + require(_senderAddress != address(0), "Invalid sender address"); + + // Verify signature + bytes32 messageHash = keccak256(abi.encodePacked(_orderId, _provider, _senderAddress, _token, _amount)); + bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash(); + address recoveredAddress = ethSignedMessageHash.recover(_signature); + require(recoveredAddress == _provider, "Invalid signature"); + // update transaction + uint256 _protocolFee = (_amount * protocolFeePercent) / MAX_BPS; + // uint256 sumAmount = _amount + _protocolFee; + // Check provider's balance, + // Note: There is no need for checks for token supported as the balance will be 0 if the token is not supported + require(balance[_token][_provider] >= _amount + _protocolFee, "Insufficient balance"); + + // Mark order as processed + processedOrders[_orderId] = true; + + // Update balances + balance[_token][_provider] -= (_amount + _protocolFee); + + // transfer to sender + IERC20(_token).transfer(_senderAddress, _amount); + if (_protocolFee != 0) { + // transfer protocol fee + IERC20(_token).transfer(treasuryAddress, _protocolFee); + } + + // Emit event + emit Escrow(_provider, _senderAddress, _amount, _token, _orderId); + } + /* ################################################################## VIEW CALLS ################################################################## */ @@ -270,4 +315,9 @@ contract Gateway is IGateway, GatewaySettingManager, PausableUpgradeable { function getBalance(address _token, address _provider) external view returns (uint256) { return balance[_token][_provider]; } + + /** See {isOrderProcessed-IGateway} */ + function isOrderProcessed(bytes32 _orderId) external view returns (bool) { + return processedOrders[_orderId]; + } } diff --git a/contracts/interfaces/IGateway.sol b/contracts/interfaces/IGateway.sol index f8f42e8..0e5e1d1 100644 --- a/contracts/interfaces/IGateway.sol +++ b/contracts/interfaces/IGateway.sol @@ -66,6 +66,15 @@ interface IGateway { */ event Deposit(address indexed sender, address indexed token, uint256 indexed amount); + /** + * @dev Emitted when an escrow is made by a provider. + * @param provider The address of the provider. + * @param senderAddress The address of the sender. + * @param amount The address of the deposited token. + * @param token The amount of the deposit. + * @param orderId The ID of the order. + */ + event Escrow(address indexed provider, address indexed senderAddress, uint256 indexed amount, address token, bytes32 orderId); /* ################################################################## STRUCTS ################################################################## */ @@ -161,6 +170,25 @@ interface IGateway { */ function deposit(address _token, uint256 _amount) external returns (bool); + + /** + * @notice Escrowed assets from provider to the sender. + * @param _orderId The ID of the transaction. + * @param _signature The signature of the provider. + * @param _provider The address of the provider. + * @param _senderAddress The address of the sender. + * @param _token The address of the asset. + * @param _amount The amount to be transferred. + */ + function escrow( + bytes32 _orderId, + bytes memory _signature, + address _provider, + address _senderAddress, + address _token, + uint256 _amount + ) external; + /** * @notice Checks if a token is supported by Gateway. * @param _token The address of the token to check. @@ -189,4 +217,11 @@ interface IGateway { * @return uint256 The provider's balance. */ function getBalance(address _asset, address _provider) external view returns (uint256); + + /** + * @notice Gets order processed status. + * @param _orderId The ID of the order. + * @return bool The order processed status. + */ + function isOrderProcessed(bytes32 _orderId) external view returns (bool); } diff --git a/test/gateway/gateway.escrow.test.js b/test/gateway/gateway.escrow.test.js new file mode 100644 index 0000000..ac46df8 --- /dev/null +++ b/test/gateway/gateway.escrow.test.js @@ -0,0 +1,527 @@ +const { ethers } = require("hardhat"); +const { BigNumber } = require("@ethersproject/bignumber"); +const { gatewayFixture } = require("../fixtures/gateway.js"); +const { + deployContract, + ZERO_AMOUNT, + Events, + mockMintDeposit, + assertBalance, + assertDepositBalance, +} = require("../utils/utils.manager.js"); +const { expect } = require("chai"); + +describe("Gateway Escrow", function () { + beforeEach(async function () { + [ + this.deployer, + this.treasuryAddress, + this.aggregator, + this.alice, + this.bob, + this.Eve, + this.hacker, + ...this.accounts + ] = await ethers.getSigners(); + + ({ gateway, mockUSDT } = await gatewayFixture()); + + this.mockDAI = await deployContract("MockUSDT"); + + this.mockUSDT = mockUSDT; + this.gateway = gateway; + + this.depositAmount = ethers.utils.parseEther("1000000"); + + await mockMintDeposit(gateway, this.alice, mockUSDT, this.depositAmount); + await mockMintDeposit(gateway, this.Eve, mockUSDT, this.depositAmount); + await mockMintDeposit( + gateway, + this.alice, + this.mockDAI, + this.depositAmount + ); + await mockMintDeposit(gateway, this.Eve, this.mockDAI, this.depositAmount); + + await assertBalance( + this.mockUSDT, + this.mockDAI, + this.alice.address, + this.depositAmount + ); + await assertBalance( + this.mockUSDT, + this.mockDAI, + this.bob.address, + ZERO_AMOUNT + ); + await assertBalance( + this.mockUSDT, + this.mockDAI, + this.Eve.address, + this.depositAmount + ); + + const token = ethers.utils.formatBytes32String("token"); + + await expect( + this.gateway + .connect(this.deployer) + .settingManagerBool(token, this.mockUSDT.address, BigNumber.from(1)) + ) + .to.emit(this.gateway, Events.Gateway.SettingManagerBool) + .withArgs(token, this.mockUSDT.address, BigNumber.from(1)); + + const treasury = ethers.utils.formatBytes32String("treasury"); + + await expect( + gateway + .connect(this.deployer) + .updateProtocolAddress(treasury, this.treasuryAddress.address) + ).to.emit(gateway, Events.Gateway.ProtocolAddressUpdated); + + const aggregator = ethers.utils.formatBytes32String("aggregator"); + + await expect( + gateway + .connect(this.deployer) + .updateProtocolAddress(aggregator, this.aggregator.address) + ).to.emit(gateway, Events.Gateway.ProtocolAddressUpdated); + }); + + it("Should escrow assets from provider to sender", async function () { + const orderId = ethers.utils.formatBytes32String("order1"); + const amount = ethers.utils.parseEther("1000"); + + // Create the message hash + const messageHash = ethers.utils.solidityKeccak256( + ["bytes32", "address", "address", "address", "uint256"], + [ + orderId, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount, + ] + ); + + // Sign the message + const signature = await this.alice.signMessage( + ethers.utils.arrayify(messageHash) + ); + + // Check initial balances + await assertDepositBalance( + this.gateway, + this.mockUSDT.address, + this.alice.address, + ZERO_AMOUNT + ); + await assertDepositBalance( + this.gateway, + this.mockUSDT.address, + this.bob.address, + ZERO_AMOUNT + ); + + await expect( + this.gateway + .connect(this.alice) + .deposit(this.mockUSDT.address, this.depositAmount) + ) + .to.emit(this.gateway, Events.Gateway.Deposit) + .withArgs( + this.alice.address, + this.mockUSDT.address, + BigNumber.from(this.depositAmount) + ); + + await assertDepositBalance( + this.gateway, + this.mockUSDT.address, + this.alice.address, + this.depositAmount + ); + + // Perform the escrow + await expect( + this.gateway.connect(this.aggregator).escrow( + orderId, + signature, + this.alice.address, // Provider + this.bob.address, // Sender + this.mockUSDT.address, + amount + ) + ) + .to.emit(this.gateway, Events.Gateway.Escrow) + .withArgs( + this.alice.address, + this.bob.address, + amount, + this.mockUSDT.address, + orderId + ); + + // Check final balances + await assertDepositBalance( + this.gateway, + this.mockUSDT.address, + this.alice.address, + this.depositAmount.sub(amount) + ); + + await assertBalance(this.mockUSDT, this.mockUSDT, this.bob.address, amount); + }); + + it("Should fail if the order is already processed", async function () { + const orderId = ethers.utils.formatBytes32String("order1"); + const amount = ethers.utils.parseEther("1000"); + + await expect( + this.gateway + .connect(this.alice) + .deposit(this.mockUSDT.address, this.depositAmount) + ) + .to.emit(this.gateway, Events.Gateway.Deposit) + .withArgs( + this.alice.address, + this.mockUSDT.address, + BigNumber.from(this.depositAmount) + ); + + // Create the message hash + const messageHash = ethers.utils.solidityKeccak256( + ["bytes32", "address", "address", "address", "uint256"], + [ + orderId, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount, + ] + ); + + // Sign the message + const signature = await this.alice.signMessage( + ethers.utils.arrayify(messageHash) + ); + + // Perform the escrow + await this.gateway + .connect(this.aggregator) + .escrow( + orderId, + signature, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount + ); + + // Try to perform the escrow again + await expect( + this.gateway + .connect(this.aggregator) + .escrow( + orderId, + signature, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount + ) + ).to.be.revertedWith("Order already processed"); + }); + + it("Should fail if the signature is invalid", async function () { + const orderId = ethers.utils.formatBytes32String("order1"); + const amount = ethers.utils.parseEther("1000"); + + // Create the message hash + const messageHash = ethers.utils.solidityKeccak256( + ["bytes32", "address", "address", "address", "uint256"], + [ + orderId, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount, + ] + ); + + // Sign the message with a different account + const signature = await this.hacker.signMessage( + ethers.utils.arrayify(messageHash) + ); + + // Try to perform the escrow + await expect( + this.gateway + .connect(this.aggregator) + .escrow( + orderId, + signature, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount + ) + ).to.be.revertedWith("Invalid signature"); + }); + + it("Should fail if the provider has insufficient balance", async function () { + const orderId = ethers.utils.formatBytes32String("order1"); + const amount = ethers.utils.parseEther("2000000"); // More than the deposit amount + + // Create the message hash + const messageHash = ethers.utils.solidityKeccak256( + ["bytes32", "address", "address", "address", "uint256"], + [ + orderId, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount, + ] + ); + + // Sign the message + const signature = await this.alice.signMessage( + ethers.utils.arrayify(messageHash) + ); + + // Try to perform the escrow + await expect( + this.gateway + .connect(this.aggregator) + .escrow( + orderId, + signature, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount + ) + ).to.be.revertedWith("Insufficient balance"); + }); + + it("Should fail when provider address is zero", async function () { + const orderId = ethers.utils.formatBytes32String("order2"); + const amount = ethers.utils.parseEther("1000"); + const zeroAddress = ethers.constants.AddressZero; + + const messageHash = ethers.utils.solidityKeccak256( + ["bytes32", "address", "address", "address", "uint256"], + [orderId, zeroAddress, this.bob.address, this.mockUSDT.address, amount] + ); + + const signature = await this.alice.signMessage( + ethers.utils.arrayify(messageHash) + ); + + await expect( + this.gateway + .connect(this.aggregator) + .escrow( + orderId, + signature, + zeroAddress, + this.bob.address, + this.mockUSDT.address, + amount + ) + ).to.be.revertedWith("Invalid provider address"); + }); + + it("Should revert when sender address is zero", async function () { + const orderId = ethers.utils.formatBytes32String("zeroSenderOrder"); + const amount = ethers.utils.parseEther("1"); + const zeroAddress = ethers.constants.AddressZero; + + const messageHash = ethers.utils.solidityKeccak256( + ["bytes32", "address", "address", "address", "uint256"], + [orderId, this.alice.address, zeroAddress, this.mockUSDT.address, amount] + ); + + const signature = await this.alice.signMessage( + ethers.utils.arrayify(messageHash) + ); + + await expect( + this.gateway + .connect(this.aggregator) + .escrow( + orderId, + signature, + this.alice.address, + zeroAddress, + this.mockUSDT.address, + amount + ) + ).to.be.revertedWith("Invalid sender address"); + }); + + it("Should correctly calculate and transfer protocol fees", async function () { + const orderId = ethers.utils.formatBytes32String("feeOrder"); + const amount = ethers.utils.parseEther("1000"); + const newProtocolFeePercent = 250; // 2.5% + + // Set a new protocol fee + await this.gateway + .connect(this.deployer) + .updateProtocolFee(newProtocolFeePercent); + + // Create the message hash + const messageHash = ethers.utils.solidityKeccak256( + ["bytes32", "address", "address", "address", "uint256"], + [ + orderId, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount, + ] + ); + + // Sign the message + const signature = await this.alice.signMessage( + ethers.utils.arrayify(messageHash) + ); + + // Get initial balances + const initialTreasuryBalance = await this.mockUSDT.balanceOf( + this.treasuryAddress.address + ); + const initialBobBalance = await this.mockUSDT.balanceOf(this.bob.address); + + await expect( + this.gateway + .connect(this.alice) + .deposit(this.mockUSDT.address, this.depositAmount) + ) + .to.emit(this.gateway, Events.Gateway.Deposit) + .withArgs( + this.alice.address, + this.mockUSDT.address, + BigNumber.from(this.depositAmount) + ); + + const initialAliceBalance = await this.gateway.getBalance( + this.mockUSDT.address, + this.alice.address + ); + + // Perform the escrow + await expect( + this.gateway + .connect(this.aggregator) + .escrow( + orderId, + signature, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount + ) + ).to.emit(this.gateway, "Escrow"); + const feeDetails = await this.gateway.getFeeDetails(); + // Calculate expected fee + const expectedFee = amount.mul(feeDetails[0]).div(feeDetails[1]); + + // Check final balances + const finalTreasuryBalance = await this.mockUSDT.balanceOf( + this.treasuryAddress.address + ); + const finalAliceBalance = await this.gateway.getBalance( + this.mockUSDT.address, + this.alice.address + ); + const finalBobBalance = await this.mockUSDT.balanceOf(this.bob.address); + + expect(finalTreasuryBalance).to.equal( + initialTreasuryBalance.add(expectedFee) + ); + expect(finalAliceBalance).to.equal( + initialAliceBalance.sub(amount.add(expectedFee)) + ); + + expect(finalBobBalance).to.equal(initialBobBalance.add(amount)); + + // validate treasury balance + expect(await this.mockUSDT.balanceOf(this.treasuryAddress.address)).to.eq( + initialTreasuryBalance.add(expectedFee) + ); + }); + + it("Should revert when a non-aggregator calls escrow", async function () { + const orderId = ethers.utils.formatBytes32String("nonAggregatorOrder"); + const amount = ethers.utils.parseEther("1"); + + await this.gateway + .connect(this.alice) + .deposit(this.mockUSDT.address, amount); + + const messageHash = ethers.utils.solidityKeccak256( + ["bytes32", "address", "address", "address", "uint256"], + [ + orderId, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount, + ] + ); + + const signature = await this.alice.signMessage( + ethers.utils.arrayify(messageHash) + ); + + await expect( + this.gateway + .connect(this.alice) + .escrow( + orderId, + signature, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount + ) + ).to.be.revertedWith("OnlyAggregator"); + }); + + it("Should revert when amount is zero", async function () { + const orderId = ethers.utils.formatBytes32String("zeroAmountOrder"); + const amount = BigNumber.from(0); + + const messageHash = ethers.utils.solidityKeccak256( + ["bytes32", "address", "address", "address", "uint256"], + [ + orderId, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount, + ] + ); + + const signature = await this.alice.signMessage( + ethers.utils.arrayify(messageHash) + ); + + await expect( + this.gateway + .connect(this.aggregator) + .escrow( + orderId, + signature, + this.alice.address, + this.bob.address, + this.mockUSDT.address, + amount + ) + ).to.be.revertedWith("AmountIsZero"); + }); + +}); diff --git a/test/utils/utils.manager.js b/test/utils/utils.manager.js index 4524cf3..3cbcddc 100644 --- a/test/utils/utils.manager.js +++ b/test/utils/utils.manager.js @@ -35,6 +35,7 @@ const Events = { ProtocolFeeUpdated: "ProtocolFeeUpdated", ProtocolAddressUpdated: "ProtocolAddressUpdated", Deposit: "Deposit", + Escrow: "Escrow", }, };