diff --git a/scripts/DeploymentParametersTefnut.sol b/scripts/DeploymentParametersTefnut.sol new file mode 100644 index 0000000..ce75dfd --- /dev/null +++ b/scripts/DeploymentParametersTefnut.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AEL +pragma solidity ^0.8.0; + +/** + * @title DeploymentParametersTefnut + * @notice Configuration management for DjedTefnut deployment across different chains + * @dev Returns deployment parameters based on chain ID + */ +contract DeploymentParametersTefnut { + + /// @notice Struct containing all DjedTefnut constructor parameters + struct Parameters { + address oracleAddress; // Oracle contract address (address(0) signals mock needed) + uint256 scalingFactor; // Scaling factor for decimal representation + address treasury; // Treasury address for fee collection + uint256 treasuryFee; // Fixed treasury fee (no decay) + uint256 fee; // Protocol fee + uint256 thresholdSupplySc; // Threshold supply for stable coins + uint256 rcMinPrice; // Minimum reserve coin price + uint256 rcInitialPrice; // Initial reserve coin price + uint256 txLimit; // Transaction limit + } + + // Chain IDs + uint256 constant SEPOLIA_CHAIN_ID = 11155111; + uint256 constant ANVIL_CHAIN_ID = 31337; + + // Known oracle addresses + address constant SEPOLIA_SHU_ORACLE_ADDRESS = address(0); // Placeholder - replace with actual address when deployed + + // Known treasury addresses + address constant SEPOLIA_TREASURY = 0x0f5342B55ABCC0cC78bdB4868375bCA62B6c16eA; + address constant LOCAL_TREASURY = 0x078D888E40faAe0f32594342c85940AF3949E666; + + /** + * @notice Get deployment parameters for a specific chain + * @param chainId The chain ID to get parameters for + * @return params The Parameters struct with all constructor arguments + */ + function getParams(uint256 chainId) public pure returns (Parameters memory params) { + if (chainId == SEPOLIA_CHAIN_ID) { + // Sepolia Testnet Configuration + params = Parameters({ + oracleAddress: SEPOLIA_SHU_ORACLE_ADDRESS, // Will be replaced with actual address + scalingFactor: 1e24, + treasury: SEPOLIA_TREASURY, + treasuryFee: 25e20, // 0.25% + fee: 15e21, // 1.5% + thresholdSupplySc: 5e11, // 500B SC + rcMinPrice: 1e18, // 1 ETH per RC + rcInitialPrice: 1e20, // 100 ETH per RC + txLimit: 1e10 // 10B SC + }); + } else { + // Local/Anvil Default Configuration + // oracleAddress = address(0) signals that a mock oracle should be deployed + params = Parameters({ + oracleAddress: address(0), // Signal to deploy mock + scalingFactor: 1e24, + treasury: LOCAL_TREASURY, + treasuryFee: 0, // 0% for testing + fee: 15e21, // 1.5% + thresholdSupplySc: 1e6, // 1M SC + rcMinPrice: 1e18, // 1 ETH per RC + rcInitialPrice: 1e20, // 100 ETH per RC + txLimit: 200e6 // 200 SC + }); + } + } + + /** + * @notice Get human-readable network name + * @param chainId The chain ID + * @return name The network name + */ + function getNetworkName(uint256 chainId) public pure returns (string memory name) { + if (chainId == SEPOLIA_CHAIN_ID) { + return "Ethereum Sepolia"; + } else if (chainId == ANVIL_CHAIN_ID) { + return "Anvil Local"; + } else { + return "Unknown Network"; + } + } +} diff --git a/scripts/deployDjedTefnutContract.s.sol b/scripts/deployDjedTefnutContract.s.sol new file mode 100644 index 0000000..2de302d --- /dev/null +++ b/scripts/deployDjedTefnutContract.s.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AEL +pragma solidity ^0.8.0; + +import "forge-std/Script.sol"; +import "./DeploymentParametersTefnut.sol"; +import {DjedTefnut} from "../src/DjedTefnut.sol"; +import {MockShuOracleTefnut} from "../src/mock/MockShuOracleTefnut.sol"; + +/** + * @title DeployDjedTefnut + * @notice Deployment script for DjedTefnut contract + * @dev Automatically deploys mock oracle for local/test chains + * + * Usage: + * Local (Anvil): forge script scripts/deployDjedTefnutContract.s.sol --broadcast --rpc-url http://127.0.0.1:8545 + * Sepolia: forge script scripts/deployDjedTefnutContract.s.sol --broadcast --rpc-url $SEPOLIA_RPC_URL + */ +contract DeployDjedTefnut is Script, DeploymentParametersTefnut { + + // Default oracle exchange rate: 1 USD = 0.5 ETH (in weis per whole SC) + uint256 constant DEFAULT_ORACLE_PRICE = 5e17; + + // Initial balance to seed the contract + uint256 constant INITIAL_BALANCE = 1e18; // 1 ETH + + function run() external { + // Get deployment parameters based on current chain + Parameters memory params = getParams(block.chainid); + + console.log("==========================================="); + console.log("Deploying DjedTefnut"); + console.log("Network:", getNetworkName(block.chainid)); + console.log("Chain ID:", block.chainid); + console.log("==========================================="); + + // Start broadcasting transactions + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + + // Dynamic Mocking: Deploy mock oracle if oracleAddress is address(0) + if (params.oracleAddress == address(0)) { + console.log(""); + console.log("Oracle address is zero - deploying MockShuOracleTefnut..."); + + MockShuOracleTefnut mockOracle = new MockShuOracleTefnut(DEFAULT_ORACLE_PRICE); + params.oracleAddress = address(mockOracle); + + console.log("MockShuOracleTefnut deployed at:", address(mockOracle)); + console.log("Initial oracle price:", DEFAULT_ORACLE_PRICE); + } + + // Log deployment parameters + console.log(""); + console.log("Deployment Parameters:"); + console.log(" Oracle Address:", params.oracleAddress); + console.log(" Scaling Factor:", params.scalingFactor); + console.log(" Treasury:", params.treasury); + console.log(" Treasury Fee:", params.treasuryFee); + console.log(" Fee:", params.fee); + console.log(" Threshold Supply SC:", params.thresholdSupplySc); + console.log(" RC Min Price:", params.rcMinPrice); + console.log(" RC Initial Price:", params.rcInitialPrice); + console.log(" TX Limit:", params.txLimit); + + // Deploy DjedTefnut with the (potentially updated) parameters + DjedTefnut djedTefnut = new DjedTefnut{value: INITIAL_BALANCE}( + params.oracleAddress, + params.scalingFactor, + params.treasury, + params.treasuryFee, + params.fee, + params.thresholdSupplySc, + params.rcMinPrice, + params.rcInitialPrice, + params.txLimit + ); + + console.log(""); + console.log("==========================================="); + console.log("DjedTefnut deployed at:", address(djedTefnut)); + console.log("StableCoin deployed at:", address(djedTefnut.stableCoin())); + console.log("ReserveCoin deployed at:", address(djedTefnut.reserveCoin())); + console.log("Initial Reserve:", address(djedTefnut).balance); + console.log("==========================================="); + + vm.stopBroadcast(); + } +} diff --git a/src/DjedTefnut.sol b/src/DjedTefnut.sol new file mode 100644 index 0000000..50127b0 --- /dev/null +++ b/src/DjedTefnut.sol @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: AEL +pragma solidity ^0.8.0; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import {Coin} from "./Coin.sol"; +import {IOracleShu} from "./IOracleShu.sol"; + +contract DjedTefnut is ReentrancyGuard { + IOracleShu public oracle; + Coin public stableCoin; + Coin public reserveCoin; + + // Treasury Parameters: + address public immutable TREASURY; // address of the treasury + uint256 public immutable TREASURY_FEE; // fixed treasury fee (no decay) + + // Djed Parameters: + uint256 public immutable FEE; + uint256 public immutable THRESHOLD_SUPPLY_SC; + uint256 public immutable RC_MIN_PRICE; + uint256 public immutable RC_INITIAL_PRICE; + uint256 public immutable TX_LIMIT; + + // Scaling factors: + uint256 public immutable SCALING_FACTOR; // used to represent a decimal number `d` as the uint number `d * SCALING_FACTOR` + uint256 public immutable SC_DECIMAL_SCALING_FACTOR; + uint256 public immutable RC_DECIMAL_SCALING_FACTOR; + + event BoughtStableCoins(address indexed buyer, address indexed receiver, uint256 amountSc, uint256 amountBc); + event SoldStableCoins(address indexed seller, address indexed receiver, uint256 amountSc, uint256 amountBc); + event BoughtReserveCoins(address indexed buyer, address indexed receiver, uint256 amountRc, uint256 amountBc); + event SoldReserveCoins(address indexed seller, address indexed receiver, uint256 amountRc, uint256 amountBc); + + constructor( + address oracleAddress, + uint256 scalingFactor, + address treasury, + uint256 treasuryFee, + uint256 fee, + uint256 thresholdSupplySc, + uint256 rcMinPrice, + uint256 rcInitialPrice, + uint256 txLimit + ) payable { + // Constructor validation + require(oracleAddress != address(0), "Invalid oracle address"); + require(treasury != address(0), "Invalid treasury address"); + require(scalingFactor > 0, "Scaling factor must be > 0"); + require(fee + treasuryFee <= scalingFactor, "Total fees exceed 100%"); + require(rcInitialPrice >= rcMinPrice, "Initial price < min price"); + require(thresholdSupplySc > 0, "Threshold supply must be > 0"); + require(txLimit > 0, "TX limit must be > 0"); + + stableCoin = new Coin("StableCoin", "SC"); + reserveCoin = new Coin("ReserveCoin", "RC"); + SC_DECIMAL_SCALING_FACTOR = 10 ** stableCoin.decimals(); + RC_DECIMAL_SCALING_FACTOR = 10 ** reserveCoin.decimals(); + SCALING_FACTOR = scalingFactor; + + TREASURY = treasury; + TREASURY_FEE = treasuryFee; + + FEE = fee; + THRESHOLD_SUPPLY_SC = thresholdSupplySc; + RC_MIN_PRICE = rcMinPrice; + RC_INITIAL_PRICE = rcInitialPrice; + TX_LIMIT = txLimit; + + oracle = IOracleShu(oracleAddress); + oracle.acceptTermsOfService(); + } + + // Reserve, Liabilities, Equity (in weis) and Reserve Ratio + function R(uint256 currentPaymentAmount) public view returns (uint256) { + return address(this).balance - currentPaymentAmount; + } + + function L(uint256 _scPrice) internal view returns (uint256) { + return (stableCoin.totalSupply() * _scPrice) / SC_DECIMAL_SCALING_FACTOR; + } + + function L() external view returns (uint256) { + return L(scMaxPrice(0)); + } + + function E(uint256 _scPrice, uint256 currentPaymentAmount) internal view returns (uint256) { + return R(currentPaymentAmount) - L(_scPrice); + } + + function E(uint256 currentPaymentAmount) external view returns (uint256) { + return E(scMaxPrice(currentPaymentAmount), currentPaymentAmount); + } + + // Ratio functions kept for informational purposes only (no longer restrict transactions) + function ratio() external view returns (uint256) { + uint256 liabilities = L(scMaxPrice(0)); + if (liabilities == 0) return type(uint256).max; + return SCALING_FACTOR * R(0) / liabilities; + } + + // # Public Trading Functions: + // scMaxPrice + function buyStableCoins(address receiver, uint256 feeUi, address ui) external payable nonReentrant { + oracle.updateOracleValues(); + uint256 scP = scMaxPrice(msg.value); + uint256 amountBc = deductFees(msg.value, feeUi, ui); + uint256 amountSc = (amountBc * SC_DECIMAL_SCALING_FACTOR) / scP; + require(amountSc <= TX_LIMIT || stableCoin.totalSupply() < THRESHOLD_SUPPLY_SC, "buySC: tx limit exceeded"); + require(amountSc > 0, "buySC: receiving zero SCs"); + stableCoin.mint(receiver, amountSc); + // Reserve ratio check removed in Tefnut + emit BoughtStableCoins(msg.sender, receiver, amountSc, amountBc); + } + + function sellStableCoins(uint256 amountSc, address receiver, uint256 feeUi, address ui) external nonReentrant { + oracle.updateOracleValues(); + require(stableCoin.balanceOf(msg.sender) >= amountSc, "sellSC: insufficient SC balance"); + require(amountSc <= TX_LIMIT || stableCoin.totalSupply() < THRESHOLD_SUPPLY_SC, "sellSC: tx limit exceeded"); + uint256 scP = scMinPrice(0); + uint256 value = (amountSc * scP) / SC_DECIMAL_SCALING_FACTOR; + uint256 amountBc = deductFees(value, feeUi, ui); + require(amountBc > 0, "sellSC: receiving zero BCs"); + stableCoin.burn(msg.sender, amountSc); + transferEth(receiver, amountBc); + emit SoldStableCoins(msg.sender, receiver, amountSc, amountBc); + } + + function buyReserveCoins(address receiver, uint256 feeUi, address ui) external payable nonReentrant { + oracle.updateOracleValues(); + uint256 scP = scMinPrice(msg.value); + uint256 rcBp = rcBuyingPrice(scP, msg.value); + uint256 amountBc = deductFees(msg.value, feeUi, ui); + require(amountBc <= (TX_LIMIT * scP) / SC_DECIMAL_SCALING_FACTOR || stableCoin.totalSupply() < THRESHOLD_SUPPLY_SC, "buyRC: tx limit exceeded"); + uint256 amountRc = (amountBc * RC_DECIMAL_SCALING_FACTOR) / rcBp; + require(amountRc > 0, "buyRC: receiving zero RCs"); + reserveCoin.mint(receiver, amountRc); + // Reserve ratio check removed in Tefnut + emit BoughtReserveCoins(msg.sender, receiver, amountRc, amountBc); + } + + function sellReserveCoins(uint256 amountRc, address receiver, uint256 feeUi, address ui) external nonReentrant { + oracle.updateOracleValues(); + require(reserveCoin.balanceOf(msg.sender) >= amountRc, "sellRC: insufficient RC balance"); + uint256 scP = scMaxPrice(0); + uint256 value = (amountRc * rcTargetPrice(scP, 0)) / RC_DECIMAL_SCALING_FACTOR; + require(value <= (TX_LIMIT * scP) / SC_DECIMAL_SCALING_FACTOR || stableCoin.totalSupply() < THRESHOLD_SUPPLY_SC, "sellRC: tx limit exceeded"); + uint256 amountBc = deductFees(value, feeUi, ui); + require(amountBc > 0, "sellRC: receiving zero BCs"); + reserveCoin.burn(msg.sender, amountRc); + transferEth(receiver, amountBc); + // Reserve ratio check removed in Tefnut + emit SoldReserveCoins(msg.sender, receiver, amountRc, amountBc); + } + + // sellBothCoins function removed in Tefnut + + // # Auxiliary Functions + + function deductFees(uint256 value, uint256 feeUi, address ui) internal returns (uint256) { + require(ui != address(0), "Invalid UI address"); + require(feeUi + FEE + TREASURY_FEE <= SCALING_FACTOR, "Total fees exceed 100%"); + uint256 f = (value * FEE) / SCALING_FACTOR; + uint256 fUi = (value * feeUi) / SCALING_FACTOR; + uint256 fT = (value * TREASURY_FEE) / SCALING_FACTOR; // Fixed treasury fee (no decay) + transferEth(TREASURY, fT); + transferEth(ui, fUi); + // transferEth(address(this), f); // this happens implicitly, and thus `f` is effectively transferred to the reserve. + return value - f - fUi - fT; // amountBc + } + + // isRatioAboveMin and isRatioBelowMax functions removed in Tefnut + + // # Price Functions: return the price in weis for 1 whole coin. + + function scPrice(uint256 currentPaymentAmount, uint256 scTargetPrice) private view returns (uint256) { + uint256 supplySc = stableCoin.totalSupply(); + return supplySc == 0 + ? scTargetPrice + : Math.min(scTargetPrice, (R(currentPaymentAmount) * SC_DECIMAL_SCALING_FACTOR) / supplySc); + } + + function scMaxPrice(uint256 currentPaymentAmount) public view returns (uint256) { + (uint256 scTargetPrice,) = oracle.readMaxPrice(); + return scPrice(currentPaymentAmount, scTargetPrice); + } + + function scMinPrice(uint256 currentPaymentAmount) public view returns (uint256) { + (uint256 scTargetPrice,) = oracle.readMinPrice(); + return scPrice(currentPaymentAmount, scTargetPrice); + } + + function rcTargetPrice(uint256 currentPaymentAmount) external view returns (uint256) { + return rcTargetPrice(scMaxPrice(currentPaymentAmount), currentPaymentAmount); + } + + function rcTargetPrice(uint256 _scPrice, uint256 currentPaymentAmount) internal view returns (uint256) { + uint256 supplyRc = reserveCoin.totalSupply(); + require(supplyRc != 0, "RC supply is zero"); + require(R(currentPaymentAmount) >= L(_scPrice), "Under-collateralized: reserve < liability"); + return (E(_scPrice, currentPaymentAmount) * RC_DECIMAL_SCALING_FACTOR) / supplyRc; + } + + function rcBuyingPrice(uint256 currentPaymentAmount) external view returns (uint256) { + return rcBuyingPrice(scMaxPrice(currentPaymentAmount), currentPaymentAmount); + } + + function rcBuyingPrice(uint256 _scPrice, uint256 currentPaymentAmount) internal view returns (uint256) { + return reserveCoin.totalSupply() == 0 + ? RC_INITIAL_PRICE + : Math.max(rcTargetPrice(_scPrice, currentPaymentAmount), RC_MIN_PRICE); + } + + function transferEth(address receiver, uint256 amount) internal { + (bool success,) = payable(receiver).call{value: amount}(""); + require(success, "Transfer failed."); + } +} diff --git a/src/mock/MockShuOracleTefnut.sol b/src/mock/MockShuOracleTefnut.sol new file mode 100644 index 0000000..fe876bb --- /dev/null +++ b/src/mock/MockShuOracleTefnut.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: AEL +pragma solidity ^0.8.0; + +import {IOracleShu} from "../IOracleShu.sol"; + +/** + * @title MockShuOracleTefnut + * @notice Mock implementation of IOracleShu for testing DjedTefnut + * @dev Provides controllable price feeds for testing scenarios + */ +contract MockShuOracleTefnut is IOracleShu { + uint256 public maxPrice; + uint256 public minPrice; + uint256 public lastUpdateTimestamp; + + constructor(uint256 _price) { + maxPrice = _price; + minPrice = _price; + lastUpdateTimestamp = block.timestamp; + } + + /// @notice Accept terms of service (no-op for mock) + function acceptTermsOfService() external override { + // No-op for mock + } + + /// @notice Returns the maximum price and timestamp + /// @return price The maximum price in weis per whole stablecoin + /// @return timestamp The timestamp of the last update + function readMaxPrice() external view override returns (uint256 price, uint256 timestamp) { + return (maxPrice, lastUpdateTimestamp); + } + + /// @notice Returns the minimum price and timestamp + /// @return price The minimum price in weis per whole stablecoin + /// @return timestamp The timestamp of the last update + function readMinPrice() external view override returns (uint256 price, uint256 timestamp) { + return (minPrice, lastUpdateTimestamp); + } + + /// @notice Update oracle values (no-op for mock, updates timestamp) + function updateOracleValues() external override { + lastUpdateTimestamp = block.timestamp; + } + + // ============ Test Helper Functions ============ + + /// @notice Set both max and min price to the same value + /// @param _price The new price in weis per whole stablecoin + function setPrice(uint256 _price) external { + maxPrice = _price; + minPrice = _price; + lastUpdateTimestamp = block.timestamp; + } + + /// @notice Set max and min prices separately + /// @param _maxPrice The new maximum price + /// @param _minPrice The new minimum price + function setPrices(uint256 _maxPrice, uint256 _minPrice) external { + require(_maxPrice >= _minPrice, "Max price must be >= min price"); + maxPrice = _maxPrice; + minPrice = _minPrice; + lastUpdateTimestamp = block.timestamp; + } + + /// @notice Increase price by a specified amount + /// @param amount The amount to increase the price by + function increasePrice(uint256 amount) external { + maxPrice += amount; + minPrice += amount; + lastUpdateTimestamp = block.timestamp; + } + + /// @notice Decrease price by a specified amount + /// @param amount The amount to decrease the price by + function decreasePrice(uint256 amount) external { + require(maxPrice >= amount && minPrice >= amount, "Price cannot go negative"); + maxPrice -= amount; + minPrice -= amount; + lastUpdateTimestamp = block.timestamp; + } +} diff --git a/src/test/DjedTefnut.t.sol b/src/test/DjedTefnut.t.sol new file mode 100644 index 0000000..a9f058e --- /dev/null +++ b/src/test/DjedTefnut.t.sol @@ -0,0 +1,481 @@ +// SPDX-License-Identifier: AEL +pragma solidity ^0.8.0; + +import "./utils/Cheatcodes.sol"; +import "./utils/Console.sol"; +import "./utils/Ctest.sol"; +import "../DjedTefnut.sol"; +import "../mock/MockShuOracleTefnut.sol"; + +/** + * @title DjedTefnutTest + * @notice Comprehensive test suite for DjedTefnut contract + * @dev Tests all trading functions, edge cases, and the key Tefnut feature: no reserve ratio restrictions + */ +contract DjedTefnutTest is CTest { + MockShuOracleTefnut private oracle; + DjedTefnut private djed; + CheatCodes private cheats = CheatCodes(HEVM_ADDRESS); + + // ============ Test Constants ============ + + uint256 constant SCALING_FACTOR = 1e24; + uint256 constant INITIAL_BALANCE = 1e18; // 1 ETH + uint256 constant SC_DECIMAL_SCALING_FACTOR = 1e6; + uint256 constant RC_DECIMAL_SCALING_FACTOR = 1e6; + + // Fee parameters + uint256 constant FEE = (15 * SCALING_FACTOR) / 1000; // 1.5% + uint256 constant TREASURY_FEE = 0; // 0% for testing + + // Coin parameters + uint256 constant RC_MIN_PRICE = 1e18; + uint256 constant RC_INITIAL_PRICE = 1e20; + uint256 constant THRESHOLD_SUPPLY_SC = 1e6; + uint256 constant TX_LIMIT = 200e6; // 200 SC + + // Oracle price: 1 USD = 0.5 ETH (in weis per whole SC) + uint256 constant ORACLE_PRICE = 5e17; + + // Test addresses + address constant TREASURY = 0x078D888E40faAe0f32594342c85940AF3949E666; + address account1 = 0x766FCe3d50d795Fe6DcB1020AB58bccddd5C5c77; + address account2 = 0xd109c2fCfc7fE7AE9ccdE37529E50772053Eb7EE; + address UI_ADDRESS = 0x3EA53fA26b41885cB9149B62f0b7c0BAf76C78D4; + + // ============ Events (for testing emission) ============ + + event BoughtStableCoins(address indexed buyer, address indexed receiver, uint256 amountSc, uint256 amountBc); + event SoldStableCoins(address indexed seller, address indexed receiver, uint256 amountSc, uint256 amountBc); + event BoughtReserveCoins(address indexed buyer, address indexed receiver, uint256 amountRc, uint256 amountBc); + event SoldReserveCoins(address indexed seller, address indexed receiver, uint256 amountRc, uint256 amountBc); + + // ============ Setup ============ + + function setUp() public { + // Deploy mock oracle + oracle = new MockShuOracleTefnut(ORACLE_PRICE); + + // Deploy DjedTefnut with initial balance + djed = (new DjedTefnut){value: INITIAL_BALANCE}( + address(oracle), + SCALING_FACTOR, + TREASURY, + TREASURY_FEE, + FEE, + THRESHOLD_SUPPLY_SC, + RC_MIN_PRICE, + RC_INITIAL_PRICE, + TX_LIMIT + ); + + // Fund test accounts + cheats.deal(account1, 100 ether); + cheats.deal(account2, 100 ether); + + // Verify deployment + assertTrue(address(djed) != address(0), "DjedTefnut not deployed"); + assertEq(address(djed).balance, INITIAL_BALANCE, "Initial balance mismatch"); + } + + // ============ Helper Functions ============ + + function R() internal view returns (uint256) { + return address(djed).balance; + } + + function calculateExpectedFee(uint256 amount) internal pure returns (uint256) { + return (amount * FEE) / SCALING_FACTOR; + } + + // ============ Basic State Tests ============ + + function testInitialState() public { + assertEq(R(), INITIAL_BALANCE, "Initial reserve mismatch"); + assertEq(djed.stableCoin().totalSupply(), 0, "Initial SC supply should be 0"); + assertEq(djed.reserveCoin().totalSupply(), 0, "Initial RC supply should be 0"); + assertEq(address(djed.oracle()), address(oracle), "Oracle address mismatch"); + assertEq(djed.TREASURY(), TREASURY, "Treasury address mismatch"); + } + + // ============ Buy StableCoins Tests ============ + + function testBuyStableCoins() public { + uint256 buyAmount = 1e18; // 1 ETH + uint256 initialBalance = account1.balance; + + cheats.prank(account1); + djed.buyStableCoins{value: buyAmount}(account1, 0, UI_ADDRESS); + + // Verify SC balance increased + uint256 scBalance = djed.stableCoin().balanceOf(account1); + assertTrue(scBalance > 0, "Should receive SC"); + + // Verify total supply + assertEq(djed.stableCoin().totalSupply(), scBalance, "Total supply should match balance"); + + // Verify reserve increased + assertEq(R(), INITIAL_BALANCE + buyAmount, "Reserve should increase"); + + // Verify account1 ETH decreased + assertEq(account1.balance, initialBalance - buyAmount, "Account balance should decrease"); + } + + function testBuyStableCoinsToReceiver() public { + uint256 buyAmount = 1e18; + + cheats.prank(account1); + djed.buyStableCoins{value: buyAmount}(account2, 0, UI_ADDRESS); + + // Verify receiver got the SC + assertTrue(djed.stableCoin().balanceOf(account2) > 0, "Receiver should get SC"); + assertEq(djed.stableCoin().balanceOf(account1), 0, "Buyer should not get SC"); + } + + // ============ Sell StableCoins Tests ============ + + function testSellStableCoins() public { + // First buy some SC + cheats.prank(account1); + djed.buyStableCoins{value: 1e18}(account1, 0, UI_ADDRESS); + + uint256 scBalance = djed.stableCoin().balanceOf(account1); + uint256 ethBalanceBefore = account1.balance; + uint256 reserveBefore = R(); + + // Sell all SC + cheats.prank(account1); + djed.sellStableCoins(scBalance, account1, 0, UI_ADDRESS); + + // Verify SC burned + assertEq(djed.stableCoin().balanceOf(account1), 0, "SC should be burned"); + assertEq(djed.stableCoin().totalSupply(), 0, "Total SC supply should be 0"); + + // Verify ETH received (less fees) + assertTrue(account1.balance > ethBalanceBefore, "Should receive ETH"); + + // Verify reserve decreased + assertTrue(R() < reserveBefore, "Reserve should decrease"); + } + + function testSellStableCoinsToReceiver() public { + // Buy SC for account1 + cheats.prank(account1); + djed.buyStableCoins{value: 1e18}(account1, 0, UI_ADDRESS); + + uint256 scBalance = djed.stableCoin().balanceOf(account1); + uint256 account2BalanceBefore = account2.balance; + + // Sell SC and send ETH to account2 + cheats.prank(account1); + djed.sellStableCoins(scBalance, account2, 0, UI_ADDRESS); + + // Verify account2 received ETH + assertTrue(account2.balance > account2BalanceBefore, "Receiver should get ETH"); + } + + // ============ Buy ReserveCoins Tests ============ + + function testBuyReserveCoins() public { + uint256 buyAmount = 1e18; + + cheats.prank(account1); + djed.buyReserveCoins{value: buyAmount}(account1, 0, UI_ADDRESS); + + // Verify RC balance + uint256 rcBalance = djed.reserveCoin().balanceOf(account1); + assertTrue(rcBalance > 0, "Should receive RC"); + + // Verify total supply + assertEq(djed.reserveCoin().totalSupply(), rcBalance, "Total supply should match"); + + // Verify reserve increased + assertEq(R(), INITIAL_BALANCE + buyAmount, "Reserve should increase"); + } + + function testBuyReserveCoinsToReceiver() public { + cheats.prank(account1); + djed.buyReserveCoins{value: 1e18}(account2, 0, UI_ADDRESS); + + assertTrue(djed.reserveCoin().balanceOf(account2) > 0, "Receiver should get RC"); + assertEq(djed.reserveCoin().balanceOf(account1), 0, "Buyer should not get RC"); + } + + // ============ Sell ReserveCoins Tests ============ + + function testSellReserveCoins() public { + // First buy RC + cheats.prank(account1); + djed.buyReserveCoins{value: 10e18}(account1, 0, UI_ADDRESS); + + // Need to buy some SC to establish equity + cheats.prank(account2); + djed.buyStableCoins{value: 1e18}(account2, 0, UI_ADDRESS); + + uint256 rcBalance = djed.reserveCoin().balanceOf(account1); + uint256 ethBalanceBefore = account1.balance; + + // Sell RC + cheats.prank(account1); + djed.sellReserveCoins(rcBalance, account1, 0, UI_ADDRESS); + + // Verify RC burned + assertEq(djed.reserveCoin().balanceOf(account1), 0, "RC should be burned"); + + // Verify ETH received + assertTrue(account1.balance > ethBalanceBefore, "Should receive ETH"); + } + + // ============ CRITICAL: No Reserve Ratio Check Tests ============ + + /** + * @notice CRITICAL TEST: Proves that buyStableCoins succeeds even with 0 Reserve Coins + * @dev In original Djed, buying SC when ratio < min would revert. In Tefnut, it should succeed. + */ + function testNoReserveRatioCheck_BuyScWithZeroRc() public { + // System state: 0 RC, 0 SC + assertEq(djed.reserveCoin().totalSupply(), 0, "RC supply should be 0"); + assertEq(djed.stableCoin().totalSupply(), 0, "SC supply should be 0"); + + // In original Djed, this would fail with "buySC: ratio below min" + // In Tefnut, it should succeed + cheats.prank(account1); + djed.buyStableCoins{value: 1e18}(account1, 0, UI_ADDRESS); + + // Verify success + assertTrue(djed.stableCoin().balanceOf(account1) > 0, "Should buy SC without RC"); + assertEq(djed.reserveCoin().totalSupply(), 0, "RC should still be 0"); + } + + /** + * @notice Test that buying large amounts of SC works without ratio restrictions + */ + function testNoReserveRatioCheck_LargeSCPurchase() public { + // Buy a large amount of SC without any RC in system + cheats.prank(account1); + djed.buyStableCoins{value: 50e18}(account1, 0, UI_ADDRESS); // 50 ETH + + assertTrue(djed.stableCoin().balanceOf(account1) > 0, "Large SC purchase should succeed"); + } + + /** + * @notice Test that selling RC works without ratio restrictions + */ + function testNoReserveRatioCheck_SellAllRc() public { + // Setup: Buy RC then SC + cheats.prank(account1); + djed.buyReserveCoins{value: 10e18}(account1, 0, UI_ADDRESS); + + cheats.prank(account2); + djed.buyStableCoins{value: 5e18}(account2, 0, UI_ADDRESS); + + uint256 rcBalance = djed.reserveCoin().balanceOf(account1); + + // In original Djed, selling all RC when SC exists would fail with "sellRC: ratio below min" + // In Tefnut, it should succeed + cheats.prank(account1); + djed.sellReserveCoins(rcBalance, account1, 0, UI_ADDRESS); + + assertEq(djed.reserveCoin().balanceOf(account1), 0, "Should sell all RC"); + assertTrue(djed.stableCoin().totalSupply() > 0, "SC should still exist"); + } + + /** + * @notice Test buying RC works without max ratio restriction + */ + function testNoReserveRatioCheck_BuyRcAboveMaxRatio() public { + // Buy SC first + cheats.prank(account1); + djed.buyStableCoins{value: 1e17}(account1, 0, UI_ADDRESS); + + // In original Djed with high reserve, buying more RC would fail with "buyRC: ratio above max" + // In Tefnut, there's no max ratio check + cheats.prank(account2); + djed.buyReserveCoins{value: 50e18}(account2, 0, UI_ADDRESS); + + assertTrue(djed.reserveCoin().balanceOf(account2) > 0, "Should buy RC without ratio limit"); + } + + // ============ Fee Tests ============ + + function testFeesDeducted() public { + uint256 treasuryBalanceBefore = TREASURY.balance; + uint256 buyAmount = 10e18; + + cheats.prank(account1); + djed.buyStableCoins{value: buyAmount}(account1, 0, UI_ADDRESS); + + // Protocol fee stays in reserve, UI fee (0) goes to UI + // Treasury fee (0 in test) goes to treasury + assertEq(TREASURY.balance, treasuryBalanceBefore, "Treasury should receive no fee (0%)"); + } + + function testUIFeeDeducted() public { + uint256 uiBalanceBefore = UI_ADDRESS.balance; + uint256 uiFee = 1e21; // 0.1% + uint256 buyAmount = 10e18; + + cheats.prank(account1); + djed.buyStableCoins{value: buyAmount}(account1, uiFee, UI_ADDRESS); + + // UI should receive fee + uint256 expectedUIFee = (buyAmount * uiFee) / SCALING_FACTOR; + assertEq(UI_ADDRESS.balance - uiBalanceBefore, expectedUIFee, "UI should receive fee"); + } + + // ============ Revert Tests ============ + + function testRevertBuyZeroSc() public { + // Sending 0 ETH should revert with "buySC: receiving zero SCs" + cheats.prank(account1); + cheats.expectRevert("buySC: receiving zero SCs"); + djed.buyStableCoins{value: 0}(account1, 0, UI_ADDRESS); + } + + function testRevertSellMoreScThanBalance() public { + cheats.prank(account1); + djed.buyStableCoins{value: 1e18}(account1, 0, UI_ADDRESS); + + uint256 scBalance = djed.stableCoin().balanceOf(account1); + + // Try to sell more than balance + cheats.prank(account1); + cheats.expectRevert("sellSC: insufficient SC balance"); + djed.sellStableCoins(scBalance + 1, account1, 0, UI_ADDRESS); + } + + function testRevertSellMoreRcThanBalance() public { + cheats.prank(account1); + djed.buyReserveCoins{value: 1e18}(account1, 0, UI_ADDRESS); + + uint256 rcBalance = djed.reserveCoin().balanceOf(account1); + + // Try to sell more than balance + cheats.prank(account1); + cheats.expectRevert("sellRC: insufficient RC balance"); + djed.sellReserveCoins(rcBalance + 1, account1, 0, UI_ADDRESS); + } + + function testRevertInvalidUIAddress() public { + cheats.prank(account1); + cheats.expectRevert("Invalid UI address"); + djed.buyStableCoins{value: 1e18}(account1, 0, address(0)); + } + + function testRevertTotalFeesExceed100Percent() public { + uint256 excessiveFee = SCALING_FACTOR; // 100% + cheats.prank(account1); + cheats.expectRevert("Total fees exceed 100%"); + djed.buyStableCoins{value: 1e18}(account1, excessiveFee, UI_ADDRESS); + } + + // ============ TX Limit Tests ============ + + function testTxLimitRespectedAboveThreshold() public { + // First get above threshold + cheats.prank(account1); + djed.buyStableCoins{value: 10e18}(account1, 0, UI_ADDRESS); + + // Verify we're above threshold + assertTrue(djed.stableCoin().totalSupply() >= THRESHOLD_SUPPLY_SC, "Should be above threshold"); + + // Calculate amount that exceeds TX_LIMIT in SC terms + // TX_LIMIT is in SC units (200e6 = 200 SC) + // At price 0.5 ETH/SC, 200 SC = 100 ETH worth + // But after fees, we need more ETH to get 200+ SC + + // This should succeed if under TX_LIMIT + cheats.prank(account2); + djed.buyStableCoins{value: 1e17}(account2, 0, UI_ADDRESS); // Small amount, should work + + assertTrue(djed.stableCoin().balanceOf(account2) > 0, "Should succeed under TX limit"); + } + + // ============ Price Function Tests ============ + + function testScMaxPrice() public { + uint256 scMaxPrice = djed.scMaxPrice(0); + // Price should be oracle price when no SC exists or reserve is high + assertTrue(scMaxPrice > 0, "SC max price should be > 0"); + } + + function testScMinPrice() public { + uint256 scMinPrice = djed.scMinPrice(0); + assertTrue(scMinPrice > 0, "SC min price should be > 0"); + } + + function testRcBuyingPrice() public { + uint256 rcBuyingPrice = djed.rcBuyingPrice(0); + // When RC supply is 0, should return initial price + assertEq(rcBuyingPrice, RC_INITIAL_PRICE, "RC buying price should be initial price when supply is 0"); + } + + // ============ Oracle Integration Tests ============ + + function testOraclePriceChange() public { + // Get initial SC price + uint256 scPriceBefore = djed.scMaxPrice(0); + + // Change oracle price + oracle.setPrice(ORACLE_PRICE * 2); // Double the price + + // Price should change + uint256 scPriceAfter = djed.scMaxPrice(0); + assertTrue(scPriceAfter != scPriceBefore || djed.stableCoin().totalSupply() > 0, + "Price should reflect oracle change"); + } + + // ============ Multiple Operations Tests ============ + + function testMultipleBuySellCycles() public { + // Multiple buy/sell cycles + for (uint256 i = 0; i < 3; i++) { + cheats.prank(account1); + djed.buyStableCoins{value: 1e18}(account1, 0, UI_ADDRESS); + + uint256 scBalance = djed.stableCoin().balanceOf(account1); + + cheats.prank(account1); + djed.sellStableCoins(scBalance / 2, account1, 0, UI_ADDRESS); + } + + assertTrue(djed.stableCoin().balanceOf(account1) > 0, "Should have SC after cycles"); + } + + function testMixedScRcOperations() public { + // Buy SC + cheats.prank(account1); + djed.buyStableCoins{value: 2e18}(account1, 0, UI_ADDRESS); + + // Buy RC + cheats.prank(account1); + djed.buyReserveCoins{value: 5e18}(account1, 0, UI_ADDRESS); + + // Sell some SC + uint256 scBalance = djed.stableCoin().balanceOf(account1); + cheats.prank(account1); + djed.sellStableCoins(scBalance / 2, account1, 0, UI_ADDRESS); + + // Sell some RC + uint256 rcBalance = djed.reserveCoin().balanceOf(account1); + cheats.prank(account1); + djed.sellReserveCoins(rcBalance / 2, account1, 0, UI_ADDRESS); + + assertTrue(djed.stableCoin().balanceOf(account1) > 0, "Should have SC remaining"); + assertTrue(djed.reserveCoin().balanceOf(account1) > 0, "Should have RC remaining"); + } + + // ============ Edge Case Tests ============ + + function testRatioCalculation() public { + // Ratio function should work even with 0 liabilities + uint256 ratio = djed.ratio(); + // With 0 SC, ratio should be max uint256 + assertEq(ratio, type(uint256).max, "Ratio should be max with 0 liabilities"); + } + + function testReserveCalculation() public { + uint256 reserve = djed.R(0); + assertEq(reserve, INITIAL_BALANCE, "Reserve calculation should match balance"); + } +}