From f188932e799b49d351615648748eb292d96da145 Mon Sep 17 00:00:00 2001 From: Kolezhniuk Date: Sat, 21 Dec 2024 22:38:18 +0100 Subject: [PATCH 1/9] Sponsor payment contract --- contracts/payment/SponsorPayment.sol | 515 +++++++++++++++++++++++++++ test/payment/sponsor-payment.test.ts | 457 ++++++++++++++++++++++++ 2 files changed, 972 insertions(+) create mode 100644 contracts/payment/SponsorPayment.sol create mode 100644 test/payment/sponsor-payment.test.ts diff --git a/contracts/payment/SponsorPayment.sol b/contracts/payment/SponsorPayment.sol new file mode 100644 index 00000000..edf94357 --- /dev/null +++ b/contracts/payment/SponsorPayment.sol @@ -0,0 +1,515 @@ +// 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; + bool exists; + } + + /** + * @dev Main storage $tructure for the contract + */ + struct SponsorPaymentStorage { + mapping(address => mapping(address => uint256)) balances; // sponsor => token => balance + mapping(address => mapping(address => WithdrawalRequest)) withdrawalRequests; // sponsor => token => request + mapping(bytes32 => bool) isWithdrawn; + uint8 ownerPercentage; + uint256 withdrawalDelay; + } + + string public constant VERSION = "1.0.0"; + + bytes32 public constant ERC20_PAYMENT_CLAIM_DATA_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 PAYMENT_CLAIM_DATA_TYPE_HASH = + keccak256( + // solhint-disable-next-line max-line-length + "ERC20SponsorPaymentInfo(address recipient,uint256 amount,uint256 expiration,uint256 nonce,bytes metadata)" + ); + + bytes32 private constant SponsorPaymentStorageLocation = + 0x843c93f996398391e581389b674681e6ea27a4f9a96390a9d8ecb41cf0226300; + + modifier validToken(address token) { + if (token != address(0)) { + if (!_isContract(token)) revert InvalidToken("Not a contract address"); + SponsorPaymentStorage storage s = _getSponsorPaymentStorage(); + } + _; + } + + /** + * @dev Valid percent value modifier + */ + modifier validPercentValue(uint256 percent) { + if (percent > 100) { + revert InvalidParameter("Invalid owner percentage"); + } + _; + } + + function initialize( + address owner, + uint8 ownerPercentage, + uint256 withdrawalDelay + ) external initializer validPercentValue(ownerPercentage) { + if (withdrawalDelay == 0) revert InvalidParameter("Invalid withdrawal delay"); + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + $.ownerPercentage = ownerPercentage; + $.withdrawalDelay = withdrawalDelay; + __ReentrancyGuard_init(); + __EIP712_init("SponsorPayment", VERSION); + __Ownable_init(owner); + } + + function _getSponsorPaymentStorage() private pure returns (SponsorPaymentStorage storage $) { + assembly { + $.slot := SponsorPaymentStorageLocation + } + } + + /** + * @notice Checks if an address contains contract code + * @param addr Address to check + */ + function _isContract(address addr) private view returns (bool) { + uint256 size; + assembly { + size := extcodesize(addr) + } + return size > 0; + } + + /** + * @dev Get the owner percentage value + * @return ownerPercentage + */ + function getOwnerPercentage() external view returns (uint8) { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + return $.ownerPercentage; + } + + /** + * @dev Updates owner percentage value + * @param ownerPercentage Amount between 0 and 100 representing the owner percentage + */ + function updateOwnerPercentage( + uint8 ownerPercentage + ) external onlyOwner validPercentValue(ownerPercentage) { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + $.ownerPercentage = ownerPercentage; + } + + 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 recoverSponsorPaymentSigner( + SponsorPaymentInfo memory payment, + bytes memory signature + ) public view returns (address) { + bytes32 digest = _hashTypedDataV4( + keccak256( + abi.encode( + PAYMENT_CLAIM_DATA_TYPE_HASH, + payment.recipient, + payment.amount, + payment.expiration, + payment.nonce, + keccak256(payment.metadata) + ) + ) + ); + + return _tryRecoverSigner(digest, signature); + } + + function recoverSponsorPaymentSignerERC20( + ERC20SponsorPaymentInfo memory payment, + bytes memory signature + ) public view returns (address) { + bytes32 digest = _hashTypedDataV4( + keccak256( + abi.encode( + ERC20_PAYMENT_CLAIM_DATA_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); + } + + function _requestWithdrawal(address balanceAddress, uint256 amount) private returns (uint256) { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + + uint256 balance = $.balances[_msgSender()][balanceAddress]; + if (balance < amount) revert InvalidWithdraw("Insufficient balance"); + + WithdrawalRequest storage request = $.withdrawalRequests[_msgSender()][balanceAddress]; + if (request.exists) revert InvalidWithdraw("Existing withdrawal pending"); + + uint256 lockTime = block.timestamp + $.withdrawalDelay; + // Create withdrawal request + request.amount = amount; + request.lockTime = lockTime; + request.exists = true; + $.balances[_msgSender()][balanceAddress] -= amount; + + return lockTime; + } + + /** + * @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); + } + + function _cancelWithdrawal(address token) private returns (uint256) { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + WithdrawalRequest storage request = $.withdrawalRequests[_msgSender()][token]; + + if (!request.exists) revert InvalidWithdraw("No withdrawal request exists"); + + uint256 amount = request.amount; + delete $.withdrawalRequests[_msgSender()][token]; + + // Return the funds + $.balances[_msgSender()][token] += amount; + + return amount; + } + + /** + * @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); + } + + function _getWithdrawalAmount(address token) private view returns (uint256) { + SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); + WithdrawalRequest memory request = $.withdrawalRequests[_msgSender()][token]; + + if (!request.exists) revert InvalidWithdraw("No withdrawal request exists"); + if (block.timestamp < request.lockTime) + revert InvalidWithdraw("Withdrawal is still locked"); + + return request.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); + } + + 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 * $.ownerPercentage) / 100; + uint256 recipientPart = amount - ownerPart; + $.isWithdrawn[requestId] = true; + $.balances[sponsor][token] -= amount; + $.balances[address(this)][token] += ownerPart; + + return recipientPart; + } + + /** + * @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); + } + + 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 validToken(tokenAddress) { + 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 { + 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]; + } +} diff --git a/test/payment/sponsor-payment.test.ts b/test/payment/sponsor-payment.test.ts new file mode 100644 index 00000000..bc4d1233 --- /dev/null +++ b/test/payment/sponsor-payment.test.ts @@ -0,0 +1,457 @@ +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); + console.log("sponsor", sponsor.address); + 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); + + const balance = await sponsorPaymentContract.getBalanceERC20(sponsor.address, tokenAddr); + console.log("balance", balance.toString()); + + 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"); + }); + }); +}); From fd5d37e5164198fd6d57b8efc2a32c83ced9a04a Mon Sep 17 00:00:00 2001 From: Kolezhniuk Date: Mon, 23 Dec 2024 08:40:35 +0100 Subject: [PATCH 2/9] Add deploy script and fixes --- contracts/payment/SponsorPayment.sol | 10 +++++++--- test/payment/sponsor-payment.test.ts | 4 ---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/contracts/payment/SponsorPayment.sol b/contracts/payment/SponsorPayment.sol index edf94357..22a2dabb 100644 --- a/contracts/payment/SponsorPayment.sol +++ b/contracts/payment/SponsorPayment.sol @@ -125,8 +125,10 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl "ERC20SponsorPaymentInfo(address recipient,uint256 amount,uint256 expiration,uint256 nonce,bytes metadata)" ); - bytes32 private constant SponsorPaymentStorageLocation = - 0x843c93f996398391e581389b674681e6ea27a4f9a96390a9d8ecb41cf0226300; + // 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)) { @@ -161,8 +163,9 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl } function _getSponsorPaymentStorage() private pure returns (SponsorPaymentStorage storage $) { + // solhint-disable-next-line no-inline-assembly assembly { - $.slot := SponsorPaymentStorageLocation + $.slot := SPONSOR_PAYMENT_STORAGE_LOCATION } } @@ -172,6 +175,7 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl */ function _isContract(address addr) private view returns (bool) { uint256 size; + // solhint-disable-next-line no-inline-assembly assembly { size := extcodesize(addr) } diff --git a/test/payment/sponsor-payment.test.ts b/test/payment/sponsor-payment.test.ts index bc4d1233..596ad391 100644 --- a/test/payment/sponsor-payment.test.ts +++ b/test/payment/sponsor-payment.test.ts @@ -321,7 +321,6 @@ describe("Sponsor Payment Contract", () => { await token.connect(owner).transfer(sponsor.address, 100); await token.connect(sponsor).approve(paymentContractAddr, 100); await sponsorPaymentContract.connect(sponsor).depositERC20(100, tokenAddr); - console.log("sponsor", sponsor.address); const amount = 50; const paymentData = { recipient: recipient.address, @@ -333,9 +332,6 @@ describe("Sponsor Payment Contract", () => { }; const signature = await sponsor.signTypedData(domainData, typesERC20, paymentData); - const balance = await sponsorPaymentContract.getBalanceERC20(sponsor.address, tokenAddr); - console.log("balance", balance.toString()); - await expect( sponsorPaymentContract.connect(recipient).claimPaymentERC20(paymentData, signature), ) From 62f73907c2330c4c7e5e218e221905257cc3137a Mon Sep 17 00:00:00 2001 From: Kolezhniuk Date: Mon, 23 Dec 2024 08:40:47 +0100 Subject: [PATCH 3/9] Add deploy script and fixes --- helpers/DeployHelper.ts | 53 ++++++++++++++++++++++++++ helpers/constants.ts | 10 +++++ ignition/modules/sponsorPayment.ts | 22 +++++++++++ scripts/deploy/deploySponsorPayment.ts | 29 ++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 ignition/modules/sponsorPayment.ts create mode 100644 scripts/deploy/deploySponsorPayment.ts 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); + }); From a1dfa8ea1ae22c5a1165ca1f8f1372b7119e5d24 Mon Sep 17 00:00:00 2001 From: Kolezhniuk Date: Mon, 23 Dec 2024 11:57:35 +0100 Subject: [PATCH 4/9] add comment --- contracts/payment/SponsorPayment.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/payment/SponsorPayment.sol b/contracts/payment/SponsorPayment.sol index 22a2dabb..68966c00 100644 --- a/contracts/payment/SponsorPayment.sol +++ b/contracts/payment/SponsorPayment.sol @@ -106,7 +106,7 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl struct SponsorPaymentStorage { mapping(address => mapping(address => uint256)) balances; // sponsor => token => balance mapping(address => mapping(address => WithdrawalRequest)) withdrawalRequests; // sponsor => token => request - mapping(bytes32 => bool) isWithdrawn; + mapping(bytes32 => bool) isWithdrawn; // requestId => isWithdrawn uint8 ownerPercentage; uint256 withdrawalDelay; } From 7ef6f30d23e915e5c4c0a20e4a2439bd52837a36 Mon Sep 17 00:00:00 2001 From: Kolezhniuk Date: Mon, 23 Dec 2024 12:03:27 +0100 Subject: [PATCH 5/9] fix validToken modifier --- contracts/payment/SponsorPayment.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/payment/SponsorPayment.sol b/contracts/payment/SponsorPayment.sol index 68966c00..63e8cbf1 100644 --- a/contracts/payment/SponsorPayment.sol +++ b/contracts/payment/SponsorPayment.sol @@ -133,7 +133,6 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl modifier validToken(address token) { if (token != address(0)) { if (!_isContract(token)) revert InvalidToken("Not a contract address"); - SponsorPaymentStorage storage s = _getSponsorPaymentStorage(); } _; } From 7345f44edc3ef8022f00542080bfd39f78847b55 Mon Sep 17 00:00:00 2001 From: Kolezhniuk Date: Mon, 23 Dec 2024 12:09:17 +0100 Subject: [PATCH 6/9] update storage variable --- contracts/payment/SponsorPayment.sol | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/contracts/payment/SponsorPayment.sol b/contracts/payment/SponsorPayment.sol index 63e8cbf1..09b51ee0 100644 --- a/contracts/payment/SponsorPayment.sol +++ b/contracts/payment/SponsorPayment.sol @@ -107,7 +107,7 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl mapping(address => mapping(address => uint256)) balances; // sponsor => token => balance mapping(address => mapping(address => WithdrawalRequest)) withdrawalRequests; // sponsor => token => request mapping(bytes32 => bool) isWithdrawn; // requestId => isWithdrawn - uint8 ownerPercentage; + uint8 ownerPercentFee; uint256 withdrawalDelay; } @@ -149,12 +149,12 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl function initialize( address owner, - uint8 ownerPercentage, + uint8 ownerPercentFee, uint256 withdrawalDelay - ) external initializer validPercentValue(ownerPercentage) { + ) external initializer validPercentValue(ownerPercentFee) { if (withdrawalDelay == 0) revert InvalidParameter("Invalid withdrawal delay"); SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); - $.ownerPercentage = ownerPercentage; + $.ownerPercentFee = ownerPercentFee; $.withdrawalDelay = withdrawalDelay; __ReentrancyGuard_init(); __EIP712_init("SponsorPayment", VERSION); @@ -183,22 +183,22 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl /** * @dev Get the owner percentage value - * @return ownerPercentage + * @return ownerPercentFee */ - function getOwnerPercentage() external view returns (uint8) { + function getOwnerPercentFee() external view returns (uint8) { SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); - return $.ownerPercentage; + return $.ownerPercentFee; } /** * @dev Updates owner percentage value - * @param ownerPercentage Amount between 0 and 100 representing the owner percentage + * @param ownerPercentFee Amount between 0 and 100 representing the owner percentage */ - function updateOwnerPercentage( - uint8 ownerPercentage - ) external onlyOwner validPercentValue(ownerPercentage) { + function updateOwnerPercentFee( + uint8 ownerPercentFee + ) external onlyOwner validPercentValue(ownerPercentFee) { SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); - $.ownerPercentage = ownerPercentage; + $.ownerPercentFee = ownerPercentFee; } function _tryRecoverSigner( @@ -406,7 +406,7 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl if (sponsorBalance == 0) revert InvalidPaymentClaim("Invalid sponsor"); if (sponsorBalance < amount) revert InvalidPaymentClaim("Insufficient balance"); - uint256 ownerPart = (amount * $.ownerPercentage) / 100; + uint256 ownerPart = (amount * $.ownerPercentFee) / 100; uint256 recipientPart = amount - ownerPart; $.isWithdrawn[requestId] = true; $.balances[sponsor][token] -= amount; From 11daab8a300e896faffbf15419369c1af16cb178 Mon Sep 17 00:00:00 2001 From: Kolezhniuk Date: Mon, 23 Dec 2024 12:15:14 +0100 Subject: [PATCH 7/9] Replace contract check verification --- contracts/payment/SponsorPayment.sol | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/contracts/payment/SponsorPayment.sol b/contracts/payment/SponsorPayment.sol index 09b51ee0..4452120f 100644 --- a/contracts/payment/SponsorPayment.sol +++ b/contracts/payment/SponsorPayment.sol @@ -132,7 +132,7 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl modifier validToken(address token) { if (token != address(0)) { - if (!_isContract(token)) revert InvalidToken("Not a contract address"); + if (token.code.length == 0) revert InvalidToken("Not a contract address"); } _; } @@ -168,19 +168,6 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl } } - /** - * @notice Checks if an address contains contract code - * @param addr Address to check - */ - function _isContract(address addr) private view returns (bool) { - uint256 size; - // solhint-disable-next-line no-inline-assembly - assembly { - size := extcodesize(addr) - } - return size > 0; - } - /** * @dev Get the owner percentage value * @return ownerPercentFee From 1bdb931385378465cac30072c3638ad8172bb6d7 Mon Sep 17 00:00:00 2001 From: Kolezhniuk Date: Tue, 24 Dec 2024 09:42:22 +0100 Subject: [PATCH 8/9] Change modifier and rename const --- contracts/payment/SponsorPayment.sol | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/contracts/payment/SponsorPayment.sol b/contracts/payment/SponsorPayment.sol index 4452120f..27827ffb 100644 --- a/contracts/payment/SponsorPayment.sol +++ b/contracts/payment/SponsorPayment.sol @@ -113,7 +113,7 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl string public constant VERSION = "1.0.0"; - bytes32 public constant ERC20_PAYMENT_CLAIM_DATA_TYPE_HASH = + 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)" @@ -131,8 +131,8 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl 0x98fc76e32452055302f77aa95cd08aa0cf22c02a3ebdaee3e1411f6c47c2ef00; modifier validToken(address token) { - if (token != address(0)) { - if (token.code.length == 0) revert InvalidToken("Not a contract address"); + if (token == address(0) || token.code.length == 0) { + revert InvalidToken("Not a contract address"); } _; } @@ -225,7 +225,7 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl bytes32 digest = _hashTypedDataV4( keccak256( abi.encode( - ERC20_PAYMENT_CLAIM_DATA_TYPE_HASH, + ERC_20_SPONSOR_PAYMENT_INFO_TYPE_HASH, payment.recipient, payment.amount, payment.token, @@ -449,9 +449,7 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl * @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 validToken(tokenAddress) { + 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"); @@ -461,6 +459,7 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl (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); From 3940d4de8667bc097de05cd3e12f2417bde2ac93 Mon Sep 17 00:00:00 2001 From: Kolezhniuk Date: Thu, 2 Jan 2025 15:45:25 +0100 Subject: [PATCH 9/9] Fix comments --- contracts/payment/SponsorPayment.sol | 205 +++++++++++++++------------ 1 file changed, 112 insertions(+), 93 deletions(-) diff --git a/contracts/payment/SponsorPayment.sol b/contracts/payment/SponsorPayment.sol index 27827ffb..26411f76 100644 --- a/contracts/payment/SponsorPayment.sol +++ b/contracts/payment/SponsorPayment.sol @@ -97,16 +97,15 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl struct WithdrawalRequest { uint256 amount; uint256 lockTime; - bool exists; } /** - * @dev Main storage $tructure for the contract + * @dev Main storage structure for the contract */ struct SponsorPaymentStorage { - mapping(address => mapping(address => uint256)) balances; // sponsor => token => balance - mapping(address => mapping(address => WithdrawalRequest)) withdrawalRequests; // sponsor => token => request - mapping(bytes32 => bool) isWithdrawn; // requestId => isWithdrawn + 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; } @@ -119,7 +118,7 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl "ERC20SponsorPaymentInfo(address recipient,uint256 amount,address token,uint256 expiration,uint256 nonce,bytes metadata)" ); - bytes32 public constant PAYMENT_CLAIM_DATA_TYPE_HASH = + 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)" @@ -188,16 +187,12 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl $.ownerPercentFee = ownerPercentFee; } - 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; - } - + /** + * @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 @@ -205,7 +200,7 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl bytes32 digest = _hashTypedDataV4( keccak256( abi.encode( - PAYMENT_CLAIM_DATA_TYPE_HASH, + SPONSOR_PAYMENT_INFO_TYPE_HASH, payment.recipient, payment.amount, payment.expiration, @@ -218,6 +213,12 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl 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 @@ -266,25 +267,6 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl emit ERC20Deposit(_msgSender(), token, amount); } - function _requestWithdrawal(address balanceAddress, uint256 amount) private returns (uint256) { - SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); - - uint256 balance = $.balances[_msgSender()][balanceAddress]; - if (balance < amount) revert InvalidWithdraw("Insufficient balance"); - - WithdrawalRequest storage request = $.withdrawalRequests[_msgSender()][balanceAddress]; - if (request.exists) revert InvalidWithdraw("Existing withdrawal pending"); - - uint256 lockTime = block.timestamp + $.withdrawalDelay; - // Create withdrawal request - request.amount = amount; - request.lockTime = lockTime; - request.exists = true; - $.balances[_msgSender()][balanceAddress] -= amount; - - return lockTime; - } - /** * @notice Request a withdrawal with delay * @param amount The amount to withdraw @@ -304,21 +286,6 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl emit ERC20WithdrawalRequested(_msgSender(), token, amount, lockTime); } - function _cancelWithdrawal(address token) private returns (uint256) { - SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); - WithdrawalRequest storage request = $.withdrawalRequests[_msgSender()][token]; - - if (!request.exists) revert InvalidWithdraw("No withdrawal request exists"); - - uint256 amount = request.amount; - delete $.withdrawalRequests[_msgSender()][token]; - - // Return the funds - $.balances[_msgSender()][token] += amount; - - return amount; - } - /** * @notice Cancel a pending withdrawal request */ @@ -336,17 +303,6 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl emit ERC20WithdrawalCancelled(_msgSender(), token, amount); } - function _getWithdrawalAmount(address token) private view returns (uint256) { - SponsorPaymentStorage storage $ = _getSponsorPaymentStorage(); - WithdrawalRequest memory request = $.withdrawalRequests[_msgSender()][token]; - - if (!request.exists) revert InvalidWithdraw("No withdrawal request exists"); - if (block.timestamp < request.lockTime) - revert InvalidWithdraw("Withdrawal is still locked"); - - return request.amount; - } - /** * @notice Execute withdrawal after delay period */ @@ -371,37 +327,6 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl emit ERC20Withdrawal(_msgSender(), token, amount); } - 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; - } - /** * @notice Allows a recipient to claim a payment with a valid signature * @param payment SponsorPayment entInfo struct containing payment details @@ -428,6 +353,11 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl 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 @@ -501,4 +431,93 @@ contract SponsorPayment is ReentrancyGuardUpgradeable, EIP712Upgradeable, Ownabl 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; + } }