diff --git a/hardhat.config.ts b/hardhat.config.ts index d2a4ff1..20f480b 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -25,6 +25,31 @@ const config: HardhatUserConfig = { chainId: 10, url: `https://mainnet.optimism.io`, }, + ethereum: { + accounts: [process.env.OPTIMISM_PRIVATE_KEY ?? ''], + chainId: 1, + url: `https://ethereum-rpc.publicnode.com`, + }, + arbitrum: { + accounts: [process.env.OPTIMISM_PRIVATE_KEY ?? ''], + chainId: 42161, + url: `https://arb1.arbitrum.io/rpc`, + }, + base: { + accounts: [process.env.OPTIMISM_PRIVATE_KEY ?? ''], + chainId: 8453, + url: `https://mainnet.base.org`, + }, + polygon: { + accounts: [process.env.OPTIMISM_PRIVATE_KEY ?? ''], + chainId: 137, + url: `https://polygon.api.onfinality.io/public`, + }, + amoy: { + accounts: [process.env.OPTIMISM_PRIVATE_KEY ?? ''], + chainId: 80002, + url: `https://rpc-amoy.polygon.technology`, + }, op_sepolia: { url: 'https://sepolia.optimism.io', chainId: 11155420, diff --git a/src/PaymentEscrow.sol b/src/PaymentEscrow.sol index 6068521..7da555c 100644 --- a/src/PaymentEscrow.sol +++ b/src/PaymentEscrow.sol @@ -9,6 +9,7 @@ import "./PaymentInput.sol"; import "./IEscrowContract.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./IPurchaseTracker.sol"; +import "./PaymentEscrowAdmins.sol"; /** * @title PaymentEscrow @@ -19,11 +20,11 @@ import "./IPurchaseTracker.sol"; * @author John R. Kosinski * LoadPipe 2024 */ -contract PaymentEscrow is HasSecurityContext, IEscrowContract +contract PaymentEscrow is PaymentEscrowAdmins, HasSecurityContext, IEscrowContract { - ISystemSettings private settings; - mapping(bytes32 => Payment) private payments; - bool private autoReleaseFlag; + ISystemSettings public settings; + mapping(bytes32 => Payment) public payments; + bool public autoReleaseFlag; bool public paused; IPurchaseTracker public purchaseTracker; @@ -40,7 +41,8 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract event ReleaseAssentGiven ( bytes32 indexed paymentId, address assentingAddress, - uint8 assentType // 1 = payer, 2 = receiver, 3 = arbiter + //TODO: consider making this an enum + uint8 assentType // 1 = payer, 2 = receiver, 3 = arbiter, 4 = admin ); event EscrowReleased ( @@ -184,29 +186,28 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract if (msg.sender != payment.receiver && msg.sender != payment.payer && - !securityContext.hasRole(Roles.ARBITER_ROLE, msg.sender)) + !securityContext.hasRole(Roles.ARBITER_ROLE, msg.sender) && + !this.hasAdminPermission(payment.receiver, msg.sender, PermissionRelease)) { revert("Unauthorized"); } if (payment.amount > 0) { - if (payment.receiver == msg.sender) { - if (!payment.receiverReleased) { - payment.receiverReleased = true; - emit ReleaseAssentGiven(paymentId, msg.sender, 1); - } + if (!payment.receiverReleased && payment.receiver == msg.sender) { + payment.receiverReleased = true; + emit ReleaseAssentGiven(paymentId, msg.sender, 1); } - if (payment.payer == msg.sender) { - if (!payment.payerReleased) { - payment.payerReleased = true; - emit ReleaseAssentGiven(paymentId, msg.sender, 2); - } + if (!payment.receiverReleased && this.hasAdminPermission(payment.receiver, msg.sender, PermissionRelease)) { + payment.receiverReleased = true; + emit ReleaseAssentGiven(paymentId, msg.sender, 4); } - if (securityContext.hasRole(Roles.ARBITER_ROLE, msg.sender)) { - if (!payment.payerReleased) { - payment.payerReleased = true; - emit ReleaseAssentGiven(paymentId, msg.sender, 3); - } + if (!payment.payerReleased && payment.payer == msg.sender) { + payment.payerReleased = true; + emit ReleaseAssentGiven(paymentId, msg.sender, 2); + } + if (!payment.payerReleased && securityContext.hasRole(Roles.ARBITER_ROLE, msg.sender)) { + payment.payerReleased = true; + emit ReleaseAssentGiven(paymentId, msg.sender, 3); } _releaseEscrowPayment(paymentId); @@ -233,10 +234,16 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract function refundPayment(bytes32 paymentId, uint256 amount) external whenNotPaused { Payment storage payment = payments[paymentId]; require(payment.released == false, "Payment already released"); + if (payment.amount > 0 && payment.amountRefunded <= payment.amount) { - if (payment.receiver != msg.sender && !securityContext.hasRole(Roles.ARBITER_ROLE, msg.sender)) + //check permission to refund + if (payment.receiver != msg.sender && + !securityContext.hasRole(Roles.ARBITER_ROLE, msg.sender) && + !this.hasAdminPermission(payment.receiver, msg.sender, PermissionRefund) + ) revert("Unauthorized"); + //check amount to refund uint256 activeAmount = payment.amount - payment.amountRefunded; if (amount > activeAmount) revert("AmountExceeded"); diff --git a/src/PaymentEscrowAdmins.sol b/src/PaymentEscrowAdmins.sol new file mode 100644 index 0000000..a597624 --- /dev/null +++ b/src/PaymentEscrowAdmins.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import "./security/HasSecurityContext.sol"; +import "./security/Roles.sol"; +import "./ISystemSettings.sol"; +import "./CarefulMath.sol"; +import "./PaymentInput.sol"; +import "./IEscrowContract.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "./IPurchaseTracker.sol"; +import "./PaymentEscrowAdmins.sol"; + + +enum PaymentEscrowAdminPermission { + None, + Release, + Refund, + All +} + +/** + * @title PaymentEscrowAdmins + * + * Add-on module for PaymentEscrow; allows PaymentEscrow to appoint admins who can, like the + * store owner, refund and release escrows. + * + * @author John R. Kosinski + * LoadPipe 2024 + */ +contract PaymentEscrowAdmins +{ + uint8 public constant PermissionRefund = 1; // 00000001 + uint8 public constant PermissionRelease = 1 << 1; // 00000010 + + mapping(address => mapping(address => uint8)) public appointedAdmins; + + function grantAdminPermission(address admin, uint8 permission) public { + appointedAdmins[msg.sender][admin] |= permission; + } + + function revokeAdminPermission(address admin, uint8 permission) public { + appointedAdmins[msg.sender][admin] &= ~permission; + } + + function hasAdminPermission(address owner, address admin, uint8 permission) public view returns (bool) { + return appointedAdmins[owner][admin] & permission != 0; + } +} \ No newline at end of file diff --git a/test-foundry/PaymentEscrowTest.t.sol b/test-foundry/PaymentEscrowTest.t.sol index 64d8f42..2a43567 100644 --- a/test-foundry/PaymentEscrowTest.t.sol +++ b/test-foundry/PaymentEscrowTest.t.sol @@ -25,6 +25,9 @@ contract PaymentEscrowTest is Test { EligibilityModule internal eligibilityModule; ToggleModule internal toggleModule; + uint8 internal constant ADMIN_PERMISSION_REFUND = 1; + uint8 internal constant ADMIN_PERMISSION_RELEASE = 2; + address internal admin; address internal nonOwner; address internal payer1; @@ -201,6 +204,7 @@ contract PaymentEscrowTest is Test { assertEq(actual.released, expected.released); } + // balances helper function for testing function _recordBalances() internal view returns (uint256[] memory) { uint256[] memory balances = new uint256[](5); @@ -256,6 +260,7 @@ contract PaymentEscrowTest is Test { assertEq(securityContext.roleToHatId(SYSTEM_ROLE), systemHatId, "System role should map to correct hat"); } + // Place Payments function testCanPlaceSingleNativePayment() public { uint256 initialContractBalance = _getBalance(address(escrow), false); @@ -447,6 +452,7 @@ contract PaymentEscrowTest is Test { vm.stopPrank(); } + // Release Payments function testCannotReleaseWithNoApprovals() public { uint256 initialContractBalance = _getBalance(address(escrow), true); @@ -767,6 +773,7 @@ contract PaymentEscrowTest is Test { assertEq(finalEscrowBalance, 0); } + // Refund Tests function _refundTest( uint256 amount, @@ -1101,6 +1108,7 @@ contract PaymentEscrowTest is Test { assertFalse(payment.released); } + // Fee Amounts function testFeesAreCalculatedCorrectly() public { uint256 feeBps = 200; // 2% @@ -1242,6 +1250,7 @@ contract PaymentEscrowTest is Test { assertEq(_getBalance(receiver1, true), receiverInitialAmount + amount); } + // Edge Cases function testPayerAndReceiverAreSame() public { uint256 initialPayerBalance = _getBalance(payer1, true); @@ -1379,7 +1388,6 @@ contract PaymentEscrowTest is Test { // Invalid Payment tests - function testCannotPlacePaymentWithZeroAmount() public { bytes32 paymentId = keccak256("zero-amount-payment"); @@ -1532,6 +1540,7 @@ contract PaymentEscrowTest is Test { escrow.releaseEscrow(paymentId); } + // Pausability Tests function testContractCanBePausedByAuthorizedAccount() public { assertFalse(escrow.paused()); @@ -1622,6 +1631,7 @@ contract PaymentEscrowTest is Test { escrow.releaseEscrow(paymentId); } + // Auto Release Test function testAutoReleaseFlagTrueBehavior() public { @@ -1701,6 +1711,266 @@ contract PaymentEscrowTest is Test { } + // Admin Permissions Tests + function testCanAppointAdminRefunder() public { + //no permissions + assertEq(escrow.appointedAdmins(receiver1, receiver2), 0); + assertFalse(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_REFUND)); + assertFalse(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_RELEASE)); + + //grant permissions + vm.prank(receiver1); + escrow.grantAdminPermission(receiver2, ADMIN_PERMISSION_REFUND); + + assertEq(escrow.appointedAdmins(receiver1, receiver2), ADMIN_PERMISSION_REFUND); + assertTrue(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_REFUND)); + assertFalse(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_RELEASE)); + } + + function testCanAppointAdminReleaser() public { + //no permissions + assertEq(escrow.appointedAdmins(receiver1, receiver2), 0); + assertFalse(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_REFUND)); + assertFalse(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_RELEASE)); + + //grant permissions + vm.prank(receiver1); + escrow.grantAdminPermission(receiver2, 2); + + assertEq(escrow.appointedAdmins(receiver1, receiver2), ADMIN_PERMISSION_RELEASE); + assertTrue(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_RELEASE)); + assertFalse(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_REFUND)); + } + + function testCanAppointAdminReleaserAndRefunder() public { + //no permissions + assertEq(escrow.appointedAdmins(receiver1, receiver2), 0); + assertFalse(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_REFUND)); + assertFalse(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_RELEASE)); + + //grant permissions + vm.prank(receiver1); + escrow.grantAdminPermission(receiver2, uint8(1) | uint8(2)); + + assertEq(escrow.appointedAdmins(receiver1, receiver2), ADMIN_PERMISSION_REFUND | ADMIN_PERMISSION_RELEASE); + assertTrue(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_REFUND)); + assertTrue(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_RELEASE)); + } + + function testCanRevokeReleasePermission() public { + uint256 initialContractBalance = _getBalance(address(escrow), true); + uint256 initialReceiverBalance = _getBalance(receiver1, true); + uint256 amount = 10_000_000; + address releaser = receiver2; + + bytes32 paymentId = keccak256("0x01"); + _placePayment(paymentId, payer1, receiver1, amount, true); + + //first grant all permissions + vm.prank(receiver1); + escrow.grantAdminPermission(releaser, 1 | 2); + + //test that releaser now CAN release escrow + vm.prank(releaser); + escrow.releaseEscrow(paymentId); + + //a new payment + paymentId = keccak256("0x02"); + _placePayment(paymentId, payer1, receiver1, amount, true); + + //revoke release permission + vm.prank(receiver1); + escrow.revokeAdminPermission(releaser, 2); + + assertTrue(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_REFUND)); + assertFalse(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_RELEASE)); + + //this should revert because release permission has been revoked + vm.startPrank(releaser); + vm.expectRevert("Unauthorized"); + escrow.releaseEscrow(paymentId); + vm.stopPrank(); + } + + function testCanRevokeRefundPermission() public { + uint256 amount = 1_000_000; + uint256 refundAmount = 100; + address refunder = receiver2; + + uint256 initialContractBalance = _getBalance(address(escrow), true); + uint256 initialPayerBalance = _getBalance(payer1, true); + + bytes32 paymentId = keccak256("0x01"); + _placePayment(paymentId, payer1, receiver1, amount, true); + + //first grant all permissions + vm.prank(receiver1); + escrow.grantAdminPermission(refunder, ADMIN_PERMISSION_REFUND | ADMIN_PERMISSION_RELEASE); + + //let the refunder try to refund with permission + vm.prank(refunder); + escrow.refundPayment(paymentId, refundAmount); + + //now revoke release permission + escrow.revokeAdminPermission(refunder, ADMIN_PERMISSION_RELEASE); + + //should still be allowed to refund + vm.prank(refunder); + escrow.refundPayment(paymentId, refundAmount); + + //now revoke refund permission + vm.startPrank(receiver1); + escrow.grantAdminPermission(refunder, ADMIN_PERMISSION_RELEASE); + escrow.revokeAdminPermission(refunder, ADMIN_PERMISSION_REFUND); + vm.stopPrank(); + + assertFalse(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_REFUND)); + assertTrue(escrow.hasAdminPermission(receiver1, receiver2, ADMIN_PERMISSION_RELEASE)); + + //let the refunder try to refund with the revoked permission + vm.startPrank(refunder); + vm.expectRevert("Unauthorized"); + escrow.refundPayment(paymentId, refundAmount); + vm.stopPrank(); + } + + function testAppointedAdminCanRelease() public { + uint256 initialContractBalance = _getBalance(address(escrow), true); + uint256 initialReceiverBalance = _getBalance(receiver1, true); + uint256 amount = 10_000_000; + address releaser = receiver2; + + bytes32 paymentId = keccak256("0x01"); + _placePayment(paymentId, payer1, receiver1, amount, true); + + uint256 newContractBalance = _getBalance(address(escrow), true); + uint256 newReceiverBalance = _getBalance(receiver1, true); + assertEq(newContractBalance, initialContractBalance + amount); + assertEq(newReceiverBalance, initialReceiverBalance); + + //appoint admin to release + vm.prank(receiver1); + escrow.grantAdminPermission(releaser, uint8(2)); + assertTrue(escrow.hasAdminPermission(receiver1, releaser, uint8(2))); + + //let buyer & releaser release + vm.prank(payer1); + escrow.releaseEscrow(paymentId); + vm.prank(releaser); + escrow.releaseEscrow(paymentId); + + Payment memory payment = _getPayment(paymentId); + _verifyPayment(payment, Payment({ + id: paymentId, + payer: payer1, + receiver: receiver1, + amount: amount, + amountRefunded: 0, + payerReleased: true, + receiverReleased: true, + released: true, + currency: address(testToken) + })); + + uint256 finalContractBalance = _getBalance(address(escrow), true); + uint256 finalReceiverBalance = _getBalance(receiver1, true); + assertEq(finalContractBalance, newContractBalance - amount); + assertEq(finalReceiverBalance, newReceiverBalance + amount); + } + + function testAppointedAdminCanRefund() public { + uint256 amount = 1_000_000; + uint256 refundAmount = 100; + address refunder = receiver2; + + uint256 initialContractBalance = _getBalance(address(escrow), true); + uint256 initialPayerBalance = _getBalance(payer1, true); + + bytes32 paymentId = keccak256("0x01"); + _placePayment(paymentId, payer1, receiver2, amount, true); + + //appoint an admin + vm.prank(receiver1); + escrow.grantAdminPermission(receiver2, ADMIN_PERMISSION_REFUND); + + //let the refunder try to refund + vm.prank(refunder); + escrow.refundPayment(paymentId, refundAmount); + + //verify the refund + Payment memory payment = _getPayment(paymentId); + assertEq(payment.amountRefunded, refundAmount); + assertEq(payment.amount, amount); + + uint256 finalContractBalance = _getBalance(address(escrow), true); + uint256 finalPayerBalance = _getBalance(payer1, true); + + assertEq(finalContractBalance, initialContractBalance + (amount - refundAmount)); + assertEq(finalPayerBalance, initialPayerBalance - (amount - refundAmount)); + } + + function testWrongAppointedAdminCannotRelease() public { + uint256 initialContractBalance = _getBalance(address(escrow), true); + uint256 initialReceiverBalance = _getBalance(receiver1, true); + uint256 amount = 10_000_000; + address releaser = receiver2; + + bytes32 paymentId = keccak256("0x01"); + _placePayment(paymentId, payer1, receiver1, amount, true); + + uint256 newContractBalance = _getBalance(address(escrow), true); + uint256 newReceiverBalance = _getBalance(receiver1, true); + assertEq(newContractBalance, initialContractBalance + amount); + assertEq(newReceiverBalance, initialReceiverBalance); + + //let buyer release + vm.prank(payer1); + escrow.releaseEscrow(paymentId); + + //this should revert, non-appointed admin + vm.startPrank(releaser); + vm.expectRevert("Unauthorized"); + escrow.releaseEscrow(paymentId); + vm.stopPrank(); + + //appoint admin to release + vm.prank(receiver1); + escrow.grantAdminPermission(releaser, ADMIN_PERMISSION_REFUND); + + //this should revert, wrong-appointed admin + vm.startPrank(releaser); + vm.expectRevert("Unauthorized"); + escrow.releaseEscrow(paymentId); + vm.stopPrank(); + } + + function testWrongAppointedAdminCannotRefund() public { + uint256 amount = 1_000_000; + uint256 refundAmount = 100; + address refunder = receiver2; + + uint256 initialContractBalance = _getBalance(address(escrow), true); + uint256 initialPayerBalance = _getBalance(payer1, true); + + bytes32 paymentId = keccak256("0x01"); + _placePayment(paymentId, payer1, receiver1, amount, true); + + //let the refunder try to refund without permission + vm.startPrank(refunder); + vm.expectRevert("Unauthorized"); + escrow.refundPayment(paymentId, refundAmount); + vm.stopPrank(); + + //let the refunder try to refund with the wrong permission + vm.prank(receiver1); + escrow.grantAdminPermission(receiver2, ADMIN_PERMISSION_RELEASE); + + vm.startPrank(refunder); + vm.expectRevert("Unauthorized"); + escrow.refundPayment(paymentId, refundAmount); + vm.stopPrank(); + } + // Event Tests