diff --git a/contracts/payment/SponsorPayment.sol b/contracts/payment/SponsorPayment.sol new file mode 100644 index 00000000..26411f76 --- /dev/null +++ b/contracts/payment/SponsorPayment.sol @@ -0,0 +1,523 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import {EIP712Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title SponsorPayment + * @dev A contract for sponsored payments between Sponsors and Recipient$. + * Supports Ether and ERC-20 token payments with enhanced security feature$. + * @custom:storage-location erc7201:iden3.storage.SponsorPayment + */ +contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownable2StepUpgradeable { + using ECDSA for bytes32; + using SafeERC20 for IERC20; + + // Custom errors for more descriptive revert reasons + error InvalidDeposit(string reason); + error InvalidPaymentClaim(string reason); + error InvalidWithdraw(string reason); + error InvalidToken(string reason); + error InvalidParameter(string reason); + + // Event emitted when a deposit is made ERC20 + event ERC20Deposit(address indexed sponsor, address indexed token, uint256 amount); + + // Event emitted when a deposit is made + event Deposit(address indexed sponsor, uint256 amount); + + // Event emitted when a withdrawal is made + event Withdrawal(address indexed sponsor, uint256 amount); + + // Event emitted when a withdrawal is made + event ERC20Withdrawal(address indexed sponsor, address indexed token, uint256 amount); + + // Event emitted when a withdrawal request is made + event WithdrawalRequested(address indexed sponsor, uint256 amount, uint256 lockTime); + + // Event emitted when a withdrawal request is made ERC20 + event ERC20WithdrawalRequested( + address indexed sponsor, + address indexed token, + uint256 amount, + uint256 lockTime + ); + + // Event emitted when a withdrawal request is cancelled + event WithdrawalCancelled(address indexed sponsor, uint256 amount); + + // Event emitted when a withdrawal request is cancelled ERC20 + event ERC20WithdrawalCancelled(address indexed sponsor, address indexed token, uint256 amount); + + // Event emitted when a payment is claimed + event PaymentClaimed(address indexed recipient, uint256 indexed nonce, uint256 amount); + + // Event emitted when a payment is claimed + event ERC20PaymentClaimed( + address indexed recipient, + uint256 indexed nonce, + address indexed token, + uint256 amount + ); + + // Event emitted when the owner withdraws their balance + event OwnerBalanceWithdrawn(uint256 amount); + + // Event emitted when the owner withdraws their balance ERC20 + event ERC20OwnerBalanceWithdrawn(uint256 amount); + + /** + * @dev Payment details used in claim logic. + */ + struct ERC20SponsorPaymentInfo { + address recipient; + uint256 amount; + address token; + uint256 nonce; + uint256 expiration; + bytes metadata; + } + + /** + * @dev Payment details used in claim logic. + */ + struct SponsorPaymentInfo { + address recipient; + uint256 amount; + uint256 nonce; + uint256 expiration; + bytes metadata; + } + + struct WithdrawalRequest { + uint256 amount; + uint256 lockTime; + } + + /** + * @dev Main storage structure for the contract + */ + struct SponsorPaymentStorage { + mapping(address sponsor => mapping(address token => uint256 balance)) balances; + mapping(address sponsor => mapping(address token => WithdrawalRequest request)) withdrawalRequests; + mapping(bytes32 requestId => bool isWithdrawn) isWithdrawn; + uint8 ownerPercentFee; + uint256 withdrawalDelay; + } + + string public constant VERSION = "1.0.0"; + + bytes32 public constant ERC_20_SPONSOR_PAYMENT_INFO_TYPE_HASH = + keccak256( + // solhint-disable-next-line max-line-length + "ERC20SponsorPaymentInfo(address recipient,uint256 amount,address token,uint256 expiration,uint256 nonce,bytes metadata)" + ); + + bytes32 public constant SPONSOR_PAYMENT_INFO_TYPE_HASH = + keccak256( + // solhint-disable-next-line max-line-length + "ERC20SponsorPaymentInfo(address recipient,uint256 amount,uint256 expiration,uint256 nonce,bytes metadata)" + ); + + // keccak256(abi.encode(uint256(keccak256("iden3.storage.SponsorPayment")) - 1)) & + // ~bytes32(uint256(0xff)); + bytes32 private constant SPONSOR_PAYMENT_STORAGE_LOCATION = + 0x98fc76e32452055302f77aa95cd08aa0cf22c02a3ebdaee3e1411f6c47c2ef00; + + modifier validToken(address token) { + if (token == address(0) || token.code.length == 0) { + revert InvalidToken("Not a contract address"); + } + _; + } + + /** + * @dev Valid percent value modifier + */ + modifier validPercentValue(uint256 percent) { + if (percent > 100) { + revert InvalidParameter("Invalid owner percentage"); + } + _; + } + + function initialize( + address owner, + uint8 ownerPercentFee, + uint256 withdrawalDelay + ) external initializer validPercentValue(ownerPercentFee) { + if (withdrawalDelay == 0) revert InvalidParameter("Invalid withdrawal delay"); + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + $.ownerPercentFee = ownerPercentFee; + $.withdrawalDelay = withdrawalDelay; + __ReentrancyGuard_init(); + __EIP712_init("SponsorPayment", VERSION); + __Ownable_init(owner); + } + + function _getSponsorPaymentStorage() private pure returns (SponsorPaymentStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := SPONSOR_PAYMENT_STORAGE_LOCATION + } + } + + /** + * @dev Get the owner percentage value + * @return ownerPercentFee + */ + function getOwnerPercentFee() external view returns (uint8) { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + return $.ownerPercentFee; + } + + /** + * @dev Updates owner percentage value + * @param ownerPercentFee Amount between 0 and 100 representing the owner percentage + */ + function updateOwnerPercentFee( + uint8 ownerPercentFee + ) external onlyOwner validPercentValue(ownerPercentFee) { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + $.ownerPercentFee = ownerPercentFee; + } + + /** + * @dev Recovers the signer of a SponsorPaymentInfo struct + * @param payment The SponsorPaymentInfo struct + * @param signature The signature of the payment + * @return The address of the signer + */ + function recoverSponsorPaymentSigner( + SponsorPaymentInfo memory payment, + bytes memory signature + ) public view returns (address) { + bytes32 digest = _hashTypedDataV4( + keccak256( + abi.encode( + SPONSOR_PAYMENT_INFO_TYPE_HASH, + payment.recipient, + payment.amount, + payment.expiration, + payment.nonce, + keccak256(payment.metadata) + ) + ) + ); + + return _tryRecoverSigner(digest, signature); + } + + /** + * @dev Recovers the signer of a SponsorPaymentInfo struct + * @param payment The SponsorPaymentInfo struct + * @param signature The signature of the payment + * @return The address of the signer + */ + function recoverSponsorPaymentSignerERC20( + ERC20SponsorPaymentInfo memory payment, + bytes memory signature + ) public view returns (address) { + bytes32 digest = _hashTypedDataV4( + keccak256( + abi.encode( + ERC_20_SPONSOR_PAYMENT_INFO_TYPE_HASH, + payment.recipient, + payment.amount, + payment.token, + payment.expiration, + payment.nonce, + keccak256(payment.metadata) + ) + ) + ); + + return _tryRecoverSigner(digest, signature); + } + + /** + * @notice Deposits Ether or tokens as a sponsor. + */ + function deposit() external payable nonReentrant { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + + if (msg.value == 0) revert InvalidDeposit("Invalid value amount"); + $.balances[_msgSender()][address(0)] += msg.value; + + emit Deposit(_msgSender(), msg.value); + } + + /** + * @notice Deposits Ether or tokens as a sponsor. + * @param amount The amount to deposit + * @param token The address of the token (use address(0) for Ether) + */ + function depositERC20(uint256 amount, address token) external nonReentrant validToken(token) { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + if (amount == 0) revert InvalidDeposit("Invalid token amount"); + IERC20(token).safeTransferFrom(_msgSender(), address(this), amount); + + $.balances[_msgSender()][token] += amount; + + emit ERC20Deposit(_msgSender(), token, amount); + } + + /** + * @notice Request a withdrawal with delay + * @param amount The amount to withdraw + */ + function requestWithdrawal(uint256 amount) external { + uint256 lockTime = _requestWithdrawal(address(0), amount); + emit WithdrawalRequested(_msgSender(), amount, lockTime); + } + + /** + * @notice Request a withdrawal with delay + * @param amount The amount to withdraw + * @param token The address of the token (use address(0) for Ether) + */ + function requestWithdrawalERC20(uint256 amount, address token) external validToken(token) { + uint256 lockTime = _requestWithdrawal(token, amount); + emit ERC20WithdrawalRequested(_msgSender(), token, amount, lockTime); + } + + /** + * @notice Cancel a pending withdrawal request + */ + function cancelWithdrawal() external { + uint256 amount = _cancelWithdrawal(address(0)); + emit WithdrawalCancelled(_msgSender(), amount); + } + + /** + * @notice Cancel a pending withdrawal request + * @param token The token address + */ + function cancelWithdrawalERC20(address token) external validToken(token) { + uint256 amount = _cancelWithdrawal(token); + emit ERC20WithdrawalCancelled(_msgSender(), token, amount); + } + + /** + * @notice Execute withdrawal after delay period + */ + function withdraw() external nonReentrant { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + uint256 amount = _getWithdrawalAmount(address(0)); + delete $.withdrawalRequests[_msgSender()][address(0)]; + (bool success, ) = payable(_msgSender()).call{value: amount}(""); + if (!success) revert InvalidWithdraw("Transfer failed"); + emit Withdrawal(_msgSender(), amount); + } + + /** + * @notice Execute withdrawal after delay period + * @param token The address of the token + */ + function withdrawERC20(address token) external nonReentrant validToken(token) { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + uint256 amount = _getWithdrawalAmount(token); + IERC20(token).safeTransfer(_msgSender(), amount); + delete $.withdrawalRequests[_msgSender()][token]; + emit ERC20Withdrawal(_msgSender(), token, amount); + } + + /** + * @notice Allows a recipient to claim a payment with a valid signature + * @param payment SponsorPayment entInfo struct containing payment details + * @param signature EIP-712 signature from the sponsor + */ + function claimPayment( + SponsorPaymentInfo calldata payment, + bytes calldata signature + ) external nonReentrant { + uint256 recipientPart = _claimPayment( + payment.recipient, + payment.nonce, + payment.expiration, + payment.amount, + address(0), + recoverSponsorPaymentSigner(payment, signature) + ); + + (bool success, ) = payable(payment.recipient).call{value: recipientPart}(""); + if (!success) { + revert InvalidPaymentClaim("Payment transfer failed"); + } + + emit PaymentClaimed(payment.recipient, payment.nonce, payment.amount); + } + + /** + * @notice Allows a recipient to claim a payment with a valid signature + * @param payment ERC20SponsorPaymentInfo struct containing payment details + * @param signature EIP-712 signature from the sponsor + */ + function claimPaymentERC20( + ERC20SponsorPaymentInfo calldata payment, + bytes calldata signature + ) external nonReentrant validToken(payment.token) { + uint256 recipientPart = _claimPayment( + payment.recipient, + payment.nonce, + payment.expiration, + payment.amount, + payment.token, + recoverSponsorPaymentSignerERC20(payment, signature) + ); + IERC20(payment.token).safeTransfer(payment.recipient, recipientPart); + + emit ERC20PaymentClaimed(payment.recipient, payment.nonce, payment.token, payment.amount); + } + + /** + * @dev Allows the owner to withdraw their accumulated balance. + * @param tokenAddress The address of the token (use address(0) for Ether) + */ + function withdrawOwnerBalance(address tokenAddress) external onlyOwner nonReentrant { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + uint256 amount = $.balances[address(this)][tokenAddress]; + if (amount == 0) revert InvalidWithdraw("No balance to withdraw"); + + $.balances[address(this)][tokenAddress] = 0; + if (tokenAddress == address(0)) { + (bool success, ) = payable(owner()).call{value: amount}(""); + if (!success) revert InvalidWithdraw("Owner balance transfer failed"); + } else { + if (tokenAddress.code.length == 0) revert InvalidToken("Not a contract address"); + IERC20(tokenAddress).safeTransfer(owner(), amount); + } + emit OwnerBalanceWithdrawn(amount); + } + + /** + * @dev Updates the withdrawal delay value + * @param newWithdrawalDelay The new withdrawal delay in seconds + */ + function updateWithdrawalDelay(uint256 newWithdrawalDelay) external onlyOwner { + if (newWithdrawalDelay == 0) revert InvalidParameter("Invalid withdrawal delay"); + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + $.withdrawalDelay = newWithdrawalDelay; + } + + /** + * @notice View functions for checking contract state + */ + function isPaymentClaimed(address recipient, uint256 nonce) external view returns (bool) { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + bytes32 requestId = keccak256(abi.encode(recipient, nonce)); + return $.isWithdrawn[requestId]; + } + + /** + * @dev Returns the balance of a specific token for a given sponsor. + * @return The balance of the specified token for the given sponsor. + */ + function getBalance(address sponsor) external view returns (uint256) { + return _getSponsorPaymentStorage().balances[sponsor][address(0)]; + } + + /** + * @dev Returns the balance of a specific token for a given sponsor. ERC20 + * @param sponsor The address of the sponsor whose balance is being queried. + * @param token The address of the token contract. + * @return The balance of the specified token for the given sponsor. + */ + function getBalanceERC20(address sponsor, address token) external view returns (uint256) { + return _getSponsorPaymentStorage().balances[sponsor][token]; + } + + function _getWithdrawalAmount(address token) private view returns (uint256) { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + WithdrawalRequest storage request = $.withdrawalRequests[_msgSender()][token]; + + if (request.amount == 0) revert InvalidWithdraw("No withdrawal request exists"); + + if (block.timestamp < request.lockTime) + revert InvalidWithdraw("Withdrawal is still locked"); + + return request.amount; + } + + function _tryRecoverSigner( + bytes32 digest, + bytes memory signature + ) private pure returns (address) { + (address signer, ECDSA.RecoverError err, ) = digest.tryRecover(signature); + if (err != ECDSA.RecoverError.NoError) revert InvalidPaymentClaim("Invalid signature"); + + return signer; + } + + function _claimPayment( + address recipient, + uint256 nonce, + uint256 expiration, + uint256 amount, + address token, + address sponsor + ) private returns (uint256) { + if (recipient == address(0) || _msgSender() != recipient) + revert InvalidPaymentClaim("Invalid recipient"); + + if (block.timestamp > expiration) revert InvalidPaymentClaim("Payment expired"); + + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + + bytes32 requestId = keccak256(abi.encode(recipient, nonce)); + if ($.isWithdrawn[requestId]) revert InvalidPaymentClaim("Payment already claimed"); + + uint256 sponsorBalance = $.balances[sponsor][token]; + if (sponsorBalance == 0) revert InvalidPaymentClaim("Invalid sponsor"); + if (sponsorBalance < amount) revert InvalidPaymentClaim("Insufficient balance"); + + uint256 ownerPart = (amount * $.ownerPercentFee) / 100; + uint256 recipientPart = amount - ownerPart; + $.isWithdrawn[requestId] = true; + $.balances[sponsor][token] -= amount; + $.balances[address(this)][token] += ownerPart; + + return recipientPart; + } + + function _requestWithdrawal(address balanceAddress, uint256 amount) private returns (uint256) { + if (amount == 0) revert InvalidWithdraw("Invalid withdrawal amount"); + + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + + uint256 balance = $.balances[_msgSender()][balanceAddress]; + if (balance < amount) revert InvalidWithdraw("Insufficient balance"); + + WithdrawalRequest storage request = $.withdrawalRequests[_msgSender()][balanceAddress]; + + if (request.amount != 0) revert InvalidWithdraw("Existing withdrawal pending"); + + uint256 lockTime = block.timestamp + $.withdrawalDelay; + // Create withdrawal request + request.amount = amount; + request.lockTime = lockTime; + $.balances[_msgSender()][balanceAddress] -= amount; + + return lockTime; + } + + function _cancelWithdrawal(address token) private returns (uint256) { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + WithdrawalRequest storage request = $.withdrawalRequests[_msgSender()][token]; + + if (request.amount == 0) revert InvalidWithdraw("No withdrawal request exists"); + + uint256 amount = request.amount; + delete $.withdrawalRequests[_msgSender()][token]; + + // Return the funds + $.balances[_msgSender()][token] += amount; + + return amount; + } +} diff --git a/helpers/DeployHelper.ts b/helpers/DeployHelper.ts index fa25cbe3..9a57d166 100644 --- a/helpers/DeployHelper.ts +++ b/helpers/DeployHelper.ts @@ -22,6 +22,7 @@ import { waitNotToInterfereWithHardhatIgnition, } from "./helperUtils"; import { MCPaymentProxyModule } from "../ignition/modules/mcPayment"; +import { SponsorPaymentProxyModule } from "../ignition/modules/sponsorPayment"; const SMT_MAX_DEPTH = 64; @@ -1232,6 +1233,58 @@ export class DeployHelper { }; } + async deploySponsorPayment( + ownerPercentage: number, + sponsorWithDrawalDelay: number, + deployStrategy: "basic" | "create2" = "basic", + ): Promise<{ + sponsorPayment: Contract; + }> { + const owner = this.signers[0]; + const SponsorPaymentFactory = await ethers.getContractFactory("SponsorPayment"); + const Create2AddressAnchorFactory = await ethers.getContractFactory("Create2AddressAnchor"); + + let sponsorPayment; + if (deployStrategy === "create2") { + this.log("deploying with CREATE2 strategy..."); + + // Deploying SponsorPayment contract to predictable address but with dummy implementation + sponsorPayment = ( + await ignition.deploy(SponsorPaymentProxyModule, { + strategy: deployStrategy, + }) + ).proxy; + await sponsorPayment.waitForDeployment(); + + // Upgrading SponsorPayment contract to the first real implementation + // and force network files import, so creation, as they do not exist at the moment + const sponsorPaymentAddress = await sponsorPayment.getAddress(); + await upgrades.forceImport(sponsorPaymentAddress, Create2AddressAnchorFactory); + sponsorPayment = await upgrades.upgradeProxy(sponsorPaymentAddress, SponsorPaymentFactory, { + redeployImplementation: "always", + call: { + fn: "initialize", + args: [await owner.getAddress(), ownerPercentage, sponsorWithDrawalDelay], + }, + }); + } else { + this.log("deploying with BASIC strategy..."); + + sponsorPayment = await upgrades.deployProxy(SponsorPaymentFactory, [ + await owner.getAddress(), + ownerPercentage, + sponsorWithDrawalDelay, + ]); + } + + await sponsorPayment.waitForDeployment(); + console.log("\nSponsorPaymentAddress deployed to:", await sponsorPayment.getAddress()); + + return { + sponsorPayment, + }; + } + async upgradeIdentityTreeStore( identityTreeStoreAddress: string, stateAddress: string, diff --git a/helpers/constants.ts b/helpers/constants.ts index 354845dc..703b8f9f 100644 --- a/helpers/constants.ts +++ b/helpers/constants.ts @@ -245,6 +245,16 @@ export const contractsInfo = Object.freeze({ libraries: {}, }, }, + SPONSOR_PAYMENT: { + name: "SponsorPayment", + version: "1.0.0", + unifiedAddress: "", + create2Calldata: ethers.hexlify(ethers.toUtf8Bytes("iden3.create2.SponsorPayment")), + verificationOpts: { + constructorArgsImplementation: [], + libraries: {}, + }, + }, CROSS_CHAIN_PROOF_VALIDATOR: { name: "CrossChainProofValidator", unifiedAddress: "", diff --git a/ignition/modules/sponsorPayment.ts b/ignition/modules/sponsorPayment.ts new file mode 100644 index 00000000..6ccdcda5 --- /dev/null +++ b/ignition/modules/sponsorPayment.ts @@ -0,0 +1,22 @@ +import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; +import { contractsInfo } from "../../helpers/constants"; + +export const SponsorPaymentProxyModule = buildModule("SponsorPaymentProxyModule", (m) => { + const proxyAdminOwner = m.getAccount(0); + + // This contract is supposed to be deployed to the same address across many networks, + // so the first implementation address is a dummy contract that does nothing but accepts any calldata. + // Therefore, it is a mechanism to deploy TransparentUpgradeableProxy contract + // with constant constructor arguments, so predictable init bytecode and predictable CREATE2 address. + // Subsequent upgrades are supposed to switch this proxy to the real implementation. + + const proxy = m.contract("TransparentUpgradeableProxy", [ + contractsInfo.CREATE2_ADDRESS_ANCHOR.unifiedAddress, + proxyAdminOwner, + contractsInfo.SPONSOR_PAYMENT.create2Calldata, + ]); + const proxyAdminAddress = m.readEventArgument(proxy, "AdminChanged", "newAdmin"); + const proxyAdmin = m.contractAt("ProxyAdmin", proxyAdminAddress); + + return { proxyAdmin, proxy }; +}); diff --git a/scripts/deploy/deploySponsorPayment.ts b/scripts/deploy/deploySponsorPayment.ts new file mode 100644 index 00000000..e7723009 --- /dev/null +++ b/scripts/deploy/deploySponsorPayment.ts @@ -0,0 +1,29 @@ +import { DeployHelper } from "../../helpers/DeployHelper"; +import { getConfig, verifyContract } from "../../helpers/helperUtils"; +import { contractsInfo } from "../../helpers/constants"; + +async function main() { + const config = getConfig(); + const deployStrategy: "basic" | "create2" = + config.deployStrategy == "create2" ? "create2" : "basic"; + + const deployHelper = await DeployHelper.initialize(null, true); + + const { sponsorPayment } = await deployHelper.deploySponsorPayment( + 10, + 60 * 60 * 24, // 1 day unix time + deployStrategy, + ); + + await verifyContract( + await sponsorPayment.getAddress(), + contractsInfo.SPONSOR_PAYMENT.verificationOpts, + ); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/test/payment/sponsor-payment.test.ts b/test/payment/sponsor-payment.test.ts new file mode 100644 index 00000000..596ad391 --- /dev/null +++ b/test/payment/sponsor-payment.test.ts @@ -0,0 +1,453 @@ +import { ethers, upgrades } from "hardhat"; +import { ERC20Token, SponsorPayment, SponsorPayment__factory } from "../../typechain-types"; +import { expect } from "chai"; +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +describe("Sponsor Payment Contract", () => { + let sponsorPaymentContract: SponsorPayment; + let paymentContractAddr: string; + let token: ERC20Token; + let tokenAddr: string; + let signers: HardhatEthersSigner[] = []; + let domainData: { name: string; version: string; chainId: number; verifyingContract: string }; + const OWNER_FEE_PERCENTS = 10; + const SPONSOR_WITHDRAW_DELAY = 60 * 60; // 1 hour + const typesERC20 = { + ERC20SponsorPaymentInfo: [ + { name: "recipient", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "token", type: "address" }, + { name: "expiration", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "metadata", type: "bytes" }, + ], + }; + const types = { + ERC20SponsorPaymentInfo: [ + { name: "recipient", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "expiration", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "metadata", type: "bytes" }, + ], + }; + + async function deployContractsFixture() { + const [owner] = await ethers.getSigners(); + + sponsorPaymentContract = (await upgrades.deployProxy(new SponsorPayment__factory(owner), [ + owner.address, + OWNER_FEE_PERCENTS, + SPONSOR_WITHDRAW_DELAY, + ])) as unknown as SponsorPayment; + await sponsorPaymentContract.waitForDeployment(); + } + + beforeEach(async () => { + await loadFixture(deployContractsFixture); + signers = await ethers.getSigners(); + paymentContractAddr = await sponsorPaymentContract.getAddress(); + + domainData = { + name: "SponsorPayment", + version: "1.0.0", + chainId: 31337, + verifyingContract: paymentContractAddr, + }; + + const tokenFactory = await ethers.getContractFactory("ERC20Token", signers[0]); + token = await tokenFactory.deploy(1_000); + tokenAddr = await token.getAddress(); + }); + + it("check signature verification:", async () => { + const [, sponsor, recipient] = signers; + + const paymentDataERC20 = { + recipient: recipient.address, + amount: 100, + token: tokenAddr, + expiration: Math.round(new Date().getTime() / 1000) + 60 * 60, + nonce: 22, + metadata: "0x", + }; + const signatureERC20 = await sponsor.signTypedData(domainData, typesERC20, paymentDataERC20); + + const signerERC20 = await sponsorPaymentContract + .connect(recipient) + .recoverSponsorPaymentSignerERC20(paymentDataERC20, signatureERC20); + expect(signerERC20).to.be.eq(sponsor.address); + + const paymentData = { + recipient: recipient.address, + amount: 100, + expiration: Math.round(new Date().getTime() / 1000) + 60 * 60, + nonce: 22, + metadata: "0x", + }; + + const signature = await sponsor.signTypedData(domainData, types, paymentData); + + const signer = await sponsorPaymentContract + .connect(recipient) + .recoverSponsorPaymentSigner(paymentData, signature); + expect(signer).to.be.eq(sponsor.address); + }); + + describe("Deposit", () => { + it("should handle negative scenario", async () => { + const [, sponsor, other] = signers; + // BEGIN ERROR TESTS + await expect( + sponsorPaymentContract.connect(sponsor).deposit({ value: 0 }), + ).to.be.revertedWithCustomError(sponsorPaymentContract, "InvalidDeposit"); + + await expect( + sponsorPaymentContract.connect(sponsor).depositERC20(100, other.address), + ).to.be.revertedWithCustomError(sponsorPaymentContract, "InvalidToken"); + + await expect( + sponsorPaymentContract.connect(sponsor).depositERC20(0, tokenAddr), + ).to.be.revertedWithCustomError(sponsorPaymentContract, "InvalidDeposit"); + }); + + it("should handle token deposit", async () => { + const [owner, sponsor, other] = signers; + + await token.connect(owner).transfer(sponsor.address, 100); + await token.connect(owner).transfer(other.address, 200); + + expect(await token.balanceOf(sponsor.address)).to.be.eq(100); + + await token.connect(sponsor).approve(paymentContractAddr, 50); + await token.connect(other).approve(paymentContractAddr, 100); + + await sponsorPaymentContract.connect(sponsor).depositERC20(50, tokenAddr); + await sponsorPaymentContract.connect(other).depositERC20(100, tokenAddr); + + expect(await token.balanceOf(sponsor.address)).to.be.eq(50); + expect(await token.balanceOf(other.address)).to.be.eq(100); + + await expect(sponsorPaymentContract.connect(sponsor).depositERC20(100, tokenAddr)).to.be + .reverted; + + expect( + await sponsorPaymentContract.connect(sponsor).getBalanceERC20(sponsor.address, tokenAddr), + ).to.be.eq(50); + + await token.connect(sponsor).approve(paymentContractAddr, 50); + + await expect(await sponsorPaymentContract.connect(sponsor).depositERC20(50, tokenAddr)) + .to.emit(sponsorPaymentContract, "ERC20Deposit") + .withArgs(sponsor.address, tokenAddr, 50); + + expect( + await sponsorPaymentContract.connect(sponsor).getBalanceERC20(sponsor.address, tokenAddr), + ).to.be.eq(100); + }); + }); + + describe("Withdrawal", () => { + it("should handle withdraw", async () => { + const [, sponsor] = signers; + + await expect( + sponsorPaymentContract.connect(sponsor).requestWithdrawal(100), + ).to.be.revertedWithCustomError(sponsorPaymentContract, "InvalidWithdraw"); + + await sponsorPaymentContract.connect(sponsor).deposit({ value: ethers.parseEther("1.0") }); + + await sponsorPaymentContract.connect(sponsor).requestWithdrawal(ethers.parseEther("0.5")); + + await expect( + sponsorPaymentContract.connect(sponsor).requestWithdrawal(ethers.parseEther("0.5")), + ).to.be.revertedWithCustomError(sponsorPaymentContract, "InvalidWithdraw"); + + await expect( + sponsorPaymentContract.connect(sponsor).withdraw(), + ).to.be.revertedWithCustomError(sponsorPaymentContract, "InvalidWithdraw"); + }); + + it("should handle successful withdrawal", async () => { + const [, sponsor] = signers; + const sponsorAddr = await sponsor.getAddress(); + + await sponsorPaymentContract.connect(sponsor).deposit({ value: ethers.parseEther("1.0") }); + + await sponsorPaymentContract.connect(sponsor).requestWithdrawal(ethers.parseEther("0.5")); + + await ethers.provider.send("evm_increaseTime", [SPONSOR_WITHDRAW_DELAY]); + await ethers.provider.send("evm_mine", []); + + await expect(sponsorPaymentContract.connect(sponsor).withdraw()) + .to.emit(sponsorPaymentContract, "Withdrawal") + .withArgs(sponsorAddr, ethers.parseEther("0.5")); + }); + + it("should handle successful ERC20 withdrawal", async () => { + const [owner, sponsor] = signers; + + await token.connect(owner).transfer(sponsor.address, 100); + await token.connect(sponsor).approve(paymentContractAddr, 100); + await sponsorPaymentContract.connect(sponsor).depositERC20(100, tokenAddr); + + await sponsorPaymentContract.connect(sponsor).requestWithdrawalERC20(50, tokenAddr); + + await ethers.provider.send("evm_increaseTime", [SPONSOR_WITHDRAW_DELAY]); + await ethers.provider.send("evm_mine", []); + + await expect(sponsorPaymentContract.connect(sponsor).withdrawERC20(tokenAddr)) + .to.emit(sponsorPaymentContract, "ERC20Withdrawal") + .withArgs(sponsor.address, tokenAddr, 50); + + expect(await token.balanceOf(sponsor.address)).to.be.eq(50); + }); + + it("should handle withdrawal cancellation", async () => { + const [, sponsor] = signers; + const sponsorAddr = await sponsor.getAddress(); + + await sponsorPaymentContract.connect(sponsor).deposit({ value: ethers.parseEther("1.0") }); + + await sponsorPaymentContract.connect(sponsor).requestWithdrawal(ethers.parseEther("0.5")); + + await expect(sponsorPaymentContract.connect(sponsor).cancelWithdrawal()) + .to.emit(sponsorPaymentContract, "WithdrawalCancelled") + .withArgs(sponsorAddr, ethers.parseEther("0.5")); + }); + + it("should handle ERC20 withdrawal cancellation", async () => { + const [owner, sponsor] = signers; + + await token.connect(owner).transfer(sponsor.address, 100); + await token.connect(sponsor).approve(paymentContractAddr, 100); + await sponsorPaymentContract.connect(sponsor).depositERC20(100, tokenAddr); + + await sponsorPaymentContract.connect(sponsor).requestWithdrawalERC20(50, tokenAddr); + + await expect(sponsorPaymentContract.connect(sponsor).cancelWithdrawalERC20(tokenAddr)) + .to.emit(sponsorPaymentContract, "ERC20WithdrawalCancelled") + .withArgs(sponsor.address, tokenAddr, 50); + + expect(await sponsorPaymentContract.getBalanceERC20(sponsor.address, tokenAddr)).to.be.eq( + 100, + ); + }); + }); + + describe("Claim Payment", () => { + it("should revert on invalid recipient", async () => { + const [, sponsor, recipient] = signers; + + const paymentData = { + recipient: await recipient.getAddress(), + amount: 100, + token: ethers.ZeroAddress, + expiration: Math.round(new Date().getTime() / 1000) + 60 * 60, + nonce: 22, + metadata: "0x", + }; + const signature = await sponsor.signTypedData(domainData, typesERC20, paymentData); + + await expect( + sponsorPaymentContract.connect(sponsor).claimPayment(paymentData, signature), + ).to.be.revertedWithCustomError(sponsorPaymentContract, "InvalidPaymentClaim"); + }); + + it("should revert on expired payment", async () => { + const [, sponsor, recipient] = signers; + + const paymentData = { + recipient: await recipient.getAddress(), + amount: 100, + token: ethers.ZeroAddress, + expiration: Math.round(new Date().getTime() / 1000) - 60 * 60, + nonce: 22, + metadata: "0x", + }; + const signature = await sponsor.signTypedData(domainData, typesERC20, paymentData); + + await expect( + sponsorPaymentContract.connect(recipient).claimPayment(paymentData, signature), + ).to.be.revertedWithCustomError(sponsorPaymentContract, "InvalidPaymentClaim"); + }); + + it("should revert on insufficient balance", async () => { + const [, sponsor, recipient] = signers; + + const paymentData = { + recipient: recipient.address, + amount: 100, + token: ethers.ZeroAddress, + expiration: Math.round(new Date().getTime() / 1000) + 60 * 60, + nonce: 22, + metadata: "0x", + }; + const signature = await sponsor.signTypedData(domainData, typesERC20, paymentData); + + await expect( + sponsorPaymentContract.connect(recipient).claimPayment(paymentData, signature), + ).to.be.revertedWithCustomError(sponsorPaymentContract, "InvalidPaymentClaim"); + }); + + it("should handle successful Ether payment claim", async () => { + const [, sponsor, recipient] = signers; + + await sponsorPaymentContract.connect(sponsor).deposit({ value: ethers.parseEther("1.0") }); + const recipientAmountBeforeClaim = await ethers.provider.getBalance(recipient.address); + + const paymentData = { + recipient: recipient.address, + amount: ethers.parseEther("0.5"), + expiration: Math.round(new Date().getTime() / 1000) + 60 * 60, + nonce: 22, + metadata: "0x", + }; + const signature = await sponsor.signTypedData(domainData, types, paymentData); + + await expect(sponsorPaymentContract.connect(recipient).claimPayment(paymentData, signature)) + .to.emit(sponsorPaymentContract, "PaymentClaimed") + .withArgs(recipient.address, 22, ethers.parseEther("0.5")); + + expect(await ethers.provider.getBalance(recipient.address)).to.be.gt( + recipientAmountBeforeClaim, + ); + }); + + it("should handle successful ERC20 payment claim", async () => { + const [owner, sponsor, recipient] = signers; + + await token.connect(owner).transfer(sponsor.address, 100); + await token.connect(sponsor).approve(paymentContractAddr, 100); + await sponsorPaymentContract.connect(sponsor).depositERC20(100, tokenAddr); + const amount = 50; + const paymentData = { + recipient: recipient.address, + amount, + token: tokenAddr, + expiration: Math.round(new Date().getTime() / 1000) + 60 * 60, + nonce: 22, + metadata: "0x", + }; + const signature = await sponsor.signTypedData(domainData, typesERC20, paymentData); + + await expect( + sponsorPaymentContract.connect(recipient).claimPaymentERC20(paymentData, signature), + ) + .to.emit(sponsorPaymentContract, "ERC20PaymentClaimed") + .withArgs(recipient.address, 22, tokenAddr, 50); + + const recipientPart = amount - (amount * OWNER_FEE_PERCENTS) / 100; + expect(await token.balanceOf(recipient.address)).to.be.eq(recipientPart); + + const ownerPart = amount - recipientPart; + expect( + await sponsorPaymentContract.getBalanceERC20( + await sponsorPaymentContract.getAddress(), + tokenAddr, + ), + ).to.be.eq(ownerPart); + }); + + it("should revert on already claimed payment", async () => { + const [, sponsor, recipient] = signers; + + await sponsorPaymentContract.connect(sponsor).deposit({ value: ethers.parseEther("1.0") }); + + const paymentData = { + recipient: recipient.address, + amount: ethers.parseEther("0.5"), + expiration: Math.round(new Date().getTime() / 1000) + 60 * 60, + nonce: 22, + metadata: "0x", + }; + const signature = await sponsor.signTypedData(domainData, types, paymentData); + + await sponsorPaymentContract.connect(recipient).claimPayment(paymentData, signature); + + expect(await sponsorPaymentContract.isPaymentClaimed(recipient.address, 22)).to.be.eq(true); + + await expect( + sponsorPaymentContract.connect(recipient).claimPayment(paymentData, signature), + ).to.be.revertedWithCustomError(sponsorPaymentContract, "InvalidPaymentClaim"); + }); + }); + + describe("Owner Fee", () => { + it("should allow owner to withdraw Ether balance", async () => { + const [owner, sponsor, recipient] = signers; + const ownerAddr = await owner.getAddress(); + + await sponsorPaymentContract.connect(sponsor).deposit({ value: ethers.parseEther("1.0") }); + + const paymentData = { + recipient: recipient.address, + amount: ethers.parseEther("0.5"), + expiration: Math.round(new Date().getTime() / 1000) + 60 * 60, + nonce: 22, + metadata: "0x", + }; + const signature = await sponsor.signTypedData(domainData, types, paymentData); + + await sponsorPaymentContract.connect(recipient).claimPayment(paymentData, signature); + + const ownerBalanceBefore = await ethers.provider.getBalance(ownerAddr); + + await expect(sponsorPaymentContract.connect(owner).withdrawOwnerBalance(ethers.ZeroAddress)) + .to.emit(sponsorPaymentContract, "OwnerBalanceWithdrawn") + .withArgs(ethers.parseEther("0.05")); + + const ownerBalanceAfter = await ethers.provider.getBalance(ownerAddr); + expect(ownerBalanceAfter).to.be.gt(ownerBalanceBefore); + }); + + it("should allow owner to withdraw ERC20 balance", async () => { + const [owner, sponsor, recipient] = signers; + const ownerAddr = await owner.getAddress(); + + await token.connect(owner).transfer(sponsor.address, 100); + await token.connect(sponsor).approve(paymentContractAddr, 100); + await sponsorPaymentContract.connect(sponsor).depositERC20(100, tokenAddr); + + const paymentData = { + recipient: recipient.address, + amount: 50, + token: tokenAddr, + expiration: Math.round(new Date().getTime() / 1000) + 60 * 60, + nonce: 22, + metadata: "0x", + }; + const signature = await sponsor.signTypedData(domainData, typesERC20, paymentData); + + await sponsorPaymentContract.connect(recipient).claimPaymentERC20(paymentData, signature); + + const ownerBalanceBefore = await token.balanceOf(ownerAddr); + + await expect(sponsorPaymentContract.connect(owner).withdrawOwnerBalance(tokenAddr)) + .to.emit(sponsorPaymentContract, "OwnerBalanceWithdrawn") + .withArgs(5); + + const ownerBalanceAfter = await token.balanceOf(ownerAddr); + expect(ownerBalanceAfter).to.be.eq(ownerBalanceBefore + 5n); + + const sponsorBalanceAfter = await token.balanceOf(await sponsorPaymentContract.getAddress()); + expect(sponsorBalanceAfter).to.be.eq(50n); + expect(await sponsorPaymentContract.getBalanceERC20(sponsor.address, tokenAddr)).to.be.eq( + 50n, + ); + }); + + it("should revert if no balance to withdraw", async () => { + const [owner] = signers; + + await expect( + sponsorPaymentContract.connect(owner).withdrawOwnerBalance(ethers.ZeroAddress), + ).to.be.revertedWithCustomError(sponsorPaymentContract, "InvalidWithdraw"); + + await expect( + sponsorPaymentContract.connect(owner).withdrawOwnerBalance(tokenAddr), + ).to.be.revertedWithCustomError(sponsorPaymentContract, "InvalidWithdraw"); + }); + }); +});