diff --git a/.gitmodules b/.gitmodules index 5440474..754dc29 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "lib/safe-contracts"] path = lib/safe-contracts url = https://github.com/gnosis/safe-contracts +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/echidna/EscrowMulticall/test-EscrowMulticall.sol b/echidna/EscrowMulticall/test-EscrowMulticall.sol index 0e8469f..d60831b 100644 --- a/echidna/EscrowMulticall/test-EscrowMulticall.sol +++ b/echidna/EscrowMulticall/test-EscrowMulticall.sol @@ -6,6 +6,7 @@ import "../../src/EscrowMulticall.sol"; import "../../src/HatsSecurityContext.sol"; import "../../src/SystemSettings.sol"; import "hevm/Hevm.sol"; +import "../../src/IPurchaseTracker.sol"; /** * @title test_EscrowMulticallInvariants @@ -64,7 +65,7 @@ contract test_EscrowMulticallInvariants { // Deploy multiple PaymentEscrow contracts for (uint256 i = 0; i < 3; i++) { - PaymentEscrow e = new PaymentEscrow(securityContext, systemSettings, false); + PaymentEscrow e = new PaymentEscrow(securityContext, systemSettings, false, IPurchaseTracker(address(0))); escrows.push(e); } diff --git a/echidna/PaymentEscrow/test-PaymentEscrow.sol b/echidna/PaymentEscrow/test-PaymentEscrow.sol index 995c966..7b6afd6 100644 --- a/echidna/PaymentEscrow/test-PaymentEscrow.sol +++ b/echidna/PaymentEscrow/test-PaymentEscrow.sol @@ -9,6 +9,7 @@ import "../../src/IHatsSecurityContext.sol"; import "../../src/ISystemSettings.sol"; import "../../src/PaymentInput.sol"; import "hevm/Hevm.sol"; +import "../../src/IPurchaseTracker.sol"; /** * @title test_PaymentEscrow @@ -65,7 +66,8 @@ contract test_PaymentEscrow { escrow = new PaymentEscrow( IHatsSecurityContext(address(securityContext)), ISystemSettings(address(systemSettings)), - false + false, + IPurchaseTracker(address(0)) ); } diff --git a/echidna/Roles/test-Roles.sol b/echidna/Roles/test-Roles.sol index 4ac62c9..4d1bba6 100644 --- a/echidna/Roles/test-Roles.sol +++ b/echidna/Roles/test-Roles.sol @@ -11,6 +11,7 @@ import "../../src/PaymentInput.sol"; import "../../src/hats/EligibilityModule.sol"; import "../../src/hats/ToggleModule.sol"; import "hevm/Hevm.sol"; +import "../../src/IPurchaseTracker.sol"; // Roles bytes32 constant DAO_ROLE = keccak256("DAO_ROLE"); bytes32 constant SYSTEM_ROLE = keccak256("SYSTEM_ROLE"); @@ -158,7 +159,8 @@ contract test_RoleInvariants { escrow = new PaymentEscrow( IHatsSecurityContext(address(hatsContext)), ISystemSettings(address(systemSettings)), - false // autoReleaseFlag + false, // autoReleaseFlag + IPurchaseTracker(address(0)) ); } diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..3b20d60 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 3b20d60d14b343ee4f908cb8079495c07f5e8981 diff --git a/remappings.txt b/remappings.txt index ff8f8a3..9e727b1 100644 --- a/remappings.txt +++ b/remappings.txt @@ -3,4 +3,5 @@ lib/ERC1155/=lib/hats-protocol/lib/ERC1155/ @hats-protocol/=lib/hats-protocol/src hevm/=lib/properties/contracts/util/ @gnosis.pm/safe-contracts/=lib/safe-contracts/ -@gnosis.pm/zodiac/=lib/zodiac/ \ No newline at end of file +@gnosis.pm/zodiac/=lib/zodiac/ +forge-std/=lib/forge-std/src/ diff --git a/src/IPurchaseTracker.sol b/src/IPurchaseTracker.sol new file mode 100644 index 0000000..5de77af --- /dev/null +++ b/src/IPurchaseTracker.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +interface IPurchaseTracker { + function recordPurchase(bytes32 paymentId, address seller, address buyer, uint256 amount) external; +} diff --git a/src/PaymentEscrow.sol b/src/PaymentEscrow.sol index dfadc66..8cd8985 100644 --- a/src/PaymentEscrow.sol +++ b/src/PaymentEscrow.sol @@ -1,13 +1,14 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.20; -import "./HasSecurityContext.sol"; -import "./ISystemSettings.sol"; +import "./HasSecurityContext.sol"; +import "./ISystemSettings.sol"; import "./CarefulMath.sol"; import "./PaymentInput.sol"; import "./IEscrowContract.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./Roles.sol"; +import "./IPurchaseTracker.sol"; /** * @title PaymentEscrow @@ -17,7 +18,6 @@ import "./Roles.sol"; * * @author John R. Kosinski * LoadPipe 2024 - * All rights reserved. Unauthorized use prohibited. */ contract PaymentEscrow is HasSecurityContext, IEscrowContract { @@ -26,8 +26,9 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract bool private autoReleaseFlag; bool public paused; - //EVENTS + IPurchaseTracker public purchaseTracker; + // EVENTS event PaymentReceived ( bytes32 indexed paymentId, address indexed to, @@ -39,7 +40,6 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract event ReleaseAssentGiven ( bytes32 indexed paymentId, address assentingAddress, - //TODO: make enum uint8 assentType // 1 = payer, 2 = receiver, 3 = arbiter ); @@ -77,21 +77,22 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract } /** - * Constructor. - * - * Emits: - * - {HasSecurityContext-SecurityContextSet} - * - * Reverts: - * - {ZeroAddressArgument} if the securityContext address is 0x0. - * - * @param securityContext Contract which will define & manage secure access for this contract. - * @param settings_ Address of contract that holds system settings. + * @notice Constructor. + * @param securityContext Contract which will define & manage secure access for this contract. + * @param settings_ Address of contract that holds system settings. + * @param autoRelease Determines whether new payments automatically have the receiver’s assent. + * @param _purchaseTracker Address of the PurchaseTracker singleton. */ - constructor(IHatsSecurityContext securityContext, ISystemSettings settings_, bool autoRelease) { + constructor( + IHatsSecurityContext securityContext, + ISystemSettings settings_, + bool autoRelease, + IPurchaseTracker _purchaseTracker + ) { _setSecurityContext(securityContext); settings = settings_; autoReleaseFlag = autoRelease; + purchaseTracker = _purchaseTracker; } /** @@ -118,14 +119,9 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract } /** - * Allows multiple payments to be processed. - * - * Reverts: - * - 'InsufficientAmount': if amount of native ETH sent is not equal to the declared amount. - * - 'TokenPaymentFailed': if token transfer fails for any reason (e.g. insufficial allowance) - * - 'DuplicatePayment': if payment id exists already - * - * Emits: + * @notice Processes a new payment. + * + * Emits: * - {PaymentEscrow-PaymentReceived} * * @param paymentInput Payment inputs @@ -162,9 +158,7 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract } /** - * Returns the payment data specified by id. - * - * @param paymentId A unique payment id + * @notice Returns the payment data for a given id. */ function getPayment(bytes32 paymentId) public view returns (Payment memory) { return payments[paymentId]; @@ -240,17 +234,13 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract Payment storage payment = payments[paymentId]; require(payment.released == false, "Payment already released"); if (payment.amount > 0 && payment.amountRefunded <= payment.amount) { - - //who has permission to refund? either the receiver or the arbiter if (payment.receiver != msg.sender && !securityContext.hasRole(Roles.ARBITER_ROLE, msg.sender)) revert("Unauthorized"); uint256 activeAmount = payment.amount - payment.amountRefunded; - if (amount > activeAmount) revert("AmountExceeded"); - //transfer amount back to payer if (amount > 0) { if (_transferAmount(payment.id, payment.payer, payment.currency, amount)) { payment.amountRefunded += amount; @@ -286,9 +276,6 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract } - //NON-PUBLIC METHODS - - // Helper function to calculate fee and remaining amount function _calculateFeeAndAmount(uint256 amount) internal view returns (uint256 fee, uint256 amountToPay) { fee = 0; uint256 feeBps = _getFeeBps(); @@ -301,7 +288,6 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract amountToPay = amount - fee; } - // Helper function to handle fee transfer function _handleFeeTransfer(bytes32 paymentId, address currency, uint256 fee) internal returns (bool) { if (fee == 0) return true; return _transferAmount(paymentId, _getvaultAddress(), currency, fee); @@ -316,21 +302,22 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract uint256 amount = payment.amount - payment.amountRefunded; (uint256 fee, uint256 amountToPay) = _calculateFeeAndAmount(amount); - // If there's no amount to pay but there is a fee, or if the transfer succeeds if ((amountToPay == 0 && fee > 0) || _transferAmount(payment.id, payment.receiver, payment.currency, amountToPay)) { - // Handle fee transfer if (_handleFeeTransfer(payment.id, payment.currency, fee)) { payment.released = true; emit EscrowReleased(paymentId, amountToPay, fee); + + if (address(purchaseTracker) != address(0)) { + purchaseTracker.recordPurchase(paymentId, payment.receiver, payment.payer, amountToPay); + } } } } function _transferAmount(bytes32 paymentId, address to, address tokenAddressOrZero, uint256 amount) internal returns (bool) { bool success = false; - if (amount > 0) { if (tokenAddressOrZero == address(0)) { (success,) = payable(to).call{value: amount}(""); @@ -347,21 +334,18 @@ contract PaymentEscrow is HasSecurityContext, IEscrowContract revert("PaymentTransferFailed"); } } - return success; } function _getFeeBps() internal view returns (uint256) { if (address(settings) != address(0)) return settings.feeBps(); - return 0; } function _getvaultAddress() internal view returns (address) { if (address(settings) != address(0)) return settings.vaultAddress(); - return address(0); } diff --git a/test-foundry/EscrowMulticallTest.t.sol b/test-foundry/EscrowMulticallTest.t.sol index af90cf9..d3ff328 100644 --- a/test-foundry/EscrowMulticallTest.t.sol +++ b/test-foundry/EscrowMulticallTest.t.sol @@ -16,6 +16,7 @@ import { TestToken } from "../src/TestToken.sol"; import { IHatsSecurityContext } from "../src/IHatsSecurityContext.sol"; import "../src/hats/EligibilityModule.sol"; import "../src/hats/ToggleModule.sol"; +import "../src/IPurchaseTracker.sol"; contract EscrowMulticallTest is Test { Hats hats; @@ -123,10 +124,10 @@ contract EscrowMulticallTest is Test { testToken = new TestToken("XYZ", "ZYX"); systemSettings = new SystemSettings(IHatsSecurityContext(address(securityContext)), vaultAddress, 0); - escrow = new PaymentEscrow(IHatsSecurityContext(address(securityContext)), ISystemSettings(address(systemSettings)), false); + escrow = new PaymentEscrow(IHatsSecurityContext(address(securityContext)), ISystemSettings(address(systemSettings)), false, IPurchaseTracker(address(0))); escrow1 = escrow; - escrow2 = new PaymentEscrow(IHatsSecurityContext(address(securityContext)), ISystemSettings(address(systemSettings)), false); - escrow3 = new PaymentEscrow(IHatsSecurityContext(address(securityContext)), ISystemSettings(address(systemSettings)), false); + escrow2 = new PaymentEscrow(IHatsSecurityContext(address(securityContext)), ISystemSettings(address(systemSettings)), false, IPurchaseTracker(address(0))); + escrow3 = new PaymentEscrow(IHatsSecurityContext(address(securityContext)), ISystemSettings(address(systemSettings)), false, IPurchaseTracker(address(0))); multicall = new EscrowMulticall(); diff --git a/test-foundry/HatsTest.t.sol b/test-foundry/HatsTest.t.sol index 3fe201e..a9ec3c3 100644 --- a/test-foundry/HatsTest.t.sol +++ b/test-foundry/HatsTest.t.sol @@ -13,6 +13,7 @@ import { ISystemSettings } from "../src/ISystemSettings.sol"; import { PaymentInput, Payment } from "../src/PaymentInput.sol"; import { FailingToken } from "../src/FailingToken.sol"; import { console } from "forge-std/console.sol"; +import "../src/IPurchaseTracker.sol"; // Dummy Eligibility and Toggle Modules contract DummyEligibilityModule { @@ -206,7 +207,8 @@ contract PaymentEscrowHatsTest is Test { escrow = new PaymentEscrow( IHatsSecurityContext(address(hatsSecurityContext)), ISystemSettings(address(systemSettings)), - false // autoReleaseFlag + false, // autoReleaseFlag + IPurchaseTracker(address(0)) ); vm.stopPrank(); diff --git a/test-foundry/PaymentEscrowTest.t.sol b/test-foundry/PaymentEscrowTest.t.sol index f4d3cb4..ce1ab81 100644 --- a/test-foundry/PaymentEscrowTest.t.sol +++ b/test-foundry/PaymentEscrowTest.t.sol @@ -14,6 +14,7 @@ import {console} from "forge-std/console.sol"; import {FailingToken} from "../src/FailingToken.sol"; import "../src/hats/EligibilityModule.sol"; import "../src/hats/ToggleModule.sol"; +import "../src/IPurchaseTracker.sol"; contract PaymentEscrowTest is Test { Hats internal hats; @@ -139,7 +140,7 @@ contract PaymentEscrowTest is Test { testToken = new TestToken("XYZ", "ZYX"); systemSettings = new SystemSettings(IHatsSecurityContext(address(securityContext)), vaultAddress, 0); - escrow = new PaymentEscrow(IHatsSecurityContext(address(securityContext)), ISystemSettings(address(systemSettings)), false); + escrow = new PaymentEscrow(IHatsSecurityContext(address(securityContext)), ISystemSettings(address(systemSettings)), false, IPurchaseTracker(address(0))); testToken.mint(nonOwner, 10_000_000_000); testToken.mint(payer1, 10_000_000_000); @@ -1615,7 +1616,8 @@ contract PaymentEscrowTest is Test { PaymentEscrow escrowAutoRelease = new PaymentEscrow( IHatsSecurityContext(address(securityContext)), ISystemSettings(address(systemSettings)), - true // autoReleaseFlag = true + true, // autoReleaseFlag = true + IPurchaseTracker(address(0)) ); vm.stopPrank();