diff --git a/.gitmodules b/.gitmodules index 44b846c..5959d3b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -28,3 +28,4 @@ [submodule "lib/hamza-escrow"] path = lib/hamza-escrow url = https://github.com/LoadPipe/hamza-escrow + branch = HAMT-75-purchase-tracker-currency diff --git a/lib/hamza-escrow b/lib/hamza-escrow index 6d05b52..13916e3 160000 --- a/lib/hamza-escrow +++ b/lib/hamza-escrow @@ -1 +1 @@ -Subproject commit 6d05b52207ad80bd56421595a5ad8cd84b3c537c +Subproject commit 13916e3d0c0088e8b52fb7dbccab7fffcef78b1e diff --git a/scripts/DeployHamzaVault.s.sol b/scripts/DeployHamzaVault.s.sol index 3484f9e..aadd7fe 100644 --- a/scripts/DeployHamzaVault.s.sol +++ b/scripts/DeployHamzaVault.s.sol @@ -14,6 +14,7 @@ import "../src/GovernanceVault.sol"; import "@hamza-escrow/SystemSettings.sol"; import "@hamza-escrow/PaymentEscrow.sol"; import "@hamza-escrow/EscrowMulticall.sol"; +import { TestToken as HamzaTestToken } from "@hamza-escrow/TestToken.sol"; import "../src/HamzaGovernor.sol"; import { HamzaGovernor } from "../src/HamzaGovernor.sol"; @@ -58,6 +59,9 @@ contract DeployHamzaVault is Script { address public escrowAddr; + // Add testToken address + address public testTokenAddr; + // Store these values to avoid stack depth issues address public safeAddr; address public hats; @@ -75,7 +79,8 @@ contract DeployHamzaVault is Script { address governanceToken, address governanceVault, address _safeAddress, - address _hatsSecurityContext + address _hatsSecurityContext, + address testToken ) { // 1) Read config file @@ -138,6 +143,9 @@ contract DeployHamzaVault is Script { // 15-16) Deploy PurchaseTracker, PaymentEscrow, and EscrowMulticall deployEscrowContracts(ISecurityContext(hatsSecurityContextAddr), vault, lootTokenAddr); + // 17) Deploy TestToken + address _testTokenAddr = deployTestToken(); + vm.stopBroadcast(); if (keccak256(abi.encodePacked(mode)) == keccak256(abi.encodePacked("Deploy"))) { @@ -146,7 +154,8 @@ contract DeployHamzaVault is Script { vault, govTokenAddr, govVaultAddr, - timelockAddr + timelockAddr, + _testTokenAddr ); } @@ -157,7 +166,8 @@ contract DeployHamzaVault is Script { govTokenAddr, // governanceToken govVaultAddr, // governanceVault safeAddr, // safeAddress - hatsSecurityContextAddr // hatsSecurityContext + hatsSecurityContextAddr, // hatsSecurityContext + _testTokenAddr // testToken ); } @@ -439,12 +449,26 @@ contract DeployHamzaVault is Script { new EscrowMulticall(); } + function deployTestToken() internal returns (address) { + // Deploy TestToken with name and symbol + HamzaTestToken testToken = new HamzaTestToken("Hamza Test Token", "HTT"); + + // Store the address + testTokenAddr = address(testToken); + + // Mint some tokens to OWNER_ONE for testing + testToken.mint(OWNER_ONE, 1000 * 10**18); + + return testTokenAddr; + } + function logDeployedAddresses( address newBaalAddr, address communityVault, address govTokenAddr, address govVaultAddr, - address timelockAddr + address timelockAddr, + address _testTokenAddr ) internal view { console2.log("Owner One (from PRIVATE_KEY):", OWNER_ONE); console2.log("Owner Two (from config): ", OWNER_TWO); @@ -464,6 +488,7 @@ contract DeployHamzaVault is Script { console2.log("Timelock deployed at:", timelockAddr); console2.log("PurchaseTracker deployed at:", purchaseTrackerAddr); console2.log("PaymentEscrow deployed at:", escrowAddr); + console2.log("TestToken deployed at:", _testTokenAddr); console2.log("-----------------------------------------------"); } diff --git a/src/PurchaseTracker.sol b/src/PurchaseTracker.sol index 8a4f167..32341e8 100644 --- a/src/PurchaseTracker.sol +++ b/src/PurchaseTracker.sol @@ -17,10 +17,12 @@ contract PurchaseTracker is HasSecurityContext, IPurchaseTracker { // Mapping from buyer address to cumulative purchase count and total purchase amount. mapping(address => uint256) public totalPurchaseCount; mapping(address => uint256) public totalPurchaseAmount; - + mapping(address => mapping(address => uint256)) public purchaseAmountByCurrency; + // mapping for sellers mapping(address => uint256) public totalSalesCount; mapping(address => uint256) public totalSalesAmount; + mapping(address => mapping(address => uint256)) public salesAmountByCurrency; // Store details about each purchase (keyed by the unique payment ID). mapping(bytes32 => Purchase) public purchases; @@ -32,13 +34,14 @@ contract PurchaseTracker is HasSecurityContext, IPurchaseTracker { address seller; address buyer; uint256 amount; + address currency; bool recorded; } // Authorized contracts (such as escrow contracts) that are allowed to record purchases. mapping(address => bool) public authorizedEscrows; - event PurchaseRecorded(bytes32 indexed paymentId, address indexed buyer, uint256 amount); + event PurchaseRecorded(bytes32 indexed paymentId, address indexed buyer, uint256 amount, address currency); modifier onlyAuthorized() { require(authorizedEscrows[msg.sender], "PurchaseTracker: Not authorized"); @@ -69,26 +72,44 @@ contract PurchaseTracker is HasSecurityContext, IPurchaseTracker { /** * @notice Records a purchase. * @param paymentId The unique payment ID. - * @param buyer The address of the buyer * @param seller The address of the seller + * @param buyer The address of the buyer * @param amount The purchase amount. + * @param currency The currency used for the purchase. * * Requirements: * - The caller must be an authorized contract. * - A purchase with the same paymentId must not have been recorded already. */ + function recordPurchase(bytes32 paymentId, address seller, address buyer, uint256 amount, address currency) external onlyAuthorized { + _recordPurchase(paymentId, seller, buyer, amount, currency); + } + + /** + * @notice Legacy recordPurchase function for backward compatibility. + * Now calls the new function with a default currency of address(0). + */ function recordPurchase(bytes32 paymentId, address seller, address buyer, uint256 amount) external onlyAuthorized { + _recordPurchase(paymentId, seller, buyer, amount, address(0)); + } + + /** + * @dev Internal implementation of recordPurchase that both external functions call. + */ + function _recordPurchase(bytes32 paymentId, address seller, address buyer, uint256 amount, address currency) internal { require(!purchases[paymentId].recorded, "PurchaseTracker: Purchase already recorded"); - purchases[paymentId] = Purchase(seller, buyer, amount, true); + purchases[paymentId] = Purchase(seller, buyer, amount, currency, true); totalPurchaseCount[buyer] += 1; totalPurchaseAmount[buyer] += amount; + purchaseAmountByCurrency[buyer][currency] += amount; //log seller info totalSalesCount[seller] += 1; totalSalesAmount[seller] += amount; + salesAmountByCurrency[seller][currency] += amount; - emit PurchaseRecorded(paymentId, buyer, amount); + emit PurchaseRecorded(paymentId, buyer, amount, currency); } function getPurchaseCount(address recipient) external view returns (uint256) { @@ -106,4 +127,12 @@ contract PurchaseTracker is HasSecurityContext, IPurchaseTracker { function getSalesAmount(address recipient) external view returns (uint256) { return totalSalesAmount[recipient]; } + + function getPurchaseAmountByCurrency(address recipient, address currency) external view returns (uint256) { + return purchaseAmountByCurrency[recipient][currency]; + } + + function getSalesAmountByCurrency(address recipient, address currency) external view returns (uint256) { + return salesAmountByCurrency[recipient][currency]; + } } diff --git a/test/DeploymentSetup.t.sol b/test/DeploymentSetup.t.sol index 0a05014..bb0a6c2 100644 --- a/test/DeploymentSetup.t.sol +++ b/test/DeploymentSetup.t.sol @@ -21,6 +21,7 @@ contract DeploymentSetup is Test { address public systemSettings; address public purchaseTracker; address public escrow; + address public deployedTestToken; uint256 public adminHatId; address public admin; address public lootToken; @@ -56,7 +57,7 @@ contract DeploymentSetup is Test { // Deploy contracts script = new DeployHamzaVault(); - (baal, communityVault, govToken, govVault, safe, hatsCtx) = script.run(); + (baal, communityVault, govToken, govVault, safe, hatsCtx, deployedTestToken) = script.run(); // Initialize addresses adminHatId = script.adminHatId(); diff --git a/test/PurchaseTracker.t.sol b/test/PurchaseTracker.t.sol index ca6340c..f250e2e 100644 --- a/test/PurchaseTracker.t.sol +++ b/test/PurchaseTracker.t.sol @@ -7,7 +7,9 @@ import "../src/PurchaseTracker.sol"; import "@hamza-escrow/PaymentEscrow.sol" as EscrowLib; import "@hamza-escrow/security/Roles.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; import "@hamza-escrow/ISystemSettings.sol"; +import { TestToken as HamzaTestToken } from "@hamza-escrow/TestToken.sol"; /** * @notice This test suite verifies that the PurchaseTracker is correctly updated @@ -328,6 +330,201 @@ contract TestPaymentAndTracker is DeploymentSetup { //TODO: TEST: test that PurchaseRecorded event is emitted //TODO: TEST: test 'PurchaseTracker: Purchase already recorded' error + /** + * @notice Tests the currency-specific tracking functionality added to PurchaseTracker + */ + function testCurrencyBasedTracking() public { + // Create a second test token to test multiple currencies + HamzaTestToken secondToken = new HamzaTestToken("Second Test Token", "STT"); + secondToken.mint(address(this), 1_000_000 ether); + secondToken.mint(payer, 10_000 ether); + secondToken.mint(payer1, 10_000 ether); + secondToken.mint(payer2, 10_000 ether); + + // Setup payment IDs and amounts + bytes32 paymentId1 = keccak256("currency-test-1"); + bytes32 paymentId2 = keccak256("currency-test-2"); + uint256 payAmount1 = 1000; + uint256 payAmount2 = 2000; + + // Place a payment using loot token + EscrowLib.Payment memory payment1 = placePayment( + payEscrow, paymentId1, payer, seller, address(loot), payAmount1 + ); + + // Place a payment using the second token + vm.startPrank(payer); + secondToken.approve(address(payEscrow), payAmount2); + vm.stopPrank(); + + // Place payment with the second token + vm.startPrank(payer); + PaymentInput memory input = PaymentInput({ + id: paymentId2, + payer: payer, + receiver: seller, + currency: address(secondToken), + amount: payAmount2 + }); + payEscrow.placePayment(input); + vm.stopPrank(); + + // Release both escrows + releaseEscrow(payEscrow, paymentId1); + releaseEscrow(payEscrow, paymentId2); + + // Calculate expected net amounts after fees + uint256 feeBps = payEscrowSettingsFee(); + uint256 expectedFee1 = (payAmount1 * feeBps) / 10000; + uint256 netAmount1 = payAmount1 - expectedFee1; + uint256 expectedFee2 = (payAmount2 * feeBps) / 10000; + uint256 netAmount2 = payAmount2 - expectedFee2; + + // Test total amounts + assertEq(tracker.totalPurchaseAmount(payer), netAmount1 + netAmount2, "Total purchase amount mismatch"); + assertEq(tracker.totalSalesAmount(seller), netAmount1 + netAmount2, "Total sales amount mismatch"); + + // Test currency-specific amounts + assertEq(tracker.getPurchaseAmountByCurrency(payer, address(loot)), netAmount1, "Loot token purchase amount mismatch"); + assertEq(tracker.getPurchaseAmountByCurrency(payer, address(secondToken)), netAmount2, "Second token purchase amount mismatch"); + + assertEq(tracker.getSalesAmountByCurrency(seller, address(loot)), netAmount1, "Loot token sales amount mismatch"); + assertEq(tracker.getSalesAmountByCurrency(seller, address(secondToken)), netAmount2, "Second token sales amount mismatch"); + + // Test with address with no purchases/sales in a specific currency + assertEq(tracker.getPurchaseAmountByCurrency(seller, address(loot)), 0, "Seller shouldn't have purchases"); + assertEq(tracker.getSalesAmountByCurrency(payer, address(loot)), 0, "Payer shouldn't have sales"); + } + + /** + * @notice Tests recording purchases in multiple currencies with the same user and verifies + * that both per-currency and total amounts are tracked correctly + */ + function testMultipleCurrencyTracking() public { + // Create three test tokens + HamzaTestToken token1 = new HamzaTestToken("Token One", "TK1"); + HamzaTestToken token2 = new HamzaTestToken("Token Two", "TK2"); + HamzaTestToken token3 = new HamzaTestToken("Token Three", "TK3"); + + // Mint tokens to test addresses + token1.mint(payer, 10_000 ether); + token2.mint(payer, 10_000 ether); + token3.mint(payer, 10_000 ether); + + // Setup payment data + bytes32[] memory paymentIds = new bytes32[](3); + paymentIds[0] = keccak256("multi-currency-1"); + paymentIds[1] = keccak256("multi-currency-2"); + paymentIds[2] = keccak256("multi-currency-3"); + + uint256[] memory amounts = new uint256[](3); + amounts[0] = 1000; + amounts[1] = 2000; + amounts[2] = 3000; + + address[] memory currencies = new address[](3); + currencies[0] = address(token1); + currencies[1] = address(token2); + currencies[2] = address(token3); + + // Place payments with different currencies + for (uint i = 0; i < 3; i++) { + vm.startPrank(payer); + IERC20(currencies[i]).approve(address(payEscrow), amounts[i]); + + PaymentInput memory input = PaymentInput({ + id: paymentIds[i], + payer: payer, + receiver: seller, + currency: currencies[i], + amount: amounts[i] + }); + payEscrow.placePayment(input); + vm.stopPrank(); + + // Release escrow + releaseEscrow(payEscrow, paymentIds[i]); + } + + // Calculate expected net amounts after fees + uint256 feeBps = payEscrowSettingsFee(); + uint256[] memory netAmounts = new uint256[](3); + uint256 totalNet = 0; + + for (uint i = 0; i < 3; i++) { + uint256 fee = (amounts[i] * feeBps) / 10000; + netAmounts[i] = amounts[i] - fee; + totalNet += netAmounts[i]; + } + + // Check total amounts + assertEq(tracker.totalPurchaseAmount(payer), totalNet, "Total purchase amount mismatch"); + assertEq(tracker.totalSalesAmount(seller), totalNet, "Total sales amount mismatch"); + + // Check currency-specific amounts + for (uint i = 0; i < 3; i++) { + assertEq( + tracker.getPurchaseAmountByCurrency(payer, currencies[i]), + netAmounts[i], + string.concat("Currency ", Strings.toString(i), " purchase amount mismatch") + ); + + assertEq( + tracker.getSalesAmountByCurrency(seller, currencies[i]), + netAmounts[i], + string.concat("Currency ", Strings.toString(i), " sales amount mismatch") + ); + } + } + + /** + * @notice Tests recording native currency (ETH) purchases correctly + */ + function testNativeCurrencyTracking() public { + // Setup payment with native currency (address(0)) + bytes32 paymentId = keccak256("native-currency-test"); + uint256 payAmount = 1 ether; + + // Ensure payer has enough ETH + vm.deal(payer, 10 ether); + + // Place payment with native currency + vm.startPrank(payer); + PaymentInput memory input = PaymentInput({ + id: paymentId, + payer: payer, + receiver: seller, + currency: address(0), // Native currency + amount: payAmount + }); + payEscrow.placePayment{value: payAmount}(input); + vm.stopPrank(); + + // Release escrow + releaseEscrow(payEscrow, paymentId); + + // Calculate expected net amount after fees + uint256 feeBps = payEscrowSettingsFee(); + uint256 expectedFee = (payAmount * feeBps) / 10000; + uint256 netAmount = payAmount - expectedFee; + + // Check total amounts + assertEq(tracker.totalPurchaseAmount(payer), netAmount, "Total purchase amount mismatch"); + assertEq(tracker.totalSalesAmount(seller), netAmount, "Total sales amount mismatch"); + + // Check native currency-specific amounts + assertEq( + tracker.getPurchaseAmountByCurrency(payer, address(0)), + netAmount, + "Native currency purchase amount mismatch" + ); + + assertEq( + tracker.getSalesAmountByCurrency(seller, address(0)), + netAmount, + "Native currency sales amount mismatch" + ); + } function payEscrowSettingsFee() internal view returns (uint256) { return systemSettings1.feeBps(); @@ -342,7 +539,15 @@ contract TestPaymentAndTracker is DeploymentSetup { uint256 amount ) private returns(EscrowLib.Payment memory) { vm.startPrank(payer); - loot.approve(address(_escrow), amount); + + // Use the appropriate token for approval + if (currency == address(0)) { + // Native ETH - no approval needed + } else if (currency == address(loot)) { + loot.approve(address(_escrow), amount); + } else { + IERC20(currency).approve(address(_escrow), amount); + } PaymentInput memory input = PaymentInput({ id: paymentId, @@ -351,7 +556,13 @@ contract TestPaymentAndTracker is DeploymentSetup { currency: currency, amount: amount }); - _escrow.placePayment(input); + + if (currency == address(0)) { + _escrow.placePayment{value: amount}(input); + } else { + _escrow.placePayment(input); + } + vm.stopPrank(); // 3. Fetch payment details diff --git a/test/Voting.t.sol b/test/Voting.t.sol index 1984350..1b94e47 100644 --- a/test/Voting.t.sol +++ b/test/Voting.t.sol @@ -5,7 +5,7 @@ import "./DeploymentSetup.t.sol"; import "@hamza-escrow/security/HatsSecurityContext.sol"; import "../src/tokens/GovernanceToken.sol"; import "../src/HamzaGovernor.sol"; -import "../src/utils/TestToken.sol"; +import { TestToken as VotingTestToken } from "../src/utils/TestToken.sol"; import "@hamza-escrow/SystemSettings.sol"; import "@openzeppelin/contracts/governance/TimelockController.sol"; import { HamzaGovernor } from "../src/HamzaGovernor.sol"; @@ -41,7 +41,7 @@ contract VotingTest is DeploymentSetup { // Use the existing securityContext, lootToken, and govToken from DeploymentSetup HatsSecurityContext securityContextLocal = HatsSecurityContext(hatsCtx); - TestToken lootTokenLocal = TestToken(lootToken); + VotingTestToken lootTokenLocal = VotingTestToken(lootToken); GovernanceToken govTokenLocal = GovernanceToken(govToken); // Mint loot tokens to voters @@ -385,7 +385,7 @@ contract VotingTest is DeploymentSetup { // Test vote delegation functionality function testVoteDelegation() public { - TestToken lootTokenLocal = TestToken(lootToken); + VotingTestToken lootTokenLocal = VotingTestToken(lootToken); GovernanceToken govTokenLocal = GovernanceToken(govToken); // Delegate voter[1]'s votes to voter[0]