diff --git a/README.md b/README.md index e9d088c..3db090e 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,10 @@ ## Contracts 1. Registry.sol: [0x2b4836d81370e37030727e4dcbd9cc5a772cf43a](https://sepolia.basescan.org/address/0x2b4836d81370e37030727e4dcbd9cc5a772cf43a) -2. Exchange.sol: [0xd9004Edc4bdEB308C4A40fdCbE320bbE5DF4AF77](https://sepolia.basescan.org/address/0xd9004edc4bdeb308c4a40fdcbe320bbe5df4af77) -3. Vault.sol: [0xd580248163CDD5AE3225A700E9f4e7CD525b27b0](https://sepolia.basescan.org/address/0xd580248163cdd5ae3225a700e9f4e7cd525b27b0) -4. XSGD.sol [0xd7260d7063fE5A62A90E6A8DD5A39Ab27A05986B](https://sepolia.basescan.org/token/0xd7260d7063fe5a62a90e6a8dd5a39ab27a05986b) +2. Exchange.sol (V1): [0xd9004Edc4bdEB308C4A40fdCbE320bbE5DF4AF77](https://sepolia.basescan.org/address/0xd9004edc4bdeb308c4a40fdcbe320bbe5df4af77) +3. Exchange.sol (V2): [0x92F5D70ffBE0988DEcD5c1E7A6cb8A048a3Fe75D](https://sepolia.basescan.org/address/0x92F5D70ffBE0988DEcD5c1E7A6cb8A048a3Fe75D) +4. Vault.sol: [0xd580248163CDD5AE3225A700E9f4e7CD525b27b0](https://sepolia.basescan.org/address/0xd580248163cdd5ae3225a700e9f4e7cd525b27b0) +5. XSGD.sol: [0xd7260d7063fE5A62A90E6A8DD5A39Ab27A05986B](https://sepolia.basescan.org/token/0xd7260d7063fe5a62a90e6a8dd5a39ab27a05986b) ## Deployment diff --git a/script/deploy/Exchange.s.sol b/script/deploy/Exchange.s.sol index 2000f89..16f43a2 100644 --- a/script/deploy/Exchange.s.sol +++ b/script/deploy/Exchange.s.sol @@ -11,9 +11,10 @@ contract DeployExchange is Script { address registryAddress = 0x2b4836d81370e37030727E4DCbd9cC5a772cf43A; address usdcAddress = 0x036CbD53842c5426634e7929541eC2318f3dCF7e; + address xsgdAddress = 0xd7260d7063fE5A62A90E6A8DD5A39Ab27A05986B; address vaultAddress = 0xd580248163CDD5AE3225A700E9f4e7CD525b27b0; - Exchange exchange = new Exchange(registryAddress, usdcAddress, vaultAddress); + Exchange exchange = new Exchange(registryAddress, usdcAddress, xsgdAddress, vaultAddress); console.log("Exchange deployed at:", address(exchange)); diff --git a/src/Exchange.sol b/src/Exchange.sol index 685f5d4..48cc432 100644 --- a/src/Exchange.sol +++ b/src/Exchange.sol @@ -19,11 +19,15 @@ contract Exchange is ReentrancyGuard, Ownable { Registry public immutable registry; IERC20 public immutable usdcToken; + IERC20 public immutable xsgdToken; IERC4626 public immutable vault; uint256 public fee = 100; // 100 basis points fee (1%) address public feeCollector; + uint256 public lastUpdateTime; + uint256 public lastPricePerShare; + event Transfer(address indexed from, address indexed to, uint256 amount, string uen); event FeeUpdated(uint256 newFee); event FeeCollectorUpdated(address newFeeCollector); @@ -31,14 +35,20 @@ contract Exchange is ReentrancyGuard, Ownable { event VaultDeposit(address indexed merchant, uint256 assets, uint256 shares); event VaultWithdraw(address indexed merchant, uint256 assets, uint256 shares); - constructor(address _registryAddress, address _usdcAddress, address _vaultAddress) Ownable(msg.sender) { + constructor(address _registryAddress, address _usdcAddress, address _xsgdAddress, address _vaultAddress) + Ownable(msg.sender) + { require(_registryAddress != address(0), "Invalid registry address"); require(_usdcAddress != address(0), "Invalid USDC address"); + require(_xsgdAddress != address(0), "Invalid xSGD address"); require(_vaultAddress != address(0), "Invalid vault address"); registry = Registry(_registryAddress); usdcToken = IERC20(_usdcAddress); + xsgdToken = IERC20(_xsgdAddress); feeCollector = address(this); vault = IERC4626(_vaultAddress); + lastUpdateTime = block.timestamp; + lastPricePerShare = vault.convertToAssets(1e6); } ///////////////////////// @@ -50,7 +60,7 @@ contract Exchange is ReentrancyGuard, Ownable { * @param _uen Merchant's UEN. * @param _amount Amount of USDC to transfer to merchant. */ - function transferToMerchant(string memory _uen, uint256 _amount) external nonReentrant { + function transferUsdcToMerchant(string memory _uen, uint256 _amount) external nonReentrant { require(_amount > 0, "Amount must be greater than zero"); address merchantWalletAddress = registry.getMerchantByUEN(_uen).wallet_address; @@ -67,6 +77,23 @@ contract Exchange is ReentrancyGuard, Ownable { emit Transfer(msg.sender, merchantWalletAddress, merchantAmount, _uen); } + function transferXsgdToMerchant(string memory _uen, uint256 _amount) external nonReentrant { + require(_amount > 0, "Amount must be greater than zero"); + + address merchantWalletAddress = registry.getMerchantByUEN(_uen).wallet_address; + require(merchantWalletAddress != address(0), "Invalid merchant wallet address"); + + uint256 feeAmount = (_amount * fee) / 10000; + uint256 merchantAmount = _amount - feeAmount; + + xsgdToken.safeTransferFrom(msg.sender, merchantWalletAddress, merchantAmount); + if (feeAmount > 0) { + xsgdToken.safeTransferFrom(msg.sender, feeCollector, feeAmount); + } + + emit Transfer(msg.sender, merchantWalletAddress, merchantAmount, _uen); + } + /** * @notice Transfer USDC to vault. * @param _uen Merchant's UEN. @@ -149,11 +176,11 @@ contract Exchange is ReentrancyGuard, Ownable { } /** - * @notice Withdraw fees. + * @notice Withdraw USDC fees. * @param _to Withdrawal address. * @param _amount Amount of USDC to withdraw. */ - function withdrawFees(address _to, uint256 _amount) external onlyOwner { + function withdrawUsdcFees(address _to, uint256 _amount) external onlyOwner { require(_to != address(0), "Invalid withdrawal address"); require(_amount > 0, "Withdrawal amount must be greater than zero"); require(_amount <= usdcToken.balanceOf(address(this)), "Insufficient balance"); @@ -161,4 +188,107 @@ contract Exchange is ReentrancyGuard, Ownable { usdcToken.safeTransfer(_to, _amount); emit FeesWithdrawn(_to, _amount); } + + /** + * @notice Withdraw XSGD fees. + * @param _to Withdrawal address. + * @param _amount Amount of XSGD to withdraw. + */ + function withdrawXsgdFees(address _to, uint256 _amount) external onlyOwner { + require(_to != address(0), "Invalid withdrawal address"); + require(_amount > 0, "Withdrawal amount must be greater than zero"); + require(_amount <= xsgdToken.balanceOf(address(this)), "Insufficient balance"); + + xsgdToken.safeTransfer(_to, _amount); + emit FeesWithdrawn(_to, _amount); + } + + /////////////////// + // VAULT HELPERS // + /////////////////// + + /** + * @notice Get current price per share + * @return Current price of 1 share in terms of assets (USDC) + */ + function getCurrentPricePerShare() public view returns (uint256) { + return vault.convertToAssets(1e6); + } + + /** + * @notice Get vault metrics + * @return totalAssets Total assets in vault + * @return totalShares Total shares issued + * @return pricePerShare Current price per share + */ + function getVaultMetrics() public view returns (uint256 totalAssets, uint256 totalShares, uint256 pricePerShare) { + totalAssets = vault.totalAssets(); + totalShares = vault.totalSupply(); + pricePerShare = getCurrentPricePerShare(); + } + + /** + * @notice Calculate APY between two price points + * @param startPrice Starting price per share + * @param endPrice Ending price per share + * @param timeElapsedInSeconds Time elapsed between prices in seconds + * @return apy Annual Percentage Yield in basis points (1% = 100) + */ + function calculateAPY(uint256 startPrice, uint256 endPrice, uint256 timeElapsedInSeconds) + public + pure + returns (uint256 apy) + { + require(timeElapsedInSeconds > 0, "Time elapsed must be > 0"); + require(startPrice > 0, "Start price must be > 0"); + + // Calculate yield for the period + uint256 yield = ((endPrice - startPrice) * 1e6) / startPrice; + + // Annualize it (multiply by seconds in year and divide by elapsed time) + uint256 secondsInYear = 365 days; + apy = (yield * secondsInYear) / timeElapsedInSeconds; + + return apy; + } + + /** + * @notice Get current APY based on last update + * @return Current APY in basis points (1% = 100) + */ + function getCurrentAPY() external view returns (uint256) { + uint256 currentPrice = getCurrentPricePerShare(); + uint256 timeElapsed = block.timestamp - lastUpdateTime; + + return calculateAPY(lastPricePerShare, currentPrice, timeElapsed); + } + + /** + * @notice Update the stored price per share + * @dev This can be called periodically to update the reference point for APY calculations + */ + function updatePricePerShare() external { + lastPricePerShare = getCurrentPricePerShare(); + lastUpdateTime = block.timestamp; + } + + /** + * @notice Get detailed yield information + * @return currentPrice Current price per share + * @return lastPrice Last recorded price per share + * @return timeSinceLastUpdate Seconds since last update + * @return currentAPY Current APY in basis points + */ + function getYieldInfo() + external + view + returns (uint256 currentPrice, uint256 lastPrice, uint256 timeSinceLastUpdate, uint256 currentAPY) + { + currentPrice = getCurrentPricePerShare(); + lastPrice = lastPricePerShare; + timeSinceLastUpdate = block.timestamp - lastUpdateTime; + currentAPY = calculateAPY(lastPrice, currentPrice, timeSinceLastUpdate); + + return (currentPrice, lastPrice, timeSinceLastUpdate, currentAPY); + } } diff --git a/test/Exchange.t.sol b/test/Exchange.t.sol index 67b83b4..c8272db 100644 --- a/test/Exchange.t.sol +++ b/test/Exchange.t.sol @@ -11,14 +11,15 @@ contract ExchangeTest is Test { Exchange public exchange; Registry public registry; MockERC20 public usdc; + MockERC20 public xsgd; MockERC4626 public vault; address public owner; address public user; address public merchant; string constant UEN = "123456789A"; - uint256 constant INITIAL_BALANCE = 1000000 * 10 ** 6; // 1M USDC - uint256 constant DEFAULT_AMOUNT = 1000 * 10 ** 6; // 1000 USDC + uint256 constant INITIAL_BALANCE = 1000000 * 10 ** 6; // 1M ERC20 + uint256 constant DEFAULT_AMOUNT = 1000 * 10 ** 6; // 1000 ERC20 event Transfer(address indexed from, address indexed to, uint256 amount, string uen); event FeeUpdated(uint256 newFee); @@ -27,6 +28,9 @@ contract ExchangeTest is Test { event VaultDeposit(address indexed merchant, uint256 assets, uint256 shares); event VaultWithdraw(address indexed merchant, uint256 assets, uint256 shares); + /** + * @notice Setup the test. + */ function setUp() public { owner = makeAddr("owner"); user = makeAddr("user"); @@ -36,6 +40,7 @@ contract ExchangeTest is Test { // Deploy mock contracts usdc = new MockERC20("USDC", "USDC", 6); + xsgd = new MockERC20("xSGD", "xSGD", 18); vault = new MockERC4626(address(usdc), "Vault USDC", "vUSDC"); // Deploy registry and add merchant @@ -48,15 +53,19 @@ contract ExchangeTest is Test { ); // Deploy exchange - exchange = new Exchange(address(registry), address(usdc), address(vault)); + exchange = new Exchange(address(registry), address(usdc), address(xsgd), address(vault)); vm.stopPrank(); // Setup initial balances deal(address(usdc), user, INITIAL_BALANCE); + deal(address(xsgd), user, INITIAL_BALANCE); } - function test_TransferToMerchant() public { + /** + * @notice Test the transferUsdcToMerchant function. + */ + function test_TransferUsdcToMerchant() public { vm.startPrank(user); usdc.approve(address(exchange), DEFAULT_AMOUNT); @@ -66,13 +75,40 @@ contract ExchangeTest is Test { vm.expectEmit(true, true, false, true); emit Transfer(user, merchant, merchantAmount, UEN); - exchange.transferToMerchant(UEN, DEFAULT_AMOUNT); + exchange.transferUsdcToMerchant(UEN, DEFAULT_AMOUNT); assertEq(usdc.balanceOf(merchant), merchantAmount); assertEq(usdc.balanceOf(address(exchange)), feeAmount); vm.stopPrank(); } + /** + * @notice Test the transferXsgdToMerchant function. + */ + function test_TransferXsgdToMerchant() public { + vm.startPrank(user); + xsgd.approve(address(exchange), DEFAULT_AMOUNT); + + uint256 feeAmount = (DEFAULT_AMOUNT * exchange.fee()) / 10000; + uint256 merchantAmount = DEFAULT_AMOUNT - feeAmount; + + vm.expectEmit(true, true, false, true); + emit Transfer(user, merchant, merchantAmount, UEN); + + exchange.transferXsgdToMerchant(UEN, DEFAULT_AMOUNT); + + assertEq(xsgd.balanceOf(merchant), merchantAmount); + assertEq(xsgd.balanceOf(address(exchange)), feeAmount); + vm.stopPrank(); + } + + ///////////////// + // VAULT TESTS // + ///////////////// + + /** + * @notice Test the transferToVault function. + */ function test_TransferToVault() public { vm.startPrank(user); usdc.approve(address(exchange), DEFAULT_AMOUNT); @@ -92,6 +128,9 @@ contract ExchangeTest is Test { vm.stopPrank(); } + /** + * @notice Test the withdrawToWallet function. + */ function test_WithdrawToWallet() public { // First deposit to vault vm.startPrank(user); @@ -114,6 +153,13 @@ contract ExchangeTest is Test { vm.stopPrank(); } + /////////////// + // FEE TESTS // + /////////////// + + /** + * @notice Test the setFee function. + */ function test_SetFee() public { uint256 newFee = 200; // 2% @@ -125,6 +171,9 @@ contract ExchangeTest is Test { assertEq(exchange.fee(), newFee); } + /** + * @notice Test the setFeeCollector function. + */ function test_SetFeeCollector() public { address newFeeCollector = makeAddr("newFeeCollector"); @@ -136,11 +185,14 @@ contract ExchangeTest is Test { assertEq(exchange.feeCollector(), newFeeCollector); } - function test_WithdrawFees() public { + /** + * @notice Test the withdrawUsdcFees function. + */ + function test_WithdrawUsdcFees() public { // First make a transfer to generate fees vm.startPrank(user); usdc.approve(address(exchange), DEFAULT_AMOUNT); - exchange.transferToMerchant(UEN, DEFAULT_AMOUNT); + exchange.transferUsdcToMerchant(UEN, DEFAULT_AMOUNT); vm.stopPrank(); uint256 feeAmount = (DEFAULT_AMOUNT * exchange.fee()) / 10000; @@ -150,67 +202,185 @@ contract ExchangeTest is Test { vm.expectEmit(true, false, false, true); emit FeesWithdrawn(feeReceiver, feeAmount); - exchange.withdrawFees(feeReceiver, feeAmount); + exchange.withdrawUsdcFees(feeReceiver, feeAmount); assertEq(usdc.balanceOf(feeReceiver), feeAmount); } - function testFail_TransferToMerchant_InvalidUEN() public { - vm.prank(user); - exchange.transferToMerchant("INVALID_UEN", DEFAULT_AMOUNT); + /** + * @notice Test the withdrawXsgdFees function. + */ + function test_WithdrawXsgdFees() public { + // First make a transfer to generate fees + vm.startPrank(user); + xsgd.approve(address(exchange), DEFAULT_AMOUNT); + exchange.transferXsgdToMerchant(UEN, DEFAULT_AMOUNT); + vm.stopPrank(); + + uint256 feeAmount = (DEFAULT_AMOUNT * exchange.fee()) / 10000; + address feeReceiver = makeAddr("feeReceiver"); + + vm.prank(owner); + vm.expectEmit(true, false, false, true); + emit FeesWithdrawn(feeReceiver, feeAmount); + + exchange.withdrawXsgdFees(feeReceiver, feeAmount); + assertEq(xsgd.balanceOf(feeReceiver), feeAmount); + } + + ////////////////// + // REVERT TESTS // + ////////////////// + + /** + * @notice Test the revert when the transferUsdcToMerchant function is called with an invalid UEN. + */ + function test_RevertWhen_TransferUsdcToMerchant_InvalidUEN() public { + vm.startPrank(user); + vm.expectRevert(); + exchange.transferUsdcToMerchant("INVALID_UEN", DEFAULT_AMOUNT); } - function testFail_TransferToMerchant_ZeroAmount() public { - vm.prank(user); - exchange.transferToMerchant(UEN, 0); + /** + * @notice Test the revert when the transferUsdcToMerchant function is called with a zero amount. + */ + function test_RevertWhen_TransferUsdcToMerchant_ZeroAmount() public { + vm.startPrank(user); + vm.expectRevert(); + exchange.transferUsdcToMerchant(UEN, 0); } - function testFail_TransferToVault_InvalidUEN() public { - vm.prank(user); + /** + * @notice Test the revert when the transferXsgdToMerchant function is called with an invalid UEN. + */ + function test_RevertWhen_TransferXsgdToMerchant_InvalidUEN() public { + vm.startPrank(user); + vm.expectRevert(); + exchange.transferXsgdToMerchant("INVALID_UEN", DEFAULT_AMOUNT); + } + + /** + * @notice Test the revert when the transferXsgdToMerchant function is called with a zero amount. + */ + function test_RevertWhen_TransferXsgdToMerchant_ZeroAmount() public { + vm.startPrank(user); + vm.expectRevert(); + exchange.transferXsgdToMerchant(UEN, 0); + } + + /** + * @notice Test the revert when the transferToVault function is called with an invalid UEN. + */ + function test_RevertWhen_TransferToVault_InvalidUEN() public { + vm.startPrank(user); + vm.expectRevert(); exchange.transferToVault("INVALID_UEN", DEFAULT_AMOUNT); } - function testFail_TransferToVault_ZeroAmount() public { - vm.prank(user); + /** + * @notice Test the revert when the transferToVault function is called with a zero amount. + */ + function test_RevertWhen_TransferToVault_ZeroAmount() public { + vm.startPrank(user); + vm.expectRevert(); exchange.transferToVault(UEN, 0); } - function testFail_WithdrawToWallet_InsufficientShares() public { - vm.prank(merchant); + /** + * @notice Test the revert when the withdrawToWallet function is called with an insufficient number of shares. + */ + function test_RevertWhen_WithdrawToWallet_InsufficientShares() public { + vm.startPrank(merchant); + vm.expectRevert(); exchange.withdrawToWallet(1000); // No shares deposited } - function testFail_SetFee_NotOwner() public { - vm.prank(user); + /** + * @notice Test the revert when the setFee function is called by a non-owner. + */ + function test_RevertWhen_SetFee_NotOwner() public { + vm.startPrank(user); + vm.expectRevert(); exchange.setFee(200); } - function testFail_SetFee_TooHigh() public { - vm.prank(owner); + /** + * @notice Test the revert when the setFee function is called with a fee greater than 10%. + */ + function test_RevertWhen_SetFee_TooHigh() public { + vm.startPrank(owner); + vm.expectRevert(); exchange.setFee(1001); // > 10% } - function testFail_SetFeeCollector_NotOwner() public { - vm.prank(user); + /** + * @notice Test the revert when the setFeeCollector function is called by a non-owner. + */ + function test_RevertWhen_SetFeeCollector_NotOwner() public { + vm.startPrank(user); + vm.expectRevert(); exchange.setFeeCollector(address(1)); } - function testFail_SetFeeCollector_ZeroAddress() public { - vm.prank(owner); + /** + * @notice Test the revert when the setFeeCollector function is called with a zero address. + */ + function test_RevertWhen_SetFeeCollector_ZeroAddress() public { + vm.startPrank(owner); + vm.expectRevert(); exchange.setFeeCollector(address(0)); } - function testFail_WithdrawFees_NotOwner() public { - vm.prank(user); - exchange.withdrawFees(address(1), 100); + /** + * @notice Test the revert when the withdrawUsdcFees function is called by a non-owner. + */ + function test_RevertWhen_WithdrawUsdcFees_NotOwner() public { + vm.startPrank(user); + vm.expectRevert(); + exchange.withdrawUsdcFees(address(1), 100); } - function testFail_WithdrawFees_ZeroAmount() public { - vm.prank(owner); - exchange.withdrawFees(address(1), 0); + /** + * @notice Test the revert when the withdrawUsdcFees function is called with a zero amount. + */ + function test_RevertWhen_WithdrawUsdcFees_ZeroAmount() public { + vm.startPrank(owner); + vm.expectRevert(); + exchange.withdrawUsdcFees(address(1), 0); } - function testFail_WithdrawFees_InsufficientBalance() public { - vm.prank(owner); - exchange.withdrawFees(address(1), INITIAL_BALANCE); + /** + * @notice Test the revert when the withdrawUsdcFees function is called with an insufficient balance. + */ + function test_RevertWhen_WithdrawFees_InsufficientBalance() public { + vm.startPrank(owner); + vm.expectRevert(); + exchange.withdrawUsdcFees(address(1), INITIAL_BALANCE); + } + + /** + * @notice Test the revert when the withdrawXsgdFees function is called by a non-owner. + */ + function test_RevertWhen_WithdrawXsgdFees_NotOwner() public { + vm.startPrank(user); + vm.expectRevert(); + exchange.withdrawXsgdFees(address(1), 100); + } + + /** + * @notice Test the revert when the withdrawXsgdFees function is called with a zero amount. + */ + function test_RevertWhen_WithdrawXsgdFees_ZeroAmount() public { + vm.startPrank(owner); + vm.expectRevert(); + exchange.withdrawXsgdFees(address(1), 0); + } + + /** + * @notice Test the revert when the withdrawXsgdFees function is called with an insufficient balance. + */ + function test_RevertWhen_WithdrawXsgdFees_InsufficientBalance() public { + vm.startPrank(owner); + vm.expectRevert(); + exchange.withdrawXsgdFees(address(1), INITIAL_BALANCE); } }