diff --git a/.github/workflows/branch-consistency.yml b/.github/workflows/branch-consistency.yml new file mode 100644 index 0000000..bb45be7 --- /dev/null +++ b/.github/workflows/branch-consistency.yml @@ -0,0 +1,56 @@ +name: Branch Consistency Check + +on: + pull_request: + branches: + - main + - minimal + +jobs: + consistency-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # Fetch all branches and history + + # Check consistency of critical files + - name: Compare Critical Files + run: | + # Ensure both branches exist + git branch -r + + # List of files to compare + critical_files=( + "foundry.toml" + ".gitignore" + "LICENSE" + "sample.env" + ) + + # Function to compare files + compare_files() { + local file="$1" + echo "Comparing $file" + + # Get file contents from both branches + main_content=$(git show main:"$file" 2>/dev/null || echo "") + minimal_content=$(git show minimal:"$file" 2>/dev/null || echo "") + + # Compare contents + if [ "$main_content" != "$minimal_content" ]; then + echo "Inconsistency detected in $file" + echo "Main branch content:" + echo "$main_content" + echo "Minimal branch content:" + echo "$minimal_content" + exit 1 + fi + } + + # Compare each critical file + for file in "${critical_files[@]}"; do + compare_files "$file" + done + + echo "All critical files are consistent between branches." diff --git a/.github/workflows/prevent-direct-merges.yml b/.github/workflows/prevent-direct-merges.yml new file mode 100644 index 0000000..a6025fe --- /dev/null +++ b/.github/workflows/prevent-direct-merges.yml @@ -0,0 +1,40 @@ +# .github/workflows/prevent-direct-merges.yml +name: Prevent Direct Merges + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + check-branches: + runs-on: ubuntu-latest + steps: + - name: Check for direct merges between main and minimal + run: | + BASE="${{ github.event.pull_request.base.ref }}" + HEAD="${{ github.event.pull_request.head.ref }}" + + # Block direct merges between main and minimal + if [[ "$BASE" == "main" && "$HEAD" == "minimal" ]]; then + echo "::error::Direct merges from minimal to main are not allowed. Please use the sync-to-main script instead." + exit 1 + fi + + if [[ "$BASE" == "minimal" && "$HEAD" == "main" ]]; then + echo "::error::Direct merges from main to minimal are not allowed. Please use the sync-to-minimal script instead." + exit 1 + fi + + # Allow sync branches explicitly + if [[ "$BASE" == "minimal" && "$HEAD" == "sync-branch" ]]; then + echo "Sync branch to minimal is allowed" + exit 0 + fi + + if [[ "$BASE" == "main" && "$HEAD" == "sync-branch-reverse" ]]; then + echo "Sync branch to main is allowed" + exit 0 + fi + + # All other PRs are allowed + echo "Regular feature branch PR - allowed" diff --git a/.vscode/settings.json b/.vscode/settings.json index 76400e1..2b22c2b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "solidity.formatter": "forge", "solidity.defaultCompiler": "localFile", + "solidity.monoRepoSupport": true, "solidity.packageDefaultDependenciesDirectory": "lib", "solidity.packageDefaultDependenciesContractsDirectory": "src" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 01e6aa2..479e993 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,11 @@ +# Contributing + +The goal of this repo is to provide not only an optimized configuration for experienced Solidity developers but to also serve as a starting point for developers who are new to Foundry or Solidity. We're looking in particular for the following Contributions to the `main` branch: + +- Reusable utility and library contracts that add value to LazerForge as a template +- Expanded [tutorials](/lazerTutorial/README.md) on smart contract development and testing in foundry +- Helpful test examples + ## Branch Structure and Guidelines ### Branch Overview @@ -21,7 +29,7 @@ This repository maintains two primary branches with distinct purposes: - Direct merges between these branches are prohibited - Branch protection rules are in place to prevent accidental merging -### Contributing +### Config Contributions - If you want to contribute changes that should apply to both branches: 1. Make changes in the `main` branch first @@ -45,6 +53,43 @@ This repository maintains two primary branches with distinct purposes: git commit -m "Selectively update minimal branch" ``` -### Questions or Clarifications +## Syncing Changes Between Branches + +We use a dedicated synchronization workflow to maintain consistency between branches while respecting their different purposes. + +**To sync changes from `main` to `minimal`:** + +1. Ensure your changes are merged to `main` first +2. Run the sync script: + +```bash +./tools/sync-to-minimal.sh +``` + +3. The script will: + + - Update a dedicated sync branch with the latest from `main` + - Open a PR creation page targeting `minimal` + +4. During PR review: + - Verify only appropriate files are included + - Exclude tutorial content or other files not needed in `minimal` + +**For emergency fixes in `minimal`:** + +If you need to make a hotfix directly to `minimal` and then sync back to `main`: + +1. Make and merge your changes to `minimal` +2. Run: + +```bash +./tools/sync-to-main.sh +``` + +3. Complete a PR to sync these changes back to `main` + +> Always make feature development PRs to `main` first, and use the sync scripts rather than manually cherry-picking to maintain consistency. + +## Questions or Clarifications If you have any questions about the branch structure or contribution process, please open an issue in the repository. diff --git a/README.md b/README.md index c9a02b0..5d719ca 100644 --- a/README.md +++ b/README.md @@ -56,52 +56,7 @@ LazerForge maintains two primary branches to cater to different needs: For detailed info on branches and contribution, check out the [Contributing Guide](CONTRIBUTING.md). -## Syncing Changes Between Branches - -We use a dedicated synchronization workflow to maintain consistency between branches while respecting their different purposes. - -**To sync changes from `main` to `minimal`:** - -1. Ensure your changes are merged to `main` first -2. Run the sync script: - -```bash -./tools/sync-to-minimal.sh -``` - -3. The script will: - - - Update a dedicated sync branch with the latest from `main` - - Open a PR creation page targeting `minimal` - -4. During PR review: - - Verify only appropriate files are included - - Exclude tutorial content or other files not needed in `minimal` - -**For emergency fixes in `minimal`:** - -If you need to make a hotfix directly to `minimal` and then sync back to `main`: - -1. Make and merge your changes to `minimal` -2. Run: - -```bash -./tools/sync-to-main.sh -``` - -3. Complete a PR to sync these changes back to `main` - -> Always make feature development PRs to `main` first, and use the sync scripts rather than manually cherry-picking to maintain consistency. - -## Documentation - -For detailed guides on various aspects of LazerForge, check out: - -- [Setup Guide](lazerTutorial/setup.md) - Initial setup and configuration -- [Testing Guide](lazerTutorial/testing.md) - Writing and running tests -- [Deployment Guide](lazerTutorial/deployment.md) - Deploying contracts -- [Network Configuration](lazerTutorial/networks.md) - Setting up networks and RPC endpoints -- [Profiles](lazerTutorial/profiles.md) - Using different Foundry profiles +> See [sync script usage](CONTRIBUTING.md#syncing-changes-between-branches) for automating branch sync. ## Reinitialize Submodules @@ -134,3 +89,13 @@ To generate reports, run ```bash ./coverage-report ``` + +## Documentation + +For detailed guides on various aspects of LazerForge, check out: + +- [Setup Guide](lazerTutorial/setup.md) - Initial setup and configuration +- [Testing Guide](lazerTutorial/testing.md) - Writing and running tests +- [Deployment Guide](lazerTutorial/deployment.md) - Deploying contracts +- [Network Configuration](lazerTutorial/networks.md) - Setting up networks and RPC endpoints +- [Profiles](lazerTutorial/profiles.md) - Using different Foundry profiles diff --git a/foundry.toml b/foundry.toml index 4a6314f..05748ba 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,7 +2,6 @@ # update solc values if needed for compatability with older contracts auto_detect_solc = false solc = '0.8.28' -evm_version = "prague" src = "src" out = "out" libs = ["lib"] @@ -10,8 +9,19 @@ remappings = [ 'forge-std/=lib/forge-std/src', 'solady/=lib/solady/src/', 'solady-test/=lib/solady/test/', + '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/', 'openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/', 'openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/', + '@uniswap/v2-core/=lib/uniswap/v2-core/contracts/', + '@uniswap/v3-core/=lib/uniswap/v3-core/contracts/', + '@uniswap/v3-periphery/=lib/uniswap/v3-periphery/contracts/', + '@uniswap/v4-core/=lib/uniswap/v4-core/src/', + '@uniswap/v4-periphery/=lib/uniswap/v4-periphery/src/', + 'uniswap-v2-core/=lib/uniswap/v2-core/contracts/', + 'uniswap-v3-core/=lib/uniswap/v3-core/contracts/', + 'uniswap-v3-periphery/=lib/uniswap/v3-periphery/contracts/', + 'uniswap-v4-core/=lib/uniswap/v4-core/src/', + 'uniswap-v4-periphery/=lib/uniswap/v4-periphery/src/' ] # ensure that block number + timestamp are realistic when running tests block_number = 17722462 @@ -31,7 +41,6 @@ sepolia = "${SEPOLIA_RPC_URL}" mainnet = "${ETHEREUM_RPC_URL}" base = "${BASE_RPC_URL}" base_sepolia = "${BASE_SEPOLIA_RPC_URL}" -anvil = "http://127.0.0.1:8545" [etherscan] ethereum = { key = "${ETHERSCAN_API_KEY}"} @@ -66,4 +75,36 @@ optimizer = true optimizer_runs = 1000000 via_ir = true +# Local anvil profile +[profile.anvil] +evm_version = 'prague' +optimizer = true +optimizer_runs = 500_000 + +# paste an anvil-generated account here +# sender = "0x..." + +# Testing knobs for local chain realism and speed +[profile.anvil.fuzz] +runs = 64 + +# Broadcast configuration - write to ./broadcast by default on scripts +[profile.anvil.broadcast] +retries = 10 +retries_delay_ms = 500 + +# If you use forked anvil often, set a default fork here (optional) +# [profile.anvil.rpc_storage_caching] +# chains = ["anvil"] + +# Permissions for scripts that need artifacts/IO locally (inherits your default, add more if needed) +[profile.anvil.fs_permissions] +# carry over the via_ir read permission +# additional example: +# { access = "read-write", path = "./broadcast" } + +# Gas reporting locally (optional) +[profile.anvil.gas_reports] +# contracts = ["MyContract","AnotherContract"] + # See more config options https://github.com/foundry-rs/foundry/tree/master/config diff --git a/src/BalanceManager.sol b/src/BalanceManager.sol new file mode 100644 index 0000000..9128b1d --- /dev/null +++ b/src/BalanceManager.sol @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IERC20} from "openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Ownable} from "openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "openzeppelin/contracts/security/ReentrancyGuard.sol"; + +/** + * @title Balance Manager + * @author [Jon Bray](https://warpcast.com/jonbray.eth) + * @notice Only admin can update user balance mappings. + * @notice Users can claim their balance of any token at any time. + */ +contract BalanceManager is Ownable, ReentrancyGuard { + mapping(address => bool) public admins; + mapping(address => mapping(address => uint256)) public balances; + mapping(address => uint256) public totalBalances; + mapping(address => address[]) public walletTokens; + mapping(address => address[]) public tokenWallets; + address[] public allTokens; + address[] public allAdmins; + + event AdminAdded(address indexed admin); + event AdminRemoved(address indexed admin); + event BalanceSet(address indexed user, address indexed token, uint256 balance); + event BalanceIncreased(address indexed user, address indexed token, uint256 amount); + event BalanceReduced(address indexed user, address indexed token, uint256 amount); + event BalanceClaimed(address indexed user, address indexed token, uint256 amount); + event Funded(address indexed token, uint256 amount); + event TokensWithdrawn(address indexed token, uint256 amount, address indexed to); + + error ContractCannotBeTheUser(); + + modifier onlyAdmin() { + require(admins[msg.sender], "Caller is not an admin"); + _; + } + + modifier notContract(address user) { + _notContract(user); + _; + } + + function _notContract(address user) internal view { + if (user == address(this)) revert ContractCannotBeTheUser(); + } + + constructor(address initialOwner) Ownable() {} + + function addAdmin(address admin) external onlyOwner { + admins[admin] = true; + allAdmins.push(admin); + emit AdminAdded(admin); + } + + function removeAdmin(address admin) external onlyOwner { + admins[admin] = false; + emit AdminRemoved(admin); + } + + /** + * @dev Sets the balance of `user` for `token` + * @dev only admin can set balance + * @param amount The amount to set + */ + function setBalance(address user, address token, uint256 amount) external onlyAdmin notContract(user) { + require(user != address(0), "Invalid user address"); + require(token != address(0), "Invalid token address"); + + uint256 currentBalance = balances[user][token]; + if (currentBalance == 0 && amount > 0) { + walletTokens[user].push(token); + tokenWallets[token].push(user); + if (totalBalances[token] == 0) { + allTokens.push(token); + } + } + + if (amount > currentBalance) { + totalBalances[token] += (amount - currentBalance); + } else { + totalBalances[token] -= (currentBalance - amount); + } + + balances[user][token] = amount; + emit BalanceSet(user, token, amount); + } + + function increaseBalance(address user, address token, uint256 amount) external onlyAdmin notContract(user) { + require(user != address(0), "Invalid user address"); + require(token != address(0), "Invalid token address"); + + if (balances[user][token] == 0 && amount > 0) { + walletTokens[user].push(token); + tokenWallets[token].push(user); + if (totalBalances[token] == 0) { + allTokens.push(token); + } + } + + balances[user][token] += amount; + totalBalances[token] += amount; + emit BalanceIncreased(user, token, amount); + } + + function reduceBalance(address user, address token, uint256 amount) external onlyAdmin notContract(user) { + require(user != address(0), "Invalid user address"); + require(token != address(0), "Invalid token address"); + require(balances[user][token] >= amount, "Insufficient balance"); + + balances[user][token] -= amount; + totalBalances[token] -= amount; + emit BalanceReduced(user, token, amount); + } + + /** + * @dev allow any user to fund the contract + * @dev balance must still be set by admin + */ + function fund(address token, uint256 amount) external { + require(token != address(0), "Invalid token address"); + require(amount > 0, "Amount must be greater than zero"); + if (totalBalances[token] == 0) { + allTokens.push(token); + } + IERC20(token).transferFrom(msg.sender, address(this), amount); + emit Funded(token, amount); + } + + /** + * @dev allows a user to claim their balance of a certain token + * @param token token to claim balance of + */ + function claim(address token) public notContract(msg.sender) nonReentrant { + require(token != address(0), "Invalid token address"); + uint256 balance = balances[msg.sender][token]; + require(balance > 0, "No balance available"); + + balances[msg.sender][token] = 0; + totalBalances[token] -= balance; + emit BalanceClaimed(msg.sender, token, balance); + IERC20(token).transfer(msg.sender, balance); + } + + /** + * @dev allows a user to claim their balance of all tokens + */ + function claimAll() external notContract(msg.sender) nonReentrant { + uint256 length = walletTokens[msg.sender].length; + require(length > 0, "No balances available to claim"); + + for (uint256 i = 0; i < length; i++) { + address token = walletTokens[msg.sender][i]; + uint256 balance = balances[msg.sender][token]; + if (balance > 0) { + balances[msg.sender][token] = 0; + totalBalances[token] -= balance; + emit BalanceClaimed(msg.sender, token, balance); + IERC20(token).transfer(msg.sender, balance); + } + } + } + + /** + * @dev allows admin to withdraw excess tokens + * @dev only tokens not assigned to a balance can be withdrawn + * @param token address of target token + * @param amount amount of token to withdraw + * @param to address of recipient account + */ + function withdrawExcessTokens(address token, uint256 amount, address to) external onlyAdmin { + require(token != address(0), "Invalid token address"); + require(to != address(0), "Invalid recipient address"); + + uint256 availableAmount = IERC20(token).balanceOf(address(this)) - totalBalances[token]; + require(amount <= availableAmount, "Insufficient excess token balance"); + + IERC20(token).transfer(to, amount); + emit TokensWithdrawn(token, amount, to); + } + + /** + * Getter Methods + */ + function getAllAdmins() external view returns (address[] memory) { + return allAdmins; + } + + function isAdmin(address account) external view returns (bool) { + return admins[account]; + } + + // get balance for a specific (wallet, token) + function getBalance(address wallet, address token) external view returns (uint256) { + return balances[wallet][token]; + } + + // get all [token, balance] for a specific wallet + function getBalancesForWallet(address wallet) external view returns (address[] memory, uint256[] memory) { + uint256 length = walletTokens[wallet].length; + address[] memory tokens = new address[](length); + uint256[] memory balanceValues = new uint256[](length); + for (uint256 i = 0; i < length; i++) { + tokens[i] = walletTokens[wallet][i]; + balanceValues[i] = balances[wallet][tokens[i]]; + } + return (tokens, balanceValues); + } + + // get all [wallet, balance] for a specific token + function getBalancesForToken(address token) external view returns (address[] memory, uint256[] memory) { + uint256 length = tokenWallets[token].length; + address[] memory wallets = new address[](length); + uint256[] memory balanceValues = new uint256[](length); + for (uint256 i = 0; i < length; i++) { + wallets[i] = tokenWallets[token][i]; + balanceValues[i] = balances[wallets[i]][token]; + } + return (wallets, balanceValues); + } + + // get all balances of all tokens + function getAllTotalBalances() external view returns (address[] memory, uint256[] memory) { + uint256 length = allTokens.length; + address[] memory tokens = new address[](length); + uint256[] memory balanceValues = new uint256[](length); + for (uint256 i = 0; i < length; i++) { + tokens[i] = allTokens[i]; + balanceValues[i] = totalBalances[tokens[i]]; + } + return (tokens, balanceValues); + } + + // get all tokens associated with a user + function getTokensForUser(address user) external view returns (address[] memory) { + return walletTokens[user]; + } + + // get all users associated with a token + function getUsersForToken(address token) external view returns (address[] memory) { + return tokenWallets[token]; + } +} diff --git a/src/InflationToken.sol b/src/InflationToken.sol index 09cac87..231a47d 100644 --- a/src/InflationToken.sol +++ b/src/InflationToken.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity ^0.8.24; -import "openzeppelin-contracts/token/ERC20/ERC20.sol"; -import "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; -import "openzeppelin-contracts/token/ERC20/extensions/ERC20Burnable.sol"; -import "openzeppelin-contracts/access/Ownable.sol"; +import {IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; using SafeERC20 for IERC20; diff --git a/src/utils/CREATE3.sol b/src/utils/CREATE3.sol index 2ffa292..b41dac2 100644 --- a/src/utils/CREATE3.sol +++ b/src/utils/CREATE3.sol @@ -83,7 +83,9 @@ library CREATE3 { deployed := keccak256(0x1e, 0x17) if iszero( mul( // The arguments of `mul` are evaluated last to first. - extcodesize(deployed), call(gas(), proxy, value, add(initCode, 0x20), mload(initCode), 0x00, 0x00)) + extcodesize(deployed), + call(gas(), proxy, value, add(initCode, 0x20), mload(initCode), 0x00, 0x00) + ) ) { mstore(0x00, 0x30116425) // `DeploymentFailed()`. revert(0x1c, 0x04) diff --git a/src/utils/Rescue.sol b/src/utils/Rescue.sol new file mode 100644 index 0000000..a584463 --- /dev/null +++ b/src/utils/Rescue.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +// minimal ERC20 interface to support transfer +interface IERC20 { + function transfer(address recipient, uint256 amount) external returns (bool); +} + +/** + * @title Rescue + * @author Jon Bray + * @notice This contract is used to withdraw funds that have been sent to an + * address on the wrong network by deploying deterministically. + * See {../script/Rescue.s.sol} for more info. + */ +contract Rescue { + error Failed(); + error NotOwner(); + + event ETHWithdrawn(address indexed recipient, uint256 amount); + event ERC20Withdrawn(address indexed token, address indexed recipient, uint256 amount); + + address public owner; + + constructor() { + owner = msg.sender; + } + + modifier onlyOwner() { + _onlyOwner(); + _; + } + + function _onlyOwner() internal view { + if (msg.sender != owner) revert NotOwner(); + } + + receive() external payable {} + + function withdrawAllETH(address payable recipient) external onlyOwner { + uint256 balance = address(this).balance; + (bool sent,) = recipient.call{value: balance}(""); + if (!sent) revert Failed(); + emit ETHWithdrawn(recipient, balance); + } + + function withdrawETH(address payable recipient, uint256 amount) external onlyOwner { + require(amount <= address(this).balance, "Insufficient balance"); + (bool sent,) = recipient.call{value: amount}(""); + if (!sent) revert Failed(); + emit ETHWithdrawn(recipient, amount); + } + + function withdrawERC20(address token, address recipient, uint256 amount) external onlyOwner { + bool sent = IERC20(token).transfer(recipient, amount); + if (!sent) revert Failed(); + emit ERC20Withdrawn(token, recipient, amount); + } +} diff --git a/test/BalanceManager.t.sol b/test/BalanceManager.t.sol new file mode 100644 index 0000000..495102c --- /dev/null +++ b/test/BalanceManager.t.sol @@ -0,0 +1,534 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/BalanceManager.sol"; +import {ERC20} from "openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * @title MockERC20 + * @dev Minimal ERC20 implementation. + */ +contract MockERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) { + _mint(msg.sender, 1000000 * 10 ** 18); // Mint initial supply to the deployer + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +/** + * @title BalanceManager Test + * @dev Test contract for BalanceManager contract. This test suite covers an + * extensive amount all the core functionality of the contract, including + * fuzz examples of most of the unit tests. + */ +contract BalanceManagerTest is Test { + BalanceManager _balanceManager; + MockERC20 _mockTokenA; + MockERC20 _mockTokenB; + MockERC20 _mockTokenC; + address _owner; + address _admin1; + address _admin2; + address _user1; + address _user2; + + uint256 _threeHundred = 300 * 10 ** 18; + uint256 _fiveHundred = 500 * 10 ** 18; + uint256 _oneThousand = 1000 * 10 ** 18; + uint256 _hundredThousand = 100000 * 10 ** 18; + + function setUp() public { + _owner = address(this); + _admin1 = vm.addr(1); + _admin2 = vm.addr(2); + _user1 = vm.addr(3); + _user2 = vm.addr(4); + + // Deploy the BalanceManager contract with the owner address + _balanceManager = new BalanceManager(_owner); + + // Deploy the test tokens + _mockTokenA = new MockERC20("Token A", "AMKT"); + _mockTokenB = new MockERC20("Token B", "BMKT"); + _mockTokenC = new MockERC20("Token C", "CMKT"); + + // Mint tokens to the admins + _mockTokenA.mint(_admin1, _hundredThousand); + _mockTokenA.mint(_admin2, _hundredThousand); + + _mockTokenB.mint(_admin1, _hundredThousand); + _mockTokenB.mint(_admin2, _hundredThousand); + + _mockTokenC.mint(_admin1, _hundredThousand); + _mockTokenC.mint(_admin2, _hundredThousand); + + // Mint tokens to the users + _mockTokenA.mint(_user1, _hundredThousand); + _mockTokenC.mint(_user1, _hundredThousand); + + _mockTokenB.mint(_user2, _hundredThousand); + + // Set admin roles + vm.startPrank(_owner); + _balanceManager.addAdmin(_admin1); + _balanceManager.addAdmin(_admin2); + vm.stopPrank(); + + // Log the token addresses and user/admin addresses + console.log("Token A address:", address(_mockTokenA)); + console.log("Token B address:", address(_mockTokenB)); + console.log("Token C address:", address(_mockTokenC)); + console.log("Owner address:", _owner); + console.log("Admin1 address:", _admin1); + console.log("Admin2 address:", _admin2); + console.log("User1 address:", _user1); + console.log("User2 address:", _user2); + } + + function test_addRemoveAdmin() public { + address newAdmin = vm.addr(5); + + // Add new admin + _balanceManager.addAdmin(newAdmin); + assertTrue(_balanceManager.admins(newAdmin), "New admin should be added"); + console.log("Added new admin:", newAdmin); + + // Remove new admin + _balanceManager.removeAdmin(newAdmin); + assertFalse(_balanceManager.admins(newAdmin), "New admin should be removed"); + console.log("Removed new admin:", newAdmin); + } + + function test_removedAdminWorks() public { + vm.startPrank(_owner); + + // Add user1 as admin + _balanceManager.addAdmin(_user1); + assertTrue(_balanceManager.isAdmin(_user1), "User1 should be added as an admin"); + console.log("Owner added User1 as admin"); + + // User1 sets balance for User2 + uint256 amount = 500 * 10 ** 18; + vm.stopPrank(); + vm.startPrank(_user1); + _balanceManager.setBalance(_user2, address(_mockTokenA), amount); + console.log("User1 set balance for User2 to:", amount); + assertEq(_balanceManager.getBalance(_user2, address(_mockTokenA)), amount, "User2 balance should be set"); + + // Remove user1 as admin + vm.stopPrank(); + vm.startPrank(_owner); + _balanceManager.removeAdmin(_user1); + assertFalse(_balanceManager.isAdmin(_user1), "User1 should be removed as admin"); + console.log("Owner removed User1 as admin"); + + // User1 attempts to set balance for User2 again + vm.stopPrank(); + vm.startPrank(_user1); + vm.expectRevert("Caller is not an admin"); + _balanceManager.setBalance(_user2, address(_mockTokenA), amount); + console.log("User1 attempted to set balance for User2 and failed as expected after being removed as admin"); + + vm.stopPrank(); + } + + function test_userCannotCallAdmin() public { + vm.startPrank(_user1); + + // Attempt to set balance as a regular user + vm.expectRevert(); + _balanceManager.setBalance(_user2, address(_mockTokenA), _fiveHundred); + console.log("User1 attempted to set balance for User2 and failed as expected"); + + // Attempt to add an admin as a regular user + vm.expectRevert(); + _balanceManager.addAdmin(_user1); + console.log("User1 attempted to add themselves as an admin and failed as expected"); + + // Attempt to remove an admin as a regular user + vm.expectRevert(); + _balanceManager.removeAdmin(_admin1); + console.log("User1 attempted to remove Admin1 and failed as expected"); + + vm.stopPrank(); + } + + function test_setBalance() public { + vm.startPrank(_admin1); + + console.log("Initial balance:", _balanceManager.balances(address(_user1), address(_mockTokenA))); + _balanceManager.setBalance(_user1, address(_mockTokenA), _fiveHundred); + console.log("Set balance:", _fiveHundred); + assertEq(_balanceManager.balances(address(_user1), address(_mockTokenA)), _fiveHundred, "Balance should be set"); + assertEq(_balanceManager.totalBalances(address(_mockTokenA)), _fiveHundred, "Total balance should be updated"); + console.log("Expected balance:", _fiveHundred); + console.log("Actual balance:", _balanceManager.balances(address(_user1), address(_mockTokenA))); + + vm.stopPrank(); + } + + function test_increaseBalance() public { + vm.startPrank(_admin1); + + uint256 initialAmount = 300 * 10 ** 18; + _balanceManager.setBalance(_user1, address(_mockTokenA), initialAmount); + console.log("Initial balance for user1:", initialAmount); + + uint256 increaseAmount = 200 * 10 ** 18; + _balanceManager.increaseBalance(_user1, address(_mockTokenA), increaseAmount); + console.log("Increase user1 balance by:", increaseAmount); + + uint256 expectedBalance = initialAmount + increaseAmount; + assertEq( + _balanceManager.balances(address(_user1), address(_mockTokenA)), + expectedBalance, + "Balance should be increased" + ); + assertEq( + _balanceManager.totalBalances(address(_mockTokenA)), expectedBalance, "Total balance should be updated" + ); + console.log("Expected user1 balance:", expectedBalance); + console.log("Actual user1 balance:", _balanceManager.balances(address(_user1), address(_mockTokenA))); + + vm.stopPrank(); + } + + function test_reduceBalance() public { + vm.startPrank(_admin1); + + uint256 initialAmount = 500 * 10 ** 18; + _balanceManager.setBalance(_user1, address(_mockTokenA), initialAmount); + console.log("Initial balance for user1:", initialAmount); + + uint256 reduceAmount = 200 * 10 ** 18; + _balanceManager.reduceBalance(_user1, address(_mockTokenA), reduceAmount); + console.log("Reduce user1 balance by:", reduceAmount); + + uint256 expectedBalance = initialAmount - reduceAmount; + assertEq( + _balanceManager.balances(address(_user1), address(_mockTokenA)), + expectedBalance, + "Balance should be reduced" + ); + assertEq( + _balanceManager.totalBalances(address(_mockTokenA)), expectedBalance, "Total balance should be updated" + ); + console.log("Expected user1 balance:", expectedBalance); + console.log("Actual user1 balance:", _balanceManager.balances(address(_user1), address(_mockTokenA))); + + vm.stopPrank(); + } + + function test_fuzzSetBalance(uint256 amount) public { + vm.assume(amount <= _hundredThousand); + vm.startPrank(_admin1); + + console.log("Setting balance for user1 to", amount); + _balanceManager.setBalance(_user1, address(_mockTokenA), amount); + + uint256 balance = _balanceManager.getBalance(_user1, address(_mockTokenA)); + console.log("Balance for user1 after setting:", balance); + + assertEq(balance, amount, "Balance should match the set amount"); + vm.stopPrank(); + } + + function test_fuzzIncreaseBalance(uint256 amount) public { + vm.assume(amount <= _hundredThousand); + vm.startPrank(_admin1); + + console.log("Increasing balance for user1 by", amount); + _balanceManager.increaseBalance(_user1, address(_mockTokenA), amount); + + uint256 balance = _balanceManager.getBalance(_user1, address(_mockTokenA)); + console.log("Balance for user1 after increase:", balance); + + assertEq(balance, amount, "Balance should match the increased amount"); + vm.stopPrank(); + } + + function test_fuzzReduceBalance(uint256 initialAmount, uint256 reduceAmount) public { + vm.assume(initialAmount <= _hundredThousand); + vm.assume(reduceAmount <= initialAmount); + + vm.startPrank(_admin1); + + console.log("Setting initial balance for user1 to", initialAmount); + _balanceManager.setBalance(_user1, address(_mockTokenA), initialAmount); + + console.log("Reducing balance for user1 by", reduceAmount); + _balanceManager.reduceBalance(_user1, address(_mockTokenA), reduceAmount); + + uint256 balance = _balanceManager.getBalance(_user1, address(_mockTokenA)); + console.log("Balance for user1 after reduction:", balance); + + assertEq(balance, initialAmount - reduceAmount, "Balance should match the reduced amount"); + vm.stopPrank(); + } + + function test_claimBalance() public { + vm.startPrank(_admin1); + + uint256 amount = 500 * 10 ** 18; + _balanceManager.setBalance(_user1, address(_mockTokenA), amount); + + vm.stopPrank(); + + // Fund the contract with tokens + vm.startPrank(_user1); + _mockTokenA.approve(address(_balanceManager), amount); + _balanceManager.fund(address(_mockTokenA), amount); + console.log("Funded contract with tokens:", amount); + vm.stopPrank(); + + uint256 initialBalance = _mockTokenA.balanceOf(_user1); + console.log("Initial User1 Token A balance:", initialBalance); + + vm.startPrank(_user1); + _balanceManager.claim(address(_mockTokenA)); + uint256 claimedBalance = _mockTokenA.balanceOf(_user1); + console.log("User1 claimed Token A balance:", claimedBalance - initialBalance); + + assertEq(_balanceManager.balances(address(_user1), address(_mockTokenA)), 0, "Balance should be claimed"); + assertEq(claimedBalance, initialBalance + amount, "User1 should receive the claimed tokens"); + console.log("User1 final Token A balance:", claimedBalance); + + vm.stopPrank(); + } + + function test_claimAllBalances() public { + vm.startPrank(_admin1); + + _balanceManager.setBalance(_user1, address(_mockTokenA), _fiveHundred); // set token A balance to 500 + _balanceManager.setBalance(_user1, address(_mockTokenC), _threeHundred); // set token C balance to 300 + console.log("Token A balance for user1:", _fiveHundred); + console.log("Token C balance for user1:", _threeHundred); + + vm.stopPrank(); + + // Fund the contract with tokens + vm.startPrank(_user1); + _mockTokenA.approve(address(_balanceManager), _oneThousand); // approve for more than balance + _mockTokenC.approve(address(_balanceManager), _oneThousand); + _balanceManager.fund(address(_mockTokenA), _oneThousand); // fund for more than balance + _balanceManager.fund(address(_mockTokenC), _oneThousand); + console.log("Funded contract with Token A:", _oneThousand); + console.log("Funded contract with Token C:", _oneThousand); + vm.stopPrank(); + + // Check initial balances + uint256 initialTokenABalance = _mockTokenA.balanceOf(_user1); + uint256 initialTokenCBalance = _mockTokenC.balanceOf(_user1); + console.log("Initial User1 Token A balance:", initialTokenABalance); + console.log("Initial User1 Token C balance:", initialTokenCBalance); + + vm.startPrank(_user1); + _balanceManager.claimAll(); + assertEq( + _balanceManager.balances(address(_user1), address(_mockTokenA)), 0, "Balance for Token A should be claimed" + ); + assertEq( + _balanceManager.balances(address(_user1), address(_mockTokenC)), 0, "Balance for Token C should be claimed" + ); + + uint256 finalTokenABalance = _mockTokenA.balanceOf(_user1); + uint256 finalTokenCBalance = _mockTokenC.balanceOf(_user1); + console.log("Final User1 Token A balance:", finalTokenABalance); + console.log("Final User1 Token C balance:", finalTokenCBalance); + + assertEq(finalTokenABalance, initialTokenABalance + _fiveHundred, "User1 should receive the claimed Token A"); + assertEq(finalTokenCBalance, initialTokenCBalance + _threeHundred, "User1 should receive the claimed Token C"); + console.log("User1 claimed all balances"); + + vm.stopPrank(); + } + + function test_withdrawExcessTokens() public { + // Set up initial balances + vm.startPrank(_admin1); + + uint256 userBalance = 500 * 10 ** 18; + uint256 fundAmount = 500 * 10 ** 18; + uint256 excessAmount = 500 * 10 ** 18; + uint256 additionalAmount = 500 * 10 ** 18; + uint256 totalAmount = fundAmount + additionalAmount; + + _balanceManager.setBalance(_user1, address(_mockTokenA), userBalance); + console.log("Set Token A balance for user1:", userBalance); + + vm.stopPrank(); + + // User1 funds the contract with Token A + vm.startPrank(_user1); + _mockTokenA.approve(address(_balanceManager), totalAmount); + _balanceManager.fund(address(_mockTokenA), fundAmount); + console.log("User1 funded contract with Token A:", fundAmount); + vm.stopPrank(); + + // Admin2 deposits additional funds to the contract + vm.startPrank(_admin2); + _mockTokenA.approve(address(_balanceManager), additionalAmount); + _balanceManager.fund(address(_mockTokenA), additionalAmount); + console.log("Admin2 funded contract with additional Token A:", additionalAmount); + vm.stopPrank(); + + // Check initial balances before withdrawal + uint256 initialAdmin1Balance = _mockTokenA.balanceOf(_admin1); + uint256 initialContractBalance = _mockTokenA.balanceOf(address(_balanceManager)); + console.log("Initial Admin1 Token A balance:", initialAdmin1Balance); + console.log("Initial contract Token A balance:", initialContractBalance); + + // Admin1 withdraws excess tokens + vm.startPrank(_admin1); + _balanceManager.withdrawExcessTokens(address(_mockTokenA), excessAmount, _admin1); + console.log("Admin1 withdrew excess tokens:", excessAmount); + vm.stopPrank(); + + // Check final balances after withdrawal + uint256 finalAdmin1Balance = _mockTokenA.balanceOf(_admin1); + uint256 finalContractBalance = _mockTokenA.balanceOf(address(_balanceManager)); + uint256 finalUserBalance = _balanceManager.balances(address(_user1), address(_mockTokenA)); + console.log("Final Admin1 Token A balance:", finalAdmin1Balance); + console.log("Final contract Token A balance:", finalContractBalance); + console.log("Final user1 Token A balance:", finalUserBalance); + + // Assert admin only withdrew extra tokens + assertEq(finalAdmin1Balance, initialAdmin1Balance + excessAmount, "Admin1 should receive the excess tokens"); + + // Assert user balance remains unchanged + assertEq(finalUserBalance, userBalance, "User1 balance should remain unchanged"); + + // Assert Token A in contract is still enough to cover user balance + assertEq(finalContractBalance, userBalance, "Contract should still have enough Token A to cover user balance"); + } + + function test_withdrawExcessTokensThenClaim() public { + // Set up initial balances + vm.startPrank(_admin1); + + uint256 userBalance = 500 * 10 ** 18; + uint256 fundAmount = 500 * 10 ** 18; + uint256 excessAmount = 500 * 10 ** 18; + uint256 additionalAmount = 500 * 10 ** 18; + uint256 totalAmount = fundAmount + additionalAmount; + + _balanceManager.setBalance(_user1, address(_mockTokenA), userBalance); + console.log("Set Token A balance for user1:", userBalance); + + vm.stopPrank(); + + // User1 funds the contract with Token A + vm.startPrank(_user1); + _mockTokenA.approve(address(_balanceManager), totalAmount); + _balanceManager.fund(address(_mockTokenA), fundAmount); + console.log("User1 funded contract with Token A:", fundAmount); + vm.stopPrank(); + + // Admin2 deposits additional funds to the contract + vm.startPrank(_admin2); + _mockTokenA.approve(address(_balanceManager), additionalAmount); + _balanceManager.fund(address(_mockTokenA), additionalAmount); + console.log("Admin2 funded contract with additional Token A:", additionalAmount); + vm.stopPrank(); + + // Check initial balances before withdrawal + uint256 initialAdmin1Balance = _mockTokenA.balanceOf(_admin1); + uint256 initialContractBalance = _mockTokenA.balanceOf(address(_balanceManager)); + uint256 initialUser1Balance = _mockTokenA.balanceOf(_user1); + console.log("Initial Admin1 Token A balance:", initialAdmin1Balance); + console.log("Initial contract Token A balance:", initialContractBalance); + console.log("Initial User1 Token A balance:", initialUser1Balance); + + // Admin1 withdraws excess tokens + vm.startPrank(_admin1); + _balanceManager.withdrawExcessTokens(address(_mockTokenA), excessAmount, _admin1); + console.log("Admin1 withdrew excess tokens:", excessAmount); + vm.stopPrank(); + + // Check balances after withdrawal + uint256 finalAdmin1Balance = _mockTokenA.balanceOf(_admin1); + uint256 finalContractBalanceAfterWithdrawal = _mockTokenA.balanceOf(address(_balanceManager)); + console.log("Final Admin1 Token A balance after withdrawal:", finalAdmin1Balance); + console.log("Final contract Token A balance after withdrawal:", finalContractBalanceAfterWithdrawal); + + // User1 claims their balance + vm.startPrank(_user1); + _balanceManager.claim(address(_mockTokenA)); + uint256 finalUser1Balance = _mockTokenA.balanceOf(_user1); + console.log("User1 claimed Token A balance:", userBalance); + vm.stopPrank(); + + // Final balances + uint256 finalContractBalance = _mockTokenA.balanceOf(address(_balanceManager)); + console.log("Final contract Token A balance:", finalContractBalance); + console.log("Final User1 Token A balance:", finalUser1Balance); + + // Assert admin only withdrew extra tokens + assertEq(finalAdmin1Balance, initialAdmin1Balance + excessAmount, "Admin1 should receive the excess tokens"); + + // Assert user balance was claimed correctly + assertEq(finalUser1Balance, initialUser1Balance + userBalance, "User1 should receive the claimed tokens"); + + // Assert Token A in contract is now zero after user's claim + assertEq(finalContractBalance, 0, "Contract should have zero Token A after user's claim"); + } + + function test_adminFundsUserClaims() public { + // Log initial user balance + uint256 initialUser1Balance = _mockTokenA.balanceOf(_user1); + console.log("Initial User1 Token A balance:", initialUser1Balance); + + // Admin1 funds the contract + vm.startPrank(_admin1); + uint256 fundAmount = 1000 * 10 ** 18; + _mockTokenA.approve(address(_balanceManager), fundAmount); + _balanceManager.fund(address(_mockTokenA), fundAmount); + console.log("Admin1 funded contract with Token A:", fundAmount); + + // Admin1 sets balance for User1 + uint256 userBalance = 500 * 10 ** 18; + _balanceManager.setBalance(_user1, address(_mockTokenA), userBalance); + console.log("Admin1 set Token A balance for User1:", userBalance); + + vm.stopPrank(); + + // User1 claims their balance + vm.startPrank(_user1); + _balanceManager.claim(address(_mockTokenA)); + uint256 claimedBalance = _mockTokenA.balanceOf(_user1) - initialUser1Balance; + console.log("User1 claimed Token A balance:", claimedBalance); + + vm.stopPrank(); + + // Final user balance + uint256 finalUser1Balance = _mockTokenA.balanceOf(_user1); + console.log("Final User1 Token A balance:", finalUser1Balance); + + // Assertions + assertEq(claimedBalance, userBalance, "User1 should receive the claimed balance"); + assertTrue(finalUser1Balance > initialUser1Balance, "User1 should have more tokens than initially"); + } + + // attempt to set balance for contract address + function test_cannotAddContractBalance() public { + vm.startPrank(_admin1); + + address contractAddress = address(_balanceManager); + vm.expectRevert(BalanceManager.ContractCannotBeTheUser.selector); + _balanceManager.setBalance(contractAddress, address(_mockTokenA), _fiveHundred); + + vm.stopPrank(); + } + + // assert contract cannot receive ETH + function test_cannotReceiveEth() public { + vm.expectRevert(bytes("Contract should not accept ETH")); + (bool success,) = address(_balanceManager).call{value: 1 ether}(""); + console.log("ETH transfer success status:", success); + assertFalse(success, "Contract should not be able to accept ETH"); + } +} diff --git a/test/InflationToken.t.sol b/test/InflationToken.t.sol index 1c49da1..44bb587 100644 --- a/test/InflationToken.t.sol +++ b/test/InflationToken.t.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity ^0.8.24; import "forge-std/Test.sol"; import "../src/InflationToken.sol"; -import "openzeppelin-contracts/token/ERC20/ERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; // Mock ERC20 Token for testing recovery contract MockERC20 is ERC20 { @@ -27,7 +27,7 @@ contract InflationTokenTest is Test { console.log("Setup completed. Owner address:", _owner, "User address:", _user); } - function testInitialSupply() public { + function test_initialSupply() public { uint256 initialSupply = 1_000_000_000 * 10 ** _token.decimals(); console.log("Testing Initial Supply"); console.log("Expected initial supply:", initialSupply); @@ -38,7 +38,7 @@ contract InflationTokenTest is Test { assertEq(_token.balanceOf(_owner), initialSupply); } - function testInflation() public { + function test_inflation() public { uint256 initialSupply = 1_000_000_000 * 10 ** _token.decimals(); // Try minting after 1 day - should fail @@ -66,7 +66,7 @@ contract InflationTokenTest is Test { assertEq(_token.totalSupply(), initialSupply + (_token.MINT_CAP() * 2)); } - function testMintToContractAddressBlocked() public { + function test_mintToContractAddressBlocked() public { vm.warp(block.timestamp + 365 days); console.log("Attempting to mint to contract address, expecting revert"); @@ -74,7 +74,7 @@ contract InflationTokenTest is Test { _token.mint(address(_token)); } - function testRecoverTokens() public { + function test_recoverTokens() public { vm.warp(block.timestamp + 365 days); _token.mint(_owner); _token.transfer(address(_token), _token.MINT_CAP()); @@ -95,7 +95,7 @@ contract InflationTokenTest is Test { assertEq(ownerBalanceAfter, ownerBalanceBefore + _token.MINT_CAP()); } - function testTotalSupplyAfterInflation() public { + function test_totalSupplyAfterInflation() public { console.log("Testing Total Supply After Inflation for 5 years"); console.log("Inflation is a constant 5% of the initial supply each year"); uint256 initialSupply = 1_000_000_000 * 10 ** _token.decimals(); @@ -111,13 +111,13 @@ contract InflationTokenTest is Test { } } - function testTransferOwnership() public { + function test_transferOwnership() public { console.log("Testing Ownership Transfer"); _token.transferOwnership(_user); console.log("Ownership transferred to user"); } - function testTokenTransfers() public { + function test_tokenTransfers() public { console.log("Testing Token Transfers"); uint256 ownerBalanceBefore = _token.balanceOf(_owner); console.log("Owner balance before transfer:", ownerBalanceBefore); @@ -129,7 +129,7 @@ contract InflationTokenTest is Test { console.log("Owner balance after transfer:", _token.balanceOf(_owner)); } - function testRecoverOtherToken() public { + function test_recoverOtherToken() public { console.log("Testing Recover Other Token"); _otherToken.mint(address(_token), 100); console.log("Transferred 100 OtherToken to contract"); @@ -138,7 +138,7 @@ contract InflationTokenTest is Test { assertEq(_otherToken.balanceOf(_owner), 100); } - function testBurnTokens() public { + function test_burnTokens() public { console.log("Testing Token Burning"); uint256 initialSupply = _token.totalSupply(); console.log("Initial total supply:", initialSupply); @@ -148,14 +148,14 @@ contract InflationTokenTest is Test { console.log("Total supply after burning:", _token.totalSupply()); } - function testCannotReceiveEth() public { + function test_cannotReceiveEth() public { vm.expectRevert(bytes("Contract should not accept ETH")); (bool success,) = address(_token).call{value: 1 ether}(""); console.log("ETH transfer success status:", success); assertFalse(success, "Contract should not be able to accept ETH"); } - function testAccess() public { + function test_access() public { console.log("Testing access to token attributes and ownership"); assertEq(_token.name(), "InflationToken", "Token name should be InflationToken"); assertEq(_token.symbol(), "INFLA", "Token symbol should be INFLA"); diff --git a/test/utils/Utils.sol b/test/utils/Utils.sol index a24c60e..351638a 100644 --- a/test/utils/Utils.sol +++ b/test/utils/Utils.sol @@ -13,10 +13,8 @@ contract Utils { /// @dev This is NOT cryptographically secure. Just good enough for testing. function _genRandomInt(uint256 min, uint256 max) internal view returns (uint256) { return min - + ( - uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao, block.number, msg.sender))) - % (max - min + 1) - ); + + (uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao, block.number, msg.sender))) + % (max - min + 1)); } function _genBytes(uint32 length) internal pure returns (bytes memory message) { diff --git a/tools/sync-to-minimal.sh b/tools/sync-to-minimal.sh new file mode 100644 index 0000000..957be64 --- /dev/null +++ b/tools/sync-to-minimal.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# sync-to-minimal.sh + +# Update local branches +git fetch origin + +# Check if sync branch exists, create if it doesn't +if ! git show-ref --verify --quiet refs/heads/sync-branch; then + echo "Creating sync-branch..." + git checkout -b sync-branch +else + echo "Checking out existing sync-branch..." + git checkout sync-branch +fi + +# Reset to main +git reset --hard origin/main +echo "Reset sync-branch to match origin/main" + +# Push to sync branch +git push -f origin sync-branch +echo "Pushed sync-branch to remote" + +# Open PR creation page (GitHub example) +REPO_URL=$(git remote get-url origin | sed 's/\.git$//' | sed 's/git@github.com:/https:\/\/github.com\//') +echo "Opening PR creation page..." +open "$REPO_URL/compare/minimal...sync-branch?expand=1" + +echo "Done! Please review and create the PR from sync-branch to minimal" \ No newline at end of file