diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..746ae31 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# All files +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.sol] +indent_size = 4 + +[*.tree] +indent_size = 1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..968c49d --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +export API_KEY_INFURA="YOUR_API_KEY_INFURA" +export MNEMONIC="YOUR_MNEMONIC" +export FOUNDRY_PROFILE="default" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..282057c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: "CI" + +env: + API_KEY_ALCHEMY: ${{ secrets.API_KEY_ALCHEMY }} + FOUNDRY_PROFILE: "ci" + +on: + workflow_dispatch: + pull_request: + push: + branches: + - "main" + +jobs: + lint: + runs-on: "ubuntu-latest" + steps: + - name: "Check out the repo" + uses: "actions/checkout@v4" + + - name: "Install Foundry" + uses: "foundry-rs/foundry-toolchain@v1" + + - name: "Install Bun" + uses: "oven-sh/setup-bun@v1" + + - name: "Install the Node.js dependencies" + run: "bun install" + + - name: "Lint the code" + run: "bun run lint" + + - name: "Add lint summary" + run: | + echo "## Lint result" >> $GITHUB_STEP_SUMMARY + echo "✅ Passed" >> $GITHUB_STEP_SUMMARY + + build: + runs-on: "ubuntu-latest" + steps: + - name: "Check out the repo" + uses: "actions/checkout@v4" + + - name: "Install Foundry" + uses: "foundry-rs/foundry-toolchain@v1" + + - name: "Install Bun" + uses: "oven-sh/setup-bun@v1" + + - name: "Install the Node.js dependencies" + run: "bun install" + + - name: "Build the contracts and print their size" + run: "forge build --sizes" + + - name: "Add build summary" + run: | + echo "## Build result" >> $GITHUB_STEP_SUMMARY + echo "✅ Passed" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/multibuild.yml b/.github/workflows/multibuild.yml new file mode 100644 index 0000000..2048be5 --- /dev/null +++ b/.github/workflows/multibuild.yml @@ -0,0 +1,26 @@ +name: "Multibuild" + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * 0" # at 3:00am UTC every Sunday + +jobs: + multibuild: + runs-on: "ubuntu-latest" + steps: + - name: "Check out the repo" + uses: "actions/checkout@v4" + + - name: "Install Bun" + uses: "oven-sh/setup-bun@v1" + + - name: "Install the Node.js dependencies" + run: "bun install --frozen-lockfile" + + - name: "Check that the project can be built with multiple Solidity versions" + uses: "PaulRBerg/foundry-multibuild@v1" + with: + min: "0.8.4" + max: "0.8.25" + skip-test: "true" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e108b40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# directories +cache +coverage +node_modules +out + +# files +*.env +*.log +.DS_Store +.pnp.* +lcov.info +package-lock.json +pnpm-lock.yaml +yarn.lock + +# broadcasts +!broadcast +broadcast/* +broadcast/*/31337/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..6c2d882 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,16 @@ +# directories +cache +coverage +node_modules +out + +# files +*.env +*.log +.DS_Store +.pnp.* +bun.lockb +lcov.info +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 0000000..a1ecdbb --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,7 @@ +bracketSpacing: true +printWidth: 120 +proseWrap: "always" +singleQuote: false +tabWidth: 2 +trailingComma: "all" +useTabs: false diff --git a/.solhint.json b/.solhint.json new file mode 100644 index 0000000..082df3d --- /dev/null +++ b/.solhint.json @@ -0,0 +1,15 @@ +{ + "extends": "solhint:recommended", + "rules": { + "avoid-low-level-calls": "off", + "code-complexity": ["error", 8], + "compiler-version": ["error", ">=0.8.4"], + "func-name-mixedcase": "off", + "func-visibility": ["error", { "ignoreConstructors": true }], + "max-line-length": ["error", 120], + "named-parameters-mapping": "warn", + "no-console": "off", + "not-rely-on-time": "off", + "one-contract-per-file": "off" + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..368551f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "[solidity]": { + "editor.defaultFormatter": "NomicFoundation.hardhat-solidity" + }, + "[toml]": { + "editor.defaultFormatter": "tamasfe.even-better-toml" + }, + "editor.formatOnSave": true, + "solidity.formatter": "forge" +} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..0424fa0 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,16 @@ +MIT License + +Copyright (c) 2024 Paul Razvan Berg + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c830727 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# Sablier Standard Library [![License: MIT][license-badge]][license] + +[license]: https://opensource.org/licenses/MIT +[license-badge]: https://img.shields.io/badge/License-MIT-blue.svg + +This repository contains the Sablier Standard Library. + +## License + +This project is licensed under MIT. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..6542020 Binary files /dev/null and b/bun.lockb differ diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..6468f08 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,43 @@ +# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config + +[profile.default] + auto_detect_solc = false + block_timestamp = 1717200000 # June 1, 2024 at 00:00 GMT + bytecode_hash = "none" + evm_version = "shanghai" + fuzz = { runs = 1_000 } + gas_reports = ["SRF20"] + optimizer = true + optimizer_runs = 10_000 + out = "out" + script = "script" + solc = "0.8.25" + src = "src" + test = "test" + +[profile.ci] + fuzz = { runs = 10_000 } + verbosity = 4 + +# Speed up compilation and tests during development +[profile.lite] + optimizer = false + +[doc] + ignore = ["**/*.t.sol"] + out = "docs" + repository = "https://github.com/sablier-labs/stdlib" + +[fmt] + bracket_spacing = true + int_types = "long" + line_length = 120 + multiline_func_header = "all" + number_underscore = "thousands" + quote_style = "double" + tab_width = 4 + wrap_comments = true + +[rpc_endpoints] + localhost = "http://localhost:8545" + sepolia = "https://sepolia.infura.io/v3/${API_KEY_INFURA}" diff --git a/package.json b/package.json new file mode 100644 index 0000000..aa9e100 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "@sablier/stdlib", + "description": "Standard library for SabVM", + "version": "1.0.0", + "author": { + "name": "Sablier Labs Ltd", + "url": "https://sablier.com" + }, + "devDependencies": { + "forge-std": "github:foundry-rs/forge-std#v1.8.1", + "prettier": "^3.2.5", + "solhint": "^3.6.2" + }, + "keywords": [ + "blockchain", + "ethereum", + "forge", + "foundry", + "smart-contracts", + "solidity", + "template" + ], + "private": true, + "scripts": { + "clean": "rm -rf cache out", + "build": "forge build", + "lint": "bun run lint:sol && bun run prettier:check", + "lint:sol": "forge fmt --check && bun solhint {script,src,test}/**/*.sol", + "prettier:check": "prettier --check \"**/*.{json,md,yml}\" --ignore-path \".prettierignore\"", + "prettier:write": "prettier --write \"**/*.{json,md,yml}\" --ignore-path \".prettierignore\"", + "test": "forge test", + "test:coverage": "forge coverage", + "test:coverage:report": "forge coverage --report lcov && genhtml lcov.info --branch-coverage --output-dir coverage" + } +} diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..bcc8cde --- /dev/null +++ b/remappings.txt @@ -0,0 +1 @@ +forge-std/=node_modules/forge-std/ diff --git a/src/Constants.sol b/src/Constants.sol new file mode 100644 index 0000000..8ebbe0d --- /dev/null +++ b/src/Constants.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.4; + +// import { SubID } from "./Types.sol"; + +/*////////////////////////////////////////////////////////////////////////// + NATIVE TOKENS +//////////////////////////////////////////////////////////////////////////*/ + +bytes32 constant DEFAULT_SUB_ID = bytes32(0); + +/*////////////////////////////////////////////////////////////////////////// + PRECOMPILES +//////////////////////////////////////////////////////////////////////////*/ + +address constant PRECOMPILE_NATIVE_TOKENS = address(0x0000000000000000000000000000000000000200); diff --git a/src/Types.sol b/src/Types.sol new file mode 100644 index 0000000..fd5868e --- /dev/null +++ b/src/Types.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.4; + +type AssetID is uint256; + +type SubID is bytes32; diff --git a/src/precompiles/native-tokens/INativeTokens.sol b/src/precompiles/native-tokens/INativeTokens.sol new file mode 100644 index 0000000..bca1b28 --- /dev/null +++ b/src/precompiles/native-tokens/INativeTokens.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.4; + +/// @notice Interface for the NativeTokens precompile, which can perform operations on native tokens. +/// @dev It is NOT recommended to use this interface directly. Instead, users should use the {NativeTokens} library. +/// This interface is used by the library to ABI encode the precompile calls. +interface INativeTokens { + function burn(address holder, bytes32 subID, uint256 amount) external; + function mint(address recipient, bytes32 subID, uint256 amount) external; + function transfer(address from, address to, bytes32 tokenID, uint256 amount) external; + function transferAndCall( + address from, + address to, + bytes32 tokenID, + uint256 amount, + address callee, + bytes calldata data + ) + external; + function transferMultiple( + address from, + address[] calldata to, + bytes32[] calldata assetIDs, + uint256[] calldata amounts + ) + external; + function transferMultipleAndCall( + address from, + address[] calldata to, + bytes32[] calldata assetIDs, + uint256[] calldata amounts, + address callee, + bytes calldata data + ) + external; +} diff --git a/src/precompiles/native-tokens/NativeTokens.sol b/src/precompiles/native-tokens/NativeTokens.sol new file mode 100644 index 0000000..093958e --- /dev/null +++ b/src/precompiles/native-tokens/NativeTokens.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.4; + +import { PRECOMPILE_NATIVE_TOKENS } from "../../Constants.sol"; +import { INativeTokens } from "./INativeTokens.sol"; + +library NativeTokens { + /// @notice Burns `amount` tokens with the sub-identifier `sub_id` from the `holder`'s account. + /// @dev Generates a Burn receipt. + /// + /// Requirements: + /// - The holder must have at least `amount` tokens. + /// + /// @param holder The address to burn native tokens from. + /// @param subID The sub-identifier of the native token to burn. + /// @param amount The quantity of native tokens to burn. + function burn(address holder, bytes32 subID, uint256 amount) internal { + // ABI encode the input parameters. + bytes memory precompileData = abi.encodeCall(INativeTokens.burn, (holder, subID, amount)); + + // Call the precompile, ignoring the response since the VM will panic if there's an issue. + (bool response,) = PRECOMPILE_NATIVE_TOKENS.delegatecall(precompileData); + response; + } + + /// @notice Mints `amount` tokens with sub-identifier `subID` to the provided `recipient`. + /// @dev Generates a Mint receipt. + /// + /// Requirements: + /// - The `recipient`'s balance must not overflow. + /// + /// @param recipient The address to mint native tokens to. + /// @param subID The sub-identifier of the native token to mint. + /// @param amount The quantity of native tokens to mint. + function mint(address recipient, bytes32 subID, uint256 amount) internal { + // ABI encode the input parameters. + bytes memory precompileData = abi.encodeCall(INativeTokens.mint, (recipient, subID, amount)); + + // Call the precompile, ignoring the response since the VM will panic if there's an issue. + (bool response,) = PRECOMPILE_NATIVE_TOKENS.delegatecall(precompileData); + response; + } + + /// @notice Transfers `amount` of native tokens from the calling contract to the recipient `to`. + /// @dev In SabVM, contracts cannot transfer native tokens on behalf of other addresses. + /// @param to The address of the recipient. + /// @param tokenID The ID of the native token to transfer. + /// @param amount The quantity of native tokens to transfer. + function transfer(address to, bytes32 tokenID, uint256 amount) internal { + // The address in the calling contract. + address from = address(this); + + // ABI encode the input parameters. + bytes memory precompileData = abi.encodeCall(INativeTokens.transfer, (from, to, tokenID, amount)); + + // Call the precompile, ignoring the response because the VM will panic if there's an issue. + (bool response,) = PRECOMPILE_NATIVE_TOKENS.delegatecall(precompileData); + response; + } + + /// @notice Transfers `amount` of native tokens from the calling contract to the recipient `to`, and calls the + /// `callee` with the calldata `data`. + /// @param to The address of the recipient. + /// @param tokenID The sub-identifier of the native token to transfer. + /// @param amount The quantity of native tokens to transfer. + /// @param callee The address of the contract to call after the transfer. + /// @param data The call data to pass to the `callee`. + function transferAndCall( + address to, + bytes32 tokenID, + uint256 amount, + address callee, + bytes calldata data + ) + internal + { + // The address in the calling contract. + address from = address(this); + + // ABI encode the input parameters. + bytes memory precompileData = + abi.encodeCall(INativeTokens.transferAndCall, (from, to, tokenID, amount, callee, data)); + + // Call the precompile, ignoring the response because the VM will panic if there's an issue. + (bool response,) = PRECOMPILE_NATIVE_TOKENS.delegatecall(precompileData); + response; + } + + /// @notice Performs multiple native token transfers from the calling contract to the recipients `to`. + /// @dev In SabVM, contracts cannot transfer native tokens on behalf of other addresses. + /// @param to The addresses of the recipients. + /// @param assetIDs The IDs of the native tokens to transfer. + /// @param amounts The quantities of native tokens to transfer. + function transferMultiple( + address[] calldata to, + bytes32[] calldata assetIDs, + uint256[] calldata amounts + ) + internal + { + // The current address in the calling contract. + address from = address(this); + + // ABI encode the input parameters. + bytes memory precompileData = abi.encodeCall(INativeTokens.transferMultiple, (from, to, assetIDs, amounts)); + + // Call the precompile, ignoring the response because the VM will panic if there's an issue. + (bool response,) = PRECOMPILE_NATIVE_TOKENS.delegatecall(precompileData); + response; + } + + /// @notice Performs multiple native token transfers from the calling contract to the recipients `to`, and calls the + /// `callee` with the calldata `data`. + /// @param to The addresses of the recipients. + /// @param assetIDs The IDs of the native tokens to transfer. + /// @param amounts The quantities of native tokens to transfer. + /// @param callee The address of the contract to call after the transfer. + /// @param data The call data to pass to the `callee`. + function transferMultipleAndCall( + address[] calldata to, + bytes32[] calldata assetIDs, + uint256[] calldata amounts, + address callee, + bytes calldata data + ) + internal + { + // The address in the calling contract. + address from = address(this); + + // ABI encode the input parameters. + bytes memory precompileData = + abi.encodeCall(INativeTokens.transferMultipleAndCall, (from, to, assetIDs, amounts, callee, data)); + + // Call the precompile, ignoring the response because the VM will panic if there's an issue. + (bool response,) = PRECOMPILE_NATIVE_TOKENS.delegatecall(precompileData); + response; + } +} diff --git a/src/standards/srf-20/ISRF20.sol b/src/standards/srf-20/ISRF20.sol new file mode 100644 index 0000000..813d789 --- /dev/null +++ b/src/standards/srf-20/ISRF20.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.4; + +/// @notice Interface for the SRF20 standard, which implements a Single Native Token. +/// @dev See https://github.com/sablier-labs/SRFs +interface ISRF20 { + /*////////////////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Indicates a failure with a holder address when tokens are burned. + error SRF20_InvalidHolder(address holder); + + /// @notice Indicates a failure with a recipient address when tokens are minted + error SRF20_InvalidRecipient(address recipient); + + /*////////////////////////////////////////////////////////////////////////// + FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Returns the number of decimals used to get the token's user representation. + /// In SabVM, this value is always 18 in order to imitate the relationship between Ether and Wei. + function decimals() external pure returns (uint8); + + /// @notice Returns the ID of the Native Token minted by the contract. + /// @dev The default sub ID of 0 is used to generate the ID. + function ID() external view returns (uint256); + + /// @notice Returns the name of the token. + function name() external view returns (string memory); + + /// @notice Returns the symbol of the token. + function symbol() external view returns (string memory); + + /// @notice Returns the total supply of tokens in circulation. + function totalSupply() external view returns (uint256); +} diff --git a/src/standards/srf-20/SRF20.sol b/src/standards/srf-20/SRF20.sol new file mode 100644 index 0000000..a4f4062 --- /dev/null +++ b/src/standards/srf-20/SRF20.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.4; + +import { DEFAULT_SUB_ID } from "../../Constants.sol"; +import { NativeTokens } from "../../precompiles/native-tokens/NativeTokens.sol"; +import { ISRF20 } from "./ISRF20.sol"; + +abstract contract SRF20 is ISRF20 { + using NativeTokens for address; + + /*////////////////////////////////////////////////////////////////////////// + STATE VARIABLES + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice The SRF-20 name of the token. + string private _name; + + /// @notice The SRF-20 symbol of the token. + string private _symbol; + + /// @notice The total amount of tokens in circulation. + uint256 public override totalSupply; + + /*////////////////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////////////////*/ + + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + } + + /*////////////////////////////////////////////////////////////////////////// + USER-FACING CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Returns the number of decimals used to get the token's user representation. + /// In SabVM, this value is always 18 in order to imitate the relationship between Ether and Wei. + function decimals() external pure override returns (uint8) { + return 18; + } + + /// @notice Returns the ID of the Native Token minted by the contract. + /// @dev The default sub ID of 0 is used to generate the ID. + function ID() external view override returns (uint256) { + // Concatenate the contract's address and the default sub ID. + bytes memory concatenation = abi.encodePacked(address(this), DEFAULT_SUB_ID); + + // Hash the concatenated value. + bytes32 hashed = keccak256(concatenation); + + // Wrap the resultant hash into the expected type. + return uint256(hashed); + } + + /// @notice Returns the name of the token. + function name() external view override returns (string memory) { + return _name; + } + + /// @notice Returns the symbol of the token. + function symbol() external view override returns (string memory) { + return _symbol; + } + + /*////////////////////////////////////////////////////////////////////////// + INTERNAL CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Burns `amount` tokens from the provided `holder`, and decreases the token supply. + /// + /// @dev Requirements: + /// + /// - Refer to the requirements in {NativeTokens-burn}. + function _burn(address holder, uint256 amount) internal { + // Checks: `holder` is not the zero address. + if (holder == address(0)) { + revert ISRF20.SRF20_InvalidHolder(holder); + } + + // Native Interactions: burn the tokens via the SabVM precompile. + holder.burn(DEFAULT_SUB_ID, amount); + + // Effects: reduce the total supply. + unchecked { + // Underflow not possible: the precompile would have panicked if the holder's balance underflowed. + totalSupply -= amount; + } + } + + /// @notice Mints `amount` tokens to the provided `recipient`, and increases the total supply. + /// + /// @dev Requirements: + /// + /// - Refer to the requirements in {NativeTokens-mint}. + /// - The total supply must not overflow. + function _mint(address recipient, uint256 amount) internal { + // Checks: `beneficiary` is not the zero address. + if (recipient == address(0)) { + revert ISRF20.SRF20_InvalidRecipient(recipient); + } + + // Native Interactions: mint the new tokens via the SabVM precompile. + recipient.mint(DEFAULT_SUB_ID, amount); + + // Effects: increase the total supply. + totalSupply += amount; + } +} diff --git a/src/standards/srf-20/SRF20Mock.sol b/src/standards/srf-20/SRF20Mock.sol new file mode 100644 index 0000000..99b17aa --- /dev/null +++ b/src/standards/srf-20/SRF20Mock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.22; + +import { SRF20 } from "../../standards/srf-20/SRF20.sol"; + +/// @notice Dummy mock contract for testing the SRF-20 implementation. The difference between this +/// and {SRF20} is that this mock can be deployed. Abstracts cannot be deployed. +/// @dev WARNING: This contract is for testing purposes only. Do not use in production. +contract SRF20Mock is SRF20 { + constructor(string memory name, string memory symbol) SRF20(name, symbol) { } + + function burn(address holder, uint256 amount) external { + _burn(holder, amount); + } + + function mint(address recipient, uint256 amount) external { + _mint(recipient, amount); + } +} diff --git a/test/Base.t.sol b/test/Base.t.sol new file mode 100644 index 0000000..5494129 --- /dev/null +++ b/test/Base.t.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +import { Test } from "forge-std/src/Test.sol"; + +import { SRF20Mock } from "./../src/standards/srf-20/SRF20Mock.sol"; + +import { Constants } from "./utils/Constants.sol"; +import { Defaults } from "./utils/Defaults.sol"; +import { Users } from "./utils/Types.sol"; + +/// @notice Base test contract with common logic needed by all tests. +abstract contract Base_Test is Constants, Test { + /*////////////////////////////////////////////////////////////////////////// + VARIABLES + //////////////////////////////////////////////////////////////////////////*/ + + Users internal users; + + /*////////////////////////////////////////////////////////////////////////// + TEST CONTRACTS + //////////////////////////////////////////////////////////////////////////*/ + + Defaults internal defaults; + SRF20Mock internal usdc; + + /*////////////////////////////////////////////////////////////////////////// + SET-UP FUNCTION + //////////////////////////////////////////////////////////////////////////*/ + + function setUp() public virtual { + // Deploy the base test contracts. + usdc = new SRF20Mock({ name: USDC_NAME, symbol: USDC_SYMBOL }); + + // Label the base test contracts. + vm.label({ account: address(usdc), newLabel: "USDC" }); + + // Deploy the defaults contract. + defaults = new Defaults(); + defaults.setToken(usdc); + + // Create users for testing. + users.alice = createUser("Alice"); + defaults.setUsers(users); + + // Warp to June 1, 2024 at 00:00 GMT to provide a more realistic testing environment. + vm.warp({ newTimestamp: JUNE_1_2024 }); + } + + /*////////////////////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Generates a user and labels its address. + function createUser(string memory name) internal returns (address payable) { + address payable user = payable(makeAddr(name)); + // TODO: dealing requires https://github.com/sablier-labs/stdlib/issues/6 + // vm.deal({ account: user, newBalance: 100 ether }); + return user; + } + + /// @dev Derives the asset ID from the contract's address and the default sub ID. + function getAssetID(SRF20Mock tokenContract) internal view returns (uint256) { + // Concatenate the contract's address and the default sub ID. + bytes memory concatenation = abi.encodePacked(address(tokenContract), defaults.SUB_ID()); + + // Hash the concatenated value. + bytes32 hashed = keccak256(concatenation); + + // Wrap the resultant hash into the expected type. + return uint256(hashed); + } +} diff --git a/test/unit/concrete/srf-20/burn/burn.t.sol b/test/unit/concrete/srf-20/burn/burn.t.sol new file mode 100644 index 0000000..f4e0349 --- /dev/null +++ b/test/unit/concrete/srf-20/burn/burn.t.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +import { ISRF20 } from "src/standards/srf-20/ISRF20.sol"; +import { Base_Test } from "../../../../Base.t.sol"; + +contract Burn_Unit_Concrete_Test is Base_Test { + function test_RevertWhen_TheHolderIsTheZeroAddress() external { + address holder = address(0); + vm.expectRevert(abi.encodeWithSelector(ISRF20.SRF20_InvalidHolder.selector, holder)); + usdc.burn({ holder: holder, amount: 1 }); + } + + modifier whenHolderNotZeroAddress() { + _; + } + + function test_WhenTheHolderIsNotTheZeroAddress() external whenHolderNotZeroAddress { + // TODO + // it should burn the tokens + } +} diff --git a/test/unit/concrete/srf-20/burn/burn.tree b/test/unit/concrete/srf-20/burn/burn.tree new file mode 100644 index 0000000..4a7f580 --- /dev/null +++ b/test/unit/concrete/srf-20/burn/burn.tree @@ -0,0 +1,5 @@ +Burn_Unit_Concrete_Test +├── when the holder is the zero address +│ └── it should revert +└── when the holder is not the zero address + └── it should burn the tokens diff --git a/test/unit/concrete/srf-20/decimals.t.sol b/test/unit/concrete/srf-20/decimals.t.sol new file mode 100644 index 0000000..bee3330 --- /dev/null +++ b/test/unit/concrete/srf-20/decimals.t.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +import { Base_Test } from "../../../Base.t.sol"; + +contract Decimals_Unit_Concrete_Test is Base_Test { + function test_Decimals() external view { + uint8 actualDecimals = usdc.decimals(); + uint8 expectedDecimals = NATIVE_TOKEN_DECIMALS; + assertEq(actualDecimals, expectedDecimals, "decimals"); + } +} diff --git a/test/unit/concrete/srf-20/id.t.sol b/test/unit/concrete/srf-20/id.t.sol new file mode 100644 index 0000000..b059bb7 --- /dev/null +++ b/test/unit/concrete/srf-20/id.t.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +import { Base_Test } from "../../../Base.t.sol"; + +contract ID_Unit_Concrete_Test is Base_Test { + function test_ID() external view { + uint256 actualID = usdc.ID(); + uint256 expectedID = getAssetID(usdc); + assertEq(actualID, expectedID, "ID"); + } +} diff --git a/test/unit/concrete/srf-20/mint/mint.t.sol b/test/unit/concrete/srf-20/mint/mint.t.sol new file mode 100644 index 0000000..b031550 --- /dev/null +++ b/test/unit/concrete/srf-20/mint/mint.t.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +import { ISRF20 } from "src/standards/srf-20/ISRF20.sol"; +import { Base_Test } from "../../../../Base.t.sol"; + +contract Mint_Unit_Concrete_Test is Base_Test { + function test_RevertWhen_TheRecipientIsTheZeroAddress() external { + address recipient = address(0); + vm.expectRevert(abi.encodeWithSelector(ISRF20.SRF20_InvalidRecipient.selector, recipient)); + usdc.mint({ recipient: recipient, amount: 1 }); + } + + modifier whenRecipientNotZeroAddress() { + _; + } + + function test_WhenTheRecipientIsNotTheZeroAddress() external whenRecipientNotZeroAddress { + // TODO + // it should mint the tokens + } +} diff --git a/test/unit/concrete/srf-20/mint/mint.tree b/test/unit/concrete/srf-20/mint/mint.tree new file mode 100644 index 0000000..2301c35 --- /dev/null +++ b/test/unit/concrete/srf-20/mint/mint.tree @@ -0,0 +1,5 @@ +Mint_Unit_Concrete_Test +├── when the recipient is the zero address +│ └── it should revert +└── when the recipient is not the zero address + └── it should mint the tokens diff --git a/test/unit/concrete/srf-20/name.t.sol b/test/unit/concrete/srf-20/name.t.sol new file mode 100644 index 0000000..0a411f3 --- /dev/null +++ b/test/unit/concrete/srf-20/name.t.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +import { Base_Test } from "../../../Base.t.sol"; + +contract Name_Unit_Concrete_Test is Base_Test { + function test_Name() external view { + string memory actualName = usdc.name(); + string memory expectedName = USDC_NAME; + assertEq(actualName, expectedName, "name"); + } +} diff --git a/test/unit/concrete/srf-20/symbol.t.sol b/test/unit/concrete/srf-20/symbol.t.sol new file mode 100644 index 0000000..34d80fd --- /dev/null +++ b/test/unit/concrete/srf-20/symbol.t.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +import { Base_Test } from "../../../Base.t.sol"; + +contract Symbol_Unit_Concrete_Test is Base_Test { + function test_Symbol() external view { + string memory actualSymbol = usdc.symbol(); + string memory expectedSymbol = USDC_SYMBOL; + assertEq(actualSymbol, expectedSymbol, "symbol"); + } +} diff --git a/test/utils/Constants.sol b/test/utils/Constants.sol new file mode 100644 index 0000000..e5887ba --- /dev/null +++ b/test/utils/Constants.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.25; + +abstract contract Constants { + uint40 internal constant JUNE_1_2024 = 1_717_200_000; + uint8 internal constant NATIVE_TOKEN_DECIMALS = 18; + string internal constant USDC_NAME = "USD Coin"; + string internal constant USDC_SYMBOL = "USDC"; +} diff --git a/test/utils/Defaults.sol b/test/utils/Defaults.sol new file mode 100644 index 0000000..3bd1771 --- /dev/null +++ b/test/utils/Defaults.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.22; + +import { ISRF20 } from "./../../src/standards/srf-20/ISRF20.sol"; + +import { Constants } from "./Constants.sol"; +import { Users } from "./Types.sol"; + +/// @notice Collection of default values used throughout the tests. +contract Defaults is Constants { + /*////////////////////////////////////////////////////////////////////////// + STATE VARIABLES + //////////////////////////////////////////////////////////////////////////*/ + + uint256 public constant BURN_AMOUNT = 2500e18; + uint256 public constant MINT_AMOUNT = 10_000e18; + bytes32 public constant SUB_ID = bytes32(0); + + ISRF20 private token; + Users private users; + + /*////////////////////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////////////////////*/ + + function setToken(ISRF20 token_) public { + token = token_; + } + + function setUsers(Users memory users_) public { + users = users_; + } +} diff --git a/test/utils/Types.sol b/test/utils/Types.sol new file mode 100644 index 0000000..7aedb76 --- /dev/null +++ b/test/utils/Types.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.22; + +struct Users { + // Impartial user. + address payable alice; + // Malicious user. + address payable eve; +}