diff --git a/contracts/payment/MCPayment.sol b/contracts/payment/MCPayment.sol index 8c3177fb..bfd65050 100644 --- a/contracts/payment/MCPayment.sol +++ b/contracts/payment/MCPayment.sol @@ -8,17 +8,23 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; /** * @dev MCPayment multi-chain payment contract */ -contract MCPayment is Ownable2StepUpgradeable, EIP712Upgradeable, ReentrancyGuardUpgradeable { +contract MCPayment is + Ownable2StepUpgradeable, + EIP712Upgradeable, + ReentrancyGuardUpgradeable, + AccessControlUpgradeable +{ using ECDSA for bytes32; using SafeERC20 for IERC20; /** * @dev Version of contract */ - string public constant VERSION = "1.0.7"; + string public constant VERSION = "1.0.8"; /** * @dev Version of EIP 712 domain @@ -43,6 +49,11 @@ contract MCPayment is Ownable2StepUpgradeable, EIP712Upgradeable, ReentrancyGuar "Iden3PaymentRailsERC20RequestV1(address tokenAddress,address recipient,uint256 amount,uint256 expirationDate,uint256 nonce,bytes metadata)" ); + /** + * @dev Withdrawer role to withdraw contract amount + */ + bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE"); + struct Iden3PaymentRailsRequestV1 { address recipient; uint256 amount; @@ -117,6 +128,17 @@ contract MCPayment is Ownable2StepUpgradeable, EIP712Upgradeable, ReentrancyGuar _; } + /** + * @dev Modifier to make a function callable only by a Withdrawer role + * or the owner. + */ + modifier onlyWithdrawerRoleOrOwner() { + if (_msgSender() != owner()) { + _checkRole(WITHDRAWER_ROLE, _msgSender()); + } + _; + } + /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); @@ -136,6 +158,16 @@ contract MCPayment is Ownable2StepUpgradeable, EIP712Upgradeable, ReentrancyGuar __Ownable_init(owner); __EIP712_init("MCPayment", DOMAIN_VERSION); __ReentrancyGuard_init(); + __AccessControl_init(); + } + + /** + * @dev Set admin role to an account + * @param admin Address to be granted admin role + */ + function setAdminRole(address admin) external onlyOwner { + // Grant admin role. Admin role can grant and revoke other roles like WITHDRAWER_ROLE + _grantRole(DEFAULT_ADMIN_ROLE, admin); } /** @@ -372,19 +404,21 @@ contract MCPayment is Ownable2StepUpgradeable, EIP712Upgradeable, ReentrancyGuar } /** - * @dev Get owner balance + * @dev Get owner balance, callable by owner or withdrawer role * @return balance of owner */ - function getOwnerBalance() public view onlyOwner returns (uint256) { + function getOwnerBalance() public view onlyWithdrawerRoleOrOwner returns (uint256) { MCPaymentStorage storage $ = _getMCPaymentStorage(); return $.ownerBalance; } /** - * @dev Get owner ERC-20 balance + * @dev Get owner ERC-20 balance, callable by owner or withdrawer role * @return balance of owner */ - function getOwnerERC20Balance(address token) public view onlyOwner returns (uint256) { + function getOwnerERC20Balance( + address token + ) public view onlyWithdrawerRoleOrOwner returns (uint256) { return IERC20(token).balanceOf(address(this)); } @@ -396,28 +430,28 @@ contract MCPayment is Ownable2StepUpgradeable, EIP712Upgradeable, ReentrancyGuar } /** - * @dev Withdraw balance to owner + * @dev Withdraw balance to owner or withdrawer role */ - function ownerWithdraw() public onlyOwner nonReentrant { + function ownerWithdraw() public onlyWithdrawerRoleOrOwner nonReentrant { MCPaymentStorage storage $ = _getMCPaymentStorage(); if ($.ownerBalance == 0) { revert WithdrawErrorNoBalance(); } uint256 amount = $.ownerBalance; $.ownerBalance = 0; - _withdraw(amount, owner()); + _withdraw(amount, _msgSender()); } /** - * @dev Withdraw ERC-20 balance to owner + * @dev Withdraw ERC-20 balance to owner or withdrawer role */ - function ownerERC20Withdraw(address token) public onlyOwner nonReentrant { + function ownerERC20Withdraw(address token) public onlyWithdrawerRoleOrOwner nonReentrant { uint256 amount = IERC20(token).balanceOf(address(this)); if (amount == 0) { revert WithdrawErrorNoBalance(); } - IERC20(token).safeTransfer(owner(), amount); + IERC20(token).safeTransfer(_msgSender(), amount); } function _recoverERC20PaymentSignature( diff --git a/helpers/constants.ts b/helpers/constants.ts index 69674d8b..27b2a1ef 100644 --- a/helpers/constants.ts +++ b/helpers/constants.ts @@ -312,7 +312,7 @@ export const contractsInfo = Object.freeze({ }, MC_PAYMENT: { name: "MCPayment", - version: "1.0.7", + version: "1.0.8", unifiedAddress: "0xe317A4f1450116b2fD381446DEaB41c882D6136D", create2Calldata: ethers.hexlify(ethers.toUtf8Bytes("iden3.create2.MCPayment")), verificationOpts: { diff --git a/scripts/integration/mcpayment-payment.ts b/scripts/integration/mcpayment-payment.ts new file mode 100644 index 00000000..2703cd2b --- /dev/null +++ b/scripts/integration/mcpayment-payment.ts @@ -0,0 +1,76 @@ +import { ethers } from "hardhat"; + +async function main() { + const mcPaymentAddress = "0xYourMCPaymentContractAddressHere"; // replace with actual deployed contract address + const tokenAddress = "0xYourTokenAddressHere"; // replace with actual token address + const issuerPrivateKey = "0xYourIssuerPrivateKeyHere"; // replace with actual issuer private key + const amount = 1000; // amount to be paid + + const issuerWallet = new ethers.Wallet(issuerPrivateKey); + + const [signer] = await ethers.getSigners(); + const token = await ethers.getContractAt("ERC20Token", tokenAddress); + const signerBalance = await token.balanceOf(signer.address); + console.log( + `Balance ${signer.address}: ${ethers.formatEther(signerBalance)} ${await token.symbol()}`, + ); + + const payment = await ethers.getContractAt("MCPayment", mcPaymentAddress); + + const domainData = { + name: "MCPayment", + version: "1.0.0", + chainId: 31337, + verifyingContract: await payment.getAddress(), + }; + + const erc20types = { + Iden3PaymentRailsERC20RequestV1: [ + { name: "tokenAddress", type: "address" }, + { name: "recipient", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "expirationDate", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "metadata", type: "bytes" }, + ], + }; + + const paymentData = { + tokenAddress: tokenAddress, + recipient: issuerWallet.address, + amount: amount, + expirationDate: Math.round(new Date().getTime() / 1000) + 60 * 60, // 1 hour + nonce: 35, + metadata: "0x", + }; + const signature = await issuerWallet.signTypedData(domainData, erc20types, paymentData); + + // approve MCPayment contract to spend tokens + const txApprove = await token.connect(signer).approve(await payment.getAddress(), amount); + await txApprove.wait(); + console.log( + `${signer.address} approved ${amount} ${await token.symbol()} for MCPayment contract at ${mcPaymentAddress}`, + ); + + const erc20PaymentGas = await payment + .connect(signer) + .payERC20.estimateGas(paymentData, signature); + console.log("ERC-20 Payment Gas: " + erc20PaymentGas); + + let ownerBalance = await payment.getOwnerERC20Balance(tokenAddress); + console.log(`Owner ERC-20 Balance before payment: ${ownerBalance}`); + + const tx = await payment.connect(signer).payERC20(paymentData, signature); + console.log("ERC-20 Payment Tx Hash: " + tx.hash); + await tx.wait(); + + ownerBalance = await payment.getOwnerERC20Balance(tokenAddress); + console.log(`Owner ERC-20 Balance after payment: ${ownerBalance}`); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/test/payment/mc-payment.test.ts b/test/payment/mc-payment.test.ts index f29f2a90..e0beea05 100644 --- a/test/payment/mc-payment.test.ts +++ b/test/payment/mc-payment.test.ts @@ -177,6 +177,90 @@ describe("MC Payment Contract", () => { ); }); + it("Check payment with WITHDRAWER_ROLE account", async () => { + const paymentData = { + recipient: issuer1Signer.address, + amount: 100, + expirationDate: Math.round(new Date().getTime() / 1000) + 60 * 60, // 1 hour + nonce: 25, + metadata: "0x", + }; + const signature = await issuer1Signer.signTypedData(domainData, types, paymentData); + + await expect( + payment.connect(userSigner).pay(paymentData, signature, { + value: 100, + }), + ).to.changeEtherBalances([userSigner, payment], [-100, 100]); + + const isPaymentDone = await payment.isPaymentDone(issuer1Signer.address, 25); + expect(isPaymentDone).to.be.true; + + // issuer withdraw + const issuer1BalanceInContract = await payment.getBalance(issuer1Signer.address); + expect(issuer1BalanceInContract).to.be.eq(90); + + await expect(payment.connect(issuer1Signer).issuerWithdraw()).to.changeEtherBalance( + issuer1Signer, + 90, + ); + + // second issuer withdraw + await expect(payment.connect(issuer1Signer).issuerWithdraw()).to.be.revertedWithCustomError( + payment, + "WithdrawErrorNoBalance", + ); + + const issuer1BalanceAfterWithdraw = await payment.getBalance(issuer1Signer.address); + expect(issuer1BalanceAfterWithdraw).to.be.eq(0); + + // owner withdraw + const ownerBalanceInContract = await payment.connect(owner).getOwnerBalance(); + expect(ownerBalanceInContract).to.be.eq(10); + + await expect(payment.connect(userSigner).ownerWithdraw()).to.be.revertedWithCustomError( + payment, + "AccessControlUnauthorizedAccount", + ); + + // grant admin role to owner + await payment.connect(owner).setAdminRole(owner.address); + // grant WITHDRAWER_ROLE to userSigner + await payment + .connect(owner) + .grantRole(await payment.WITHDRAWER_ROLE(), await userSigner.getAddress()); + + // now userSigner can withdraw owner balance + await expect(payment.connect(userSigner).ownerWithdraw()).to.changeEtherBalance(userSigner, 10); + // owner balance should be 0 + const ownerBalanceAfterWithdraw = await payment.connect(owner).getOwnerBalance(); + expect(ownerBalanceAfterWithdraw).to.be.eq(0); + + // second owner withdraw + await expect(payment.connect(userSigner).ownerWithdraw()).to.be.revertedWithCustomError( + payment, + "WithdrawErrorNoBalance", + ); + await expect(payment.connect(owner).ownerWithdraw()).to.be.revertedWithCustomError( + payment, + "WithdrawErrorNoBalance", + ); + }); + + it("Calling setAdminRole by owner:", async () => { + expect(await payment.hasRole(await payment.DEFAULT_ADMIN_ROLE(), owner.address)).to.be.false; + await payment.connect(owner).setAdminRole(owner.address); + expect(await payment.hasRole(await payment.DEFAULT_ADMIN_ROLE(), owner.address)).to.be.true; + await payment.connect(owner).revokeRole(await payment.DEFAULT_ADMIN_ROLE(), owner.address); + expect(await payment.hasRole(await payment.DEFAULT_ADMIN_ROLE(), owner.address)).to.be.false; + }); + + it("Calling setAdminRole by non-owner:", async () => { + await expect( + payment.connect(userSigner).setAdminRole(owner.address), + ).to.be.revertedWithCustomError(payment, "OwnableUnauthorizedAccount"); + }); + it("Update owner percentage:", async () => { expect(await payment.getOwnerPercentage()).to.be.eq(10); await payment.connect(owner).updateOwnerPercentage(20); @@ -208,10 +292,10 @@ describe("MC Payment Contract", () => { ).to.be.revertedWithCustomError(payment, "OwnableUnauthorizedAccount"); }); - it("Owner withdraw not owner account:", async () => { + it("Owner withdraw not owner or WITHDRAWER_ROLE account:", async () => { await expect(payment.connect(issuer1Signer).ownerWithdraw()).to.be.revertedWithCustomError( payment, - "OwnableUnauthorizedAccount", + "AccessControlUnauthorizedAccount", ); }); @@ -325,6 +409,64 @@ describe("MC Payment Contract", () => { expect(await payment.getOwnerERC20Balance(tokenAddress)).to.be.eq(0); }); + it("ERC-20 payment with withdrawer role:", async () => { + const tokenFactory = await ethers.getContractFactory("ERC20Token", owner); + const token = await tokenFactory.deploy(1_000); + await token.connect(owner).transfer(await userSigner.getAddress(), 100); + expect(await token.balanceOf(await userSigner.getAddress())).to.be.eq(100); + + await token.connect(userSigner).approve(await payment.getAddress(), 10); + + const paymentData = { + tokenAddress: await token.getAddress(), + recipient: issuer1Signer.address, + amount: 10, + expirationDate: Math.round(new Date().getTime() / 1000) + 60 * 60, // 1 hour + nonce: 35, + metadata: "0x", + }; + + const signature = await issuer1Signer.signTypedData(domainData, erc20types, paymentData); + const erc20PaymentGas = await payment + .connect(userSigner) + .payERC20.estimateGas(paymentData, signature); + console.log("ERC-20 Payment Gas: " + erc20PaymentGas); + + await expect( + payment.connect(userSigner).payERC20(paymentData, signature), + ).to.changeTokenBalances(token, [userSigner, issuer1Signer, payment], [-10, 9, 1]); + expect(await payment.isPaymentDone(issuer1Signer.address, 35)).to.be.true; + // owner ERC-20 withdraw + const tokenAddress = await token.getAddress(); + const ownerBalance = await payment.getOwnerERC20Balance(tokenAddress); + expect(ownerBalance).to.be.eq(1); + + await expect( + payment.connect(userSigner).ownerERC20Withdraw(tokenAddress), + ).to.be.revertedWithCustomError(payment, "AccessControlUnauthorizedAccount"); + + // grant admin role to owner + await payment.connect(owner).setAdminRole(owner.address); + // grant WITHDRAWER_ROLE to userSigner + await payment + .connect(owner) + .grantRole(await payment.WITHDRAWER_ROLE(), await userSigner.getAddress()); + + await expect( + payment.connect(userSigner).ownerERC20Withdraw(tokenAddress), + ).to.changeTokenBalances(token, [userSigner, payment], [1, -1]); + + // second owner or withdrawer withdraw + await expect( + payment.connect(userSigner).ownerERC20Withdraw(tokenAddress), + ).to.be.revertedWithCustomError(payment, "WithdrawErrorNoBalance"); + + await expect( + payment.connect(owner).ownerERC20Withdraw(tokenAddress), + ).to.be.revertedWithCustomError(payment, "WithdrawErrorNoBalance"); + expect(await payment.getOwnerERC20Balance(tokenAddress)).to.be.eq(0); + }); + it("ERC-20 payment with different issuer owner percentage:", async () => { const tokenFactory = await ethers.getContractFactory("ERC20Token", owner); const token = await tokenFactory.deploy(1_000);