Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 46 additions & 12 deletions contracts/payment/MCPayment.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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));
}

Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion helpers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
76 changes: 76 additions & 0 deletions scripts/integration/mcpayment-payment.ts
Original file line number Diff line number Diff line change
@@ -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);
});
146 changes: 144 additions & 2 deletions test/payment/mc-payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
);
});

Expand Down Expand Up @@ -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);
Expand Down
Loading