diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e032b53 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,9 @@ +## What's new in this PR? + +In bullet point format, please describe what's new in this PR. + +## Why? + +What problem does this solve? +Why is this important? +What's the context? diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 0000000..510ad55 --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,45 @@ +name: Continuous Integration + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + strategy: + fail-fast: true + + name: Build and Test Contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + # - name: Run Forge build + # run: | + # forge --version + # forge build nest --sizes + # id: build + + - name: Run Forge format + run: | + forge fmt nest --check + id: format + + # - name: Run Forge tests + # run: | + # forge test nest -vvv + # id: test diff --git a/.gitmodules b/.gitmodules index 42cbfbb..188bad0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "nest/lib/openzeppelin-contracts-upgradeable"] path = nest/lib/openzeppelin-contracts-upgradeable url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "nest/lib/openzeppelin-foundry-upgrades"] + path = nest/lib/openzeppelin-foundry-upgrades + url = https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades diff --git a/foundry-template/.gitignore b/foundry-template/.gitignore new file mode 100644 index 0000000..c38a98e --- /dev/null +++ b/foundry-template/.gitignore @@ -0,0 +1,15 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/*/18230/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/foundry-template/README.md b/foundry-template/README.md new file mode 100644 index 0000000..e38039d --- /dev/null +++ b/foundry-template/README.md @@ -0,0 +1,9 @@ +## Foundry Template Setup + +Copy this folder to start a new Foundry project, then run the following to initialize the submodules: + +```bash +$ forge install foundry-rs/forge-std +$ forge install OpenZeppelin/openzeppelin-foundry-upgrades +$ forge install OpenZeppelin/openzeppelin-contracts-upgradeable +``` diff --git a/foundry-template/foundry.toml b/foundry-template/foundry.toml new file mode 100644 index 0000000..b5d5cbd --- /dev/null +++ b/foundry-template/foundry.toml @@ -0,0 +1,26 @@ +[profile.default] +solc = "0.8.25" +evm_version = "cancun" +src = "src" +out = "out" +libs = ["lib"] +ffi = true +ast = true +build_info = true +extra_output = ["storageLayout"] + +[fmt] +single_line_statement_blocks = "multi" +multiline_func_header = "params_first" +sort_imports = true +contract_new_lines = true +bracket_spacing = true +int_types = "long" +quote_style = "double" +number_underscore = "thousands" +wrap_comments = true + +remappings = [ + "@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/", + "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/", +] diff --git a/nest/.github/workflows/test.yml b/nest/.github/workflows/test.yml deleted file mode 100644 index 9282e82..0000000 --- a/nest/.github/workflows/test.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: test - -on: workflow_dispatch - -env: - FOUNDRY_PROFILE: ci - -jobs: - check: - strategy: - fail-fast: true - - name: Foundry project - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - with: - version: nightly - - - name: Run Forge build - run: | - forge --version - forge build --sizes - id: build - - - name: Run Forge tests - run: | - forge test -vvv - id: test diff --git a/nest/.gitignore b/nest/.gitignore index 85198aa..c38a98e 100644 --- a/nest/.gitignore +++ b/nest/.gitignore @@ -5,6 +5,7 @@ out/ # Ignores development broadcast logs !/broadcast /broadcast/*/31337/ +/broadcast/*/18230/ /broadcast/**/dry-run/ # Docs diff --git a/nest/foundry.toml b/nest/foundry.toml index 25b918f..b5d5cbd 100644 --- a/nest/foundry.toml +++ b/nest/foundry.toml @@ -1,6 +1,26 @@ [profile.default] +solc = "0.8.25" +evm_version = "cancun" src = "src" out = "out" libs = ["lib"] +ffi = true +ast = true +build_info = true +extra_output = ["storageLayout"] -# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options +[fmt] +single_line_statement_blocks = "multi" +multiline_func_header = "params_first" +sort_imports = true +contract_new_lines = true +bracket_spacing = true +int_types = "long" +quote_style = "double" +number_underscore = "thousands" +wrap_comments = true + +remappings = [ + "@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/", + "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/", +] diff --git a/nest/lib/openzeppelin-foundry-upgrades b/nest/lib/openzeppelin-foundry-upgrades new file mode 160000 index 0000000..dd9e5dd --- /dev/null +++ b/nest/lib/openzeppelin-foundry-upgrades @@ -0,0 +1 @@ +Subproject commit dd9e5dd22b885b364354af6a1cbad8a36958e3df diff --git a/nest/script/Counter.s.sol b/nest/script/Counter.s.sol deleted file mode 100644 index df9ee8b..0000000 --- a/nest/script/Counter.s.sol +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script, console} from "forge-std/Script.sol"; - -contract CounterScript is Script { - function setUp() public {} - - function run() public { - vm.broadcast(); - } -} diff --git a/nest/script/DeployNestContracts.s.sol b/nest/script/DeployNestContracts.s.sol new file mode 100644 index 0000000..0fda1ff --- /dev/null +++ b/nest/script/DeployNestContracts.s.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import "forge-std/Script.sol"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Upgrades } from "openzeppelin-foundry-upgrades/Upgrades.sol"; + +import { FakeComponentToken } from "../src/FakeComponentToken.sol"; + +contract DeployNestContracts is Script { + + address private constant ARC_ADMIN_ADDRESS = 0x1c9d94FAD4ccCd522804a955103899e0D6A4405a; + address private constant USDC_ADDRESS = 0x849c25e6cCB03cdc23ba91d92440dA7bC8486be2; + + function run() external { + vm.startBroadcast(ARC_ADMIN_ADDRESS); + + address fakeComponentTokenProxy = Upgrades.deployUUPSProxy( + "FakeComponentToken.sol", + abi.encodeCall(FakeComponentToken.initialize, (msg.sender, "Banana", "BAN", IERC20(USDC_ADDRESS), 18)) + ); + console.log("FakeComponentToken deployed to:", fakeComponentTokenProxy); + + vm.stopBroadcast(); + } + +} diff --git a/nest/src/FakeComponentToken.sol b/nest/src/FakeComponentToken.sol index f1ed312..b0fbf39 100644 --- a/nest/src/FakeComponentToken.sol +++ b/nest/src/FakeComponentToken.sol @@ -1,137 +1,211 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.25; +import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + import { IComponentToken } from "./interfaces/IComponentToken.sol"; -import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/contracts/token/ERC20/ERC20Upgradeable.sol"; /** - * @title ComponentTokenExample - * @dev ERC20 token that represents a component of an AggregateToken - * Invariant: the total value of all ComponentTokens minted is equal to the total value of all of its component tokens + * @title FakeComponentToken + * @author Eugene Y. Q. Shen + * @notice Fake example of a ComponentToken that could be used in an AggregateToken when testing. + * Users can buy and sell one FakeComponentToken by exchanging it with one CurrencyToken at any time. + * @custom:oz-upgrades-from FakeComponentToken */ -contract ComponentToken is IComponentToken, ERC20Upgradeable { - IERC20 public currencyToken; +contract FakeComponentToken is + Initializable, + AccessControlUpgradeable, + UUPSUpgradeable, + ERC20Upgradeable, + IComponentToken +{ + + // Storage + + /// @custom:storage-location erc7201:plume.storage.FakeComponentToken + struct FakeComponentTokenStorage { + /// @dev CurrencyToken used to mint and burn the FakeComponentToken + IERC20 currencyToken; + /// @dev Number of decimals of the FakeComponentToken + uint8 decimals; + } - // Base at which we do calculations in order to minimize rounding differences - uint256 _BASE = 10 ** 18; + // keccak256(abi.encode(uint256(keccak256("plume.storage.FakeComponentToken")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant FAKE_COMPONENT_TOKEN_STORAGE_LOCATION = + 0x2c4e9dd7fc35b7006b8a84e1ac11ecc9e53a0dd5c8824b364abab355c5037600; - // Price at which the vault manager is willing to sell the aggregate token, times the base - uint256 askPrice; - /* Price at which the vault manager is willing to buy back the aggregate token, times the base - * This is always smaller than the ask price, so if the vault manager never changes either price, - * then they will always be able to buy back all outstanding ComponentTokens at a profit - */ - uint256 bidPrice; + function _getFakeComponentTokenStorage() private pure returns (FakeComponentTokenStorage storage $) { + assembly { + $.slot := FAKE_COMPONENT_TOKEN_STORAGE_LOCATION + } + } - uint8 private _currencyDecimals; - uint8 private _decimals; + // Constants + + /// @notice Role for the upgrader of the FakeComponentToken + bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADE_ROLE"); // Events /** - * @dev Emitted when a user stakes currencyToken to receive aggregateToken in return - * @param user Address of the user who staked the currencyToken - * @param currencyTokenAmount Amount of currencyToken staked - * @param aggregateTokenAmount Amount of aggregateToken received + * @notice Emitted when a user buys FakeComponentToken using CurrencyToken + * @param user Address of the user who bought the FakeComponentToken + * @param currencyToken CurrencyToken used to buy the FakeComponentToken + * @param currencyTokenAmount Amount of CurrencyToken paid + * @param componentTokenAmount Amount of FakeComponentToken received + */ + event ComponentTokenBought( + address indexed user, IERC20 indexed currencyToken, uint256 currencyTokenAmount, uint256 componentTokenAmount + ); + + /** + * @notice Emitted when a user sells FakeComponentToken to receive CurrencyToken + * @param user Address of the user who sold the FakeComponentToken + * @param currencyToken CurrencyToken received in exchange for the FakeComponentToken + * @param currencyTokenAmount Amount of CurrencyToken received + * @param componentTokenAmount Amount of FakeComponentToken sold + */ + event ComponentTokenSold( + address indexed user, IERC20 indexed currencyToken, uint256 currencyTokenAmount, uint256 componentTokenAmount + ); + + // Errors + + /** + * @notice Indicates a failure because the given CurrencyToken does not match actual CurrencyToken + * @param invalidCurrencyToken CurrencyToken that does not match the actual CurrencyToken + * @param currencyToken Actual CurrencyToken used to mint and burn the FakeComponentToken + */ + error InvalidCurrencyToken(IERC20 invalidCurrencyToken, IERC20 currencyToken); + + /** + * @notice Indicates a failure because the FakeComponentToken does not have enough CurrencyToken + * @param currencyToken CurrencyToken used to mint and burn the FakeComponentToken + * @param amount Amount of CurrencyToken required in the failed transfer */ - event Buy(address indexed user, uint256 currencyTokenAmount, uint256 aggregateTokenAmount); + error CurrencyTokenInsufficientBalance(IERC20 currencyToken, uint256 amount); /** - * @dev Emitted when a user unstakes aggregateToken to receive currencyToken in return - * @param user Address of the user who unstaked the aggregateToken - * @param currencyTokenAmount Amount of currencyToken received - * @param aggregateTokenAmount Amount of aggregateToken unstaked + * @notice Indicates a failure because the user does not have enough CurrencyToken + * @param currencyToken CurrencyToken used to mint and burn the FakeComponentToken + * @param user Address of the user who is selling the CurrencyToken + * @param amount Amount of CurrencyToken required in the failed transfer */ - event Sell(address indexed user, uint256 currencyTokenAmount, uint256 aggregateTokenAmount); + error UserCurrencyTokenInsufficientBalance(IERC20 currencyToken, address user, uint256 amount); + // Initializer + + /** + * @notice Initialize the FakeComponentToken + * @param owner Address of the owner of the FakeComponentToken + * @param name Name of the FakeComponentToken + * @param symbol Symbol of the FakeComponentToken + * @param currencyToken CurrencyToken used to mint and burn the FakeComponentToken + * @param decimals_ Number of decimals of the FakeComponentToken + */ function initialize( + address owner, string memory name, string memory symbol, - uint8 __decimals, - string memory _tokenURI, - address _currencyToken, - uint256 _askPrice, - uint256 _bidPrice + IERC20 currencyToken, + uint8 decimals_ ) public initializer { - tokenURI = _tokenURI; + __ERC20_init(name, symbol); + __AccessControl_init(); + __UUPSUpgradeable_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, owner); + _grantRole(UPGRADER_ROLE, owner); - currencyToken = IERC20(_currencyToken); - _currencyDecimals = currencyToken.decimals(); - askPrice = _askPrice; - bidPrice = _bidPrice; - _decimals = __decimals; + FakeComponentTokenStorage storage $ = _getFakeComponentTokenStorage(); + $.currencyToken = currencyToken; + $.decimals = decimals_; } // Override Functions /** - * @notice Returns the number of decimals of the aggregateToken + * @notice Revert when `msg.sender` is not authorized to upgrade the contract + * @param newImplementation Address of the new implementation */ + function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADER_ROLE) { } + + /// @notice Number of decimals of the FakeComponentToken function decimals() public view override returns (uint8) { - return _decimals; + FakeComponentTokenStorage storage $ = _getFakeComponentTokenStorage(); + return $.decimals; } // User Functions /** - * @notice Stake the currencyToken to receive aggregateToken in return - * @dev The user must approve the contract to spend the currencyToken - * @param currencyTokenAmount Amount of currencyToken to stake + * @notice Buy FakeComponentToken using CurrencyToken + * @dev The user must approve the contract to spend the CurrencyToken + * @param currencyToken_ CurrencyToken used to buy the FakeComponentToken + * @param amount Amount of CurrencyToken to pay to receive the same amount of FakeComponentToken */ - function buy( - address currencyToken, - uint256 currencyTokenAmount - ) public { - /* - // TODO: figure decimals math - uint256 aggregateTokenAmount = currencyTokenAmount * _BASE / askPrice; + function buy(IERC20 currencyToken_, uint256 amount) public returns (uint256) { + FakeComponentTokenStorage storage $ = _getFakeComponentTokenStorage(); + IERC20 currencyToken = $.currencyToken; - require(currencyToken.transferFrom(msg.sender, address(this), currencyTokenAmount), "AggregateToken: failed to transfer currencyToken"); - _mint(msg.sender, aggregateTokenAmount); + if (currencyToken_ != currencyToken) { + revert InvalidCurrencyToken(currencyToken_, currencyToken); + } + if (!currencyToken.transferFrom(msg.sender, address(this), amount)) { + revert UserCurrencyTokenInsufficientBalance(currencyToken, msg.sender, amount); + } - emit Staked(msg.sender, currencyTokenAmount, aggregateTokenAmount); - */ + _mint(msg.sender, amount); - DEX.swap(p, address(this)); + emit ComponentTokenBought(msg.sender, currencyToken, amount, amount); + return amount; } /** - * @notice Unstake the aggregateToken to receive currencyToken in return - * @param currencyTokenAmount Amount of currencyToken to receive + * @notice Sell FakeComponentToken to receive CurrencyToken + * @param currencyToken_ CurrencyToken received in exchange for the FakeComponentToken + * @param amount Amount of CurrencyToken to receive in exchange for the FakeComponentToken */ - function ( - address currencyToken, - uint256 currencyTokenAmount - ) public { - // TODO: figure decimals math - uint256 aggregateTokenAmount = currencyTokenAmount * _BASE / bidPrice; + function sell(IERC20 currencyToken_, uint256 amount) public returns (uint256) { + FakeComponentTokenStorage storage $ = _getFakeComponentTokenStorage(); + IERC20 currencyToken = $.currencyToken; - require(currencyToken.transfer(msg.sender, currencyTokenAmount), "AggregateToken: failed to transfer currencyToken"); - _burn(msg.sender, aggregateTokenAmount); + if (currencyToken_ != currencyToken) { + revert InvalidCurrencyToken(currencyToken_, currencyToken); + } + if (!currencyToken.transfer(msg.sender, amount)) { + revert CurrencyTokenInsufficientBalance(currencyToken, amount); + } - emit Unstaked(msg.sender, currencyTokenAmount, aggregateTokenAmount); - } + _burn(msg.sender, amount); - /** - * @notice - */ - function claim(uint256 amount) public { - // TODO - rebasing vs. streaming - } + emit ComponentTokenSold(msg.sender, currencyToken, amount, amount); - function claimAll() public { - uint256 amount = claimableAmount(msg.sender); - claim(amount); + return amount; } // Admin Setter Functions - function setAskPrice(uint256 price) public onlyOwner { - askPrice = price; + /** + * @notice Set the CurrencyToken used to mint and burn the FakeComponentToken + * @param currencyToken New CurrencyToken + */ + function setCurrencyToken(IERC20 currencyToken) public onlyRole(DEFAULT_ADMIN_ROLE) { + FakeComponentTokenStorage storage $ = _getFakeComponentTokenStorage(); + $.currencyToken = currencyToken; } - - function setBidPrice(uint256 price) public onlyOwner { - bidPrice = price; + + // Getter View Functions + + /// @notice CurrencyToken used to mint and burn the FakeComponentToken + function getCurrencyToken() public view returns (IERC20) { + FakeComponentTokenStorage storage $ = _getFakeComponentTokenStorage(); + return $.currencyToken; } -} \ No newline at end of file + +} diff --git a/nest/src/interfaces/IComponentToken.sol b/nest/src/interfaces/IComponentToken.sol index 49d2e2d..d5933f1 100644 --- a/nest/src/interfaces/IComponentToken.sol +++ b/nest/src/interfaces/IComponentToken.sol @@ -1,9 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.25; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + interface IComponentToken is IERC20 { - function buy(uint256 amount) external; - function sell(uint256 amount) external; - function claim(uint256 amount) external; - function claimAll() external; -} \ No newline at end of file + + function buy(IERC20 currencyToken, uint256 currencyTokenAmount) external returns (uint256 componentTokenAmount); + function sell(IERC20 currencyToken, uint256 currencyTokenAmount) external returns (uint256 componentTokenAmount); + +} diff --git a/nest/test/Counter.t.sol b/nest/test/Counter.t.sol deleted file mode 100644 index 54b724f..0000000 --- a/nest/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -}