diff --git a/.gitmodules b/.gitmodules index 5183b0c..5f6f19f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,10 @@ +[submodule "rent/lib/forge-std"] + path = dwell/lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "rent/lib/openzeppelin-contracts"] + path = dwell/lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts + [submodule "riff/lib/forge-std"] path = riff/lib/forge-std url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md index 5228730..fd58cf2 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ Welcome to the **Seismic** blockchain's **Prototypes** repository! This repo is ## Purpose - This repository exists for: +This repository exists for: -- **Reference & Examples:** Developers looking to understand the Seismic blockchain or seeking best practices can explore these prototypes as real-world examples. -- **Collaboration:** We encourage contributions, feedback, and discussions about all things Seismic. +- **Reference & Examples:** Developers looking to understand the Seismic blockchain or seeking best practices can explore these prototypes as real-world examples. +- **Collaboration:** We encourage contributions, feedback, and discussions about all things Seismic. Each project in this repo lives in its own directory and includes a dedicated README that provides more details. @@ -15,16 +15,15 @@ Each project in this repo lives in its own directory and includes a dedicated RE Below is a quick summary of each prototype currently available in this repository: -1. **`Riff`** - A bonding curve that you can hear. -2. **`Project 2 here`** - Description here. - +1. **`RIFF`** + Listen to a bonding curve. +1. **`DWELL`** + Pay your rent with a yield-bearing stablecoin. ## Contributing -1. **Fork** this repository. -2. **Create** a new branch for your prototype or feature. +1. **Fork** this repository. +2. **Create** a new branch for your prototype or feature. 3. **Add** your prototype in a new directory, including a `README.md` with setup instructions and information on your project. Additionally, include a 1-2 sentence summary of the project in this top-level `README.md`. 4. **Open** a pull request describing your changes and why they're valuable. @@ -34,7 +33,7 @@ We're excited to see what you build and look forward to collaborating on the fut ## Get in Touch -- **Website:** [Seismic Blockchain](https://www.seismic.systems) -- **Twitter:** [@SeismicSys](https://x.com/SeismicSys) +- **Website:** [Seismic Blockchain](https://www.seismic.systems) +- **Twitter:** [@SeismicSys](https://x.com/SeismicSys) -If you have any questions or want to propose a new idea, please open an issue or reach out on our official channels. Thank you for being an early part of the Seismic ecosystem! \ No newline at end of file +If you have any questions or want to propose a new idea, please open an issue or reach out on our official channels. Thank you for being an early part of the Seismic ecosystem! diff --git a/dwell/.gitignore b/dwell/.gitignore new file mode 100644 index 0000000..8c81651 --- /dev/null +++ b/dwell/.gitignore @@ -0,0 +1,4 @@ +.env +broadcast/ +cache/ +out/ diff --git a/dwell/README.md b/dwell/README.md new file mode 100644 index 0000000..cc09aeb --- /dev/null +++ b/dwell/README.md @@ -0,0 +1,20 @@ +# Privacy-Preserving/Yield-Bearing Tokens + +## Overview + +**Problem**: Traditional rent payments are antiquated, requiring manual processing, offering no yield on deposits, and exposing sensitive financial information. This creates inefficiencies for both tenants and landlords while leaving value on the table. + +**Insight**: Since rental markets operate on predictable payment schedules, there's an opportunity to optimize capital efficiency through automated payments and yield generation. Privacy-preserving mechanisms can protect sensitive financial data while maintaining transparency where needed. + +**Solution**: USDY (USD Yield) implements a privacy-preserving token system for rental payments that generates yield during deposit periods while protecting transaction privacy. Tenants can earn returns on their deposits until rent is due, landlords receive guaranteed on-time payments, and all parties maintain financial privacy through shielded transactions. The system uses a shares-based accounting mechanism to distribute yield fairly among all participants. + +## Architecture + +- `SRC20.sol`: Base privacy-preserving ERC20 implementation using shielded types +- `ISRC20.sol`: Interface for shielded ERC20 functionality +- `USDY.sol`: Yield-bearing USD stablecoin with privacy features +- Comprehensive test suite in `test/` directory + +## License + +AGPL-3.0-only diff --git a/dwell/foundry.toml b/dwell/foundry.toml new file mode 100644 index 0000000..25b918f --- /dev/null +++ b/dwell/foundry.toml @@ -0,0 +1,6 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/dwell/lib/forge-std b/dwell/lib/forge-std new file mode 160000 index 0000000..3b20d60 --- /dev/null +++ b/dwell/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 3b20d60d14b343ee4f908cb8079495c07f5e8981 diff --git a/dwell/lib/openzeppelin-contracts b/dwell/lib/openzeppelin-contracts new file mode 160000 index 0000000..acd4ff7 --- /dev/null +++ b/dwell/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit acd4ff74de833399287ed6b31b4debf6b2b35527 diff --git a/dwell/src/ISRC20.sol b/dwell/src/ISRC20.sol new file mode 100644 index 0000000..5094611 --- /dev/null +++ b/dwell/src/ISRC20.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +pragma solidity ^0.8.20; + +/** + * @dev Interface of the ERC-20 standard as defined in the ERC, modified for shielded types. + */ +interface ISRC20 { + /** + * @dev Function to emit when tokens are moved from one account to another. + * Must be overridden by implementing contracts to define event emission behavior. + * Default implementation is no-op for privacy. + * + * @param from The sender address + * @param to The recipient address + * @param value The transfer amount + */ + function emitTransfer(address from, address to, uint256 value) external; + + /** + * @dev Function to emit when allowance is modified. + * Must be overridden by implementing contracts to define event emission behavior. + * Default implementation is no-op for privacy. + * + * @param owner The token owner + * @param spender The approved spender + * @param value The approved amount + */ + function emitApproval(address owner, address spender, uint256 value) external; + + /** + * @dev Returns the value of tokens in existence. + */ + function totalSupply() external view returns (uint256); + + /** + * @dev Returns the value of tokens owned by `account`. + * For privacy reasons, returns actual balance only if caller is the account owner, + * otherwise reverts. + */ + function balanceOf(saddress account) external view returns (uint256); + + /** + * @dev Moves a shielded `value` amount of tokens from the caller's account to `to`. + * + * Returns a boolean value indicating whether the operation succeeded, + * otherwise reverts. + * + * Expected that implementation calls emitTransfer. + */ + function transfer(saddress to, suint256 value) external returns (bool); + + /** + * @dev Returns the remaining number of tokens that `spender` will be + * allowed to spend on behalf of `owner` through {transferFrom}. This is + * zero by default. + * + * This value changes when {approve} or {transferFrom} are called. + * For privacy reasons, returns actual allowance only if caller is either owner or spender, + * otherwise reverts. + */ + function allowance(saddress owner, saddress spender) external view returns (uint256); + + /** + * @dev Sets a shielded `value` amount of tokens as the allowance of a shielded `spender` over the + * caller's tokens. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Expected that implementation calls emitApproval. + */ + function approve(saddress spender, suint256 value) external returns (bool); + + /** + * @dev Moves a shielded `value` amount of tokens from a shielded `from` address to a shielded `to` address using the + * allowance mechanism. `value` is then deducted from the caller's + * allowance. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Expected that implementation calls emitTransfer. + */ + function transferFrom(saddress from, saddress to, suint256 value) external returns (bool); +} diff --git a/dwell/src/ISRC20Metadata.sol b/dwell/src/ISRC20Metadata.sol new file mode 100644 index 0000000..14e93e7 --- /dev/null +++ b/dwell/src/ISRC20Metadata.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +pragma solidity ^0.8.20; + +import {ISRC20} from "./ISRC20.sol"; + +/** + * @dev Interface for the optional metadata functions from the ERC-20 standard. + */ +interface ISRC20Metadata is ISRC20 { + /** + * @dev Returns the name of the token. + */ + function name() external view returns (string memory); + + /** + * @dev Returns the symbol of the token. + */ + function symbol() external view returns (string memory); + + /** + * @dev Returns the decimals places of the token. + */ + function decimals() external view returns (uint8); +} diff --git a/dwell/src/SRC20.sol b/dwell/src/SRC20.sol new file mode 100644 index 0000000..f475a19 --- /dev/null +++ b/dwell/src/SRC20.sol @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +pragma solidity ^0.8.20; + +import {ISRC20} from "./ISRC20.sol"; +import {ISRC20Metadata} from "./ISRC20Metadata.sol"; +import {Context} from "../lib/openzeppelin-contracts/contracts/utils/Context.sol"; +import {IERC20Errors} from "../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; + +error UnauthorizedView(); + +/** + * @dev Implementation of the {ISRC20} interface with privacy protections using shielded types. + * Public view functions that would leak privacy are implemented as no-ops while maintaining interface compatibility. + * Total supply remains public while individual balances and transfers are private. + */ +abstract contract SRC20 is Context, ISRC20, ISRC20Metadata, IERC20Errors { + mapping(saddress account => suint256) private _balances; + mapping(saddress account => mapping(saddress spender => suint256)) private _allowances; + + uint256 private _totalSupply; + + string private _name; + string private _symbol; + + /** + * @dev Sets the values for {name} and {symbol}. + * + * All two of these values are immutable: they can only be set once during + * construction. + */ + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + } + + /** + * @dev Returns the name of the token. + */ + function name() public view virtual returns (string memory) { + return _name; + } + + /** + * @dev Returns the symbol of the token, usually a shorter version of the + * name. + */ + function symbol() public view virtual returns (string memory) { + return _symbol; + } + + /** + * @dev Returns the number of decimals used to get its user representation. + * For example, if `decimals` equals `2`, a balance of `505` tokens should + * be displayed to a user as `5.05` (`505 / 10 ** 2`). + * + * Tokens usually opt for a value of 18, imitating the relationship between + * Ether and Wei. This is the default value returned by this function, unless + * it's overridden. + * + * NOTE: This information is only used for _display_ purposes: it in + * no way affects any of the arithmetic of the contract, including + * {ISRC20-balanceOf} and {ISRC20-transfer}. + */ + function decimals() public view virtual override returns (uint8) { + return 18; + } + + /** + * @dev See {ISRC20-totalSupply}. + */ + function totalSupply() public view virtual returns (uint256) { + return _totalSupply; + } + + /** + * @dev See {ISRC20-balanceOf}. + * Reverts if caller is not the account owner to maintain privacy. + */ + function balanceOf(saddress account) public view virtual override returns (uint256) { + if (account == saddress(_msgSender())) { + return uint256(_balances[account]); + } + revert UnauthorizedView(); + } + + /** + * @dev Safe version of balanceOf that returns success boolean along with balance. + * Returns (true, balance) if caller is the account owner, (false, 0) otherwise. + */ + function safeBalanceOf(saddress account) public view returns (bool, uint256) { + if (account == saddress(_msgSender())) { + return (true, uint256(_balances[account])); + } + return (false, 0); + } + + /** + * @dev Transfers a shielded `value` amount of tokens to a shielded `to` address. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - the caller must have a balance of at least `value`. + * + * Note: Both `to` and `value` are shielded to maintain privacy. + */ + function transfer(saddress to, suint256 value) public virtual override returns (bool) { + saddress owner = saddress(_msgSender()); + _transfer(owner, to, value); + return true; + } + + /** + * @dev See {ISRC20-allowance}. + * Reverts if caller is neither the owner nor the spender to maintain privacy. + */ + function allowance(saddress owner, saddress spender) public virtual view returns (uint256) { + saddress caller = saddress(_msgSender()); + if (caller == owner || caller == spender) { + return uint256(_allowances[saddress(owner)][saddress(spender)]); + } + revert UnauthorizedView(); + } + + /** + * @dev Safe version of allowance that returns success boolean along with allowance. + * Returns (true, allowance) if caller is owner or spender, (false, 0) otherwise. + */ + function safeAllowance(saddress owner, saddress spender) public view returns (bool, uint256) { + saddress caller = saddress(_msgSender()); + if (caller == owner || caller == spender) { + return (true, uint256(_allowances[saddress(owner)][saddress(spender)])); + } + return (false, 0); + } + + /** + * @dev Approves a shielded `spender` to spend a shielded `value` amount of tokens on behalf of the caller. + * + * NOTE: If `value` is the maximum `suint256`, the allowance is not updated on + * `transferFrom`. This is semantically equivalent to an infinite approval. + * + * WARNING: Changing an allowance with this method can have security implications. When changing an approved + * allowance to a specific value, a race condition may occur if another transaction is submitted before + * the original allowance change is confirmed. To safely adjust allowances, use {increaseAllowance} and + * {decreaseAllowance} which provide atomic operations protected against such race conditions. + * + * Requirements: + * + * - `spender` cannot be the zero address. + * + * Note: Both `spender` and `value` are shielded to maintain privacy. + */ + function approve(saddress spender, suint256 value) public virtual override returns (bool) { + saddress owner = saddress(_msgSender()); + _approve(owner, spender, value); + return true; + } + + /** + * @dev Transfers a shielded `value` amount of tokens from a shielded `from` address to a shielded `to` address. + * + * Skips emitting an {Approval} event indicating an allowance update to maintain privacy. + * + * NOTE: Does not update the allowance if the current allowance + * is the maximum `suint256`. + * + * Requirements: + * + * - `from` and `to` cannot be the zero address. + * - `from` must have a balance of at least `value`. + * - the caller must have allowance for ``from``'s tokens of at least + * `value`. + * + * Note: All parameters are shielded to maintain privacy. + */ + function transferFrom(saddress from, saddress to, suint256 value) public virtual returns (bool) { + saddress spender = saddress(_msgSender()); + _spendAllowance(from, spender, value); + _transfer(from, to, value); + return true; + } + + /** + * @dev Atomically increases the allowance granted to a shielded `spender` by a shielded `addedValue`. + * + * This is an alternative to {approve} that can be used as a mitigation for + * problems described in {ISRC20-approve}. + * + * The operation is atomic - it directly accesses and modifies the underlying + * shielded allowance mapping to prevent race conditions. + * + * Requirements: + * + * - `spender` cannot be the zero address. + * - The sum of current allowance and `addedValue` must not overflow. + * + * Note: Both `spender` and `addedValue` are shielded to maintain privacy. + */ + function increaseAllowance(saddress spender, suint256 addedValue) public virtual returns (bool) { + saddress owner = saddress(_msgSender()); + suint256 currentAllowance = _allowances[owner][spender]; + _approve(owner, spender, currentAllowance + addedValue); + return true; + } + + /** + * @dev Atomically decreases the allowance granted to a shielded `spender` by a shielded `subtractedValue`. + * + * This is an alternative to {approve} that can be used as a mitigation for + * problems described in {ISRC20-approve}. + * + * The operation is atomic - it directly accesses and modifies the underlying + * shielded allowance mapping to prevent race conditions. + * + * Requirements: + * + * - `spender` cannot be the zero address. + * - The current allowance must be greater than or equal to `subtractedValue`. + * - The difference between the current allowance and `subtractedValue` must not underflow. + * + * Note: Both `spender` and `subtractedValue` are shielded to maintain privacy. + */ + function decreaseAllowance(saddress spender, suint256 subtractedValue) public virtual returns (bool) { + saddress owner = saddress(_msgSender()); + suint256 currentAllowance = _allowances[owner][spender]; + if (currentAllowance < subtractedValue) { + revert ERC20InsufficientAllowance(address(spender), 0, 0); + } + unchecked { + _approve(owner, spender, currentAllowance - subtractedValue); + } + return true; + } + + /** + * @dev Moves a shielded `value` amount of tokens from a shielded `from` to a shielded `to` address. + * + * This internal function is equivalent to {transfer}, and can be used to + * e.g. implement automatic token fees, slashing mechanisms, etc. + * + * Calls emitTransfer. + * + * NOTE: This function is not virtual, {_update} should be overridden instead. + */ + function _transfer(saddress from, saddress to, suint256 value) internal { + if (from == saddress(address(0))) { + revert ERC20InvalidSender(address(0)); + } + if (to == saddress(address(0))) { + revert ERC20InvalidReceiver(address(0)); + } + _update(from, to, value); + } + + /** + * @dev Transfers a `value` amount of tokens from `from` to `to`, or alternatively mints (or burns) if `from` + * (or `to`) is the zero address. All customizations to transfers, mints, and burns should be done by overriding + * this function. + * + * Calls `emitTransferEvent`. + */ + function _update(saddress from, saddress to, suint256 value) internal virtual { + _beforeTokenTransfer(from, to, value); + + if (from == saddress(address(0))) { + // Convert from shielded to unshielded for total supply + _totalSupply += uint256(value); + } else { + suint256 fromBalance = _balances[from]; + if (fromBalance < value) { + revert ERC20InsufficientBalance(address(from), uint256(0), uint256(0)); + } + unchecked { + _balances[from] = fromBalance - value; + } + } + + if (to == saddress(address(0))) { + unchecked { + // Convert from shielded to unshielded for total supply + _totalSupply -= uint256(value); + } + } else { + unchecked { + _balances[to] += value; + } + } + + emitTransfer(address(from), address(to), uint256(value)); + + _afterTokenTransfer(from, to, value); + } + + /** + * @dev Creates a shielded `value` amount of tokens and assigns them to a shielded `account`. + * Relies on the `_update` mechanism. + * + * Calls emitTransfer. + * + * NOTE: This function is not virtual, {_update} should be overridden instead. + */ + function _mint(saddress account, suint256 value) internal { + if (account == saddress(address(0))) { + revert ERC20InvalidReceiver(address(0)); + } + _update(saddress(address(0)), account, value); + } + + /** + * @dev Destroys a shielded `value` amount of tokens from a shielded `account`, lowering the total supply. + * Relies on the `_update` mechanism. + * + * Calls emitTransfer. + * + * NOTE: This function is not virtual, {_update} should be overridden instead + */ + function _burn(saddress account, suint256 value) internal { + if (account == saddress(address(0))) { + revert ERC20InvalidSender(address(0)); + } + _update(account, saddress(address(0)), value); + } + + /** + * @dev Sets `value` as the allowance of `spender` over the `owner` s tokens. + * + * This internal function is equivalent to `approve`, and can be used to + * e.g. set automatic allowances for certain subsystems, etc. + * + * Calls emitApproval which is a no-op by default for privacy. + * + * Requirements: + * + * - `owner` cannot be the zero address. + * - `spender` cannot be the zero address. + */ + function _approve(saddress owner, saddress spender, suint256 value) internal virtual { + if (owner == saddress(address(0))) { + revert ERC20InvalidApprover(address(0)); + } + if (spender == saddress(address(0))) { + revert ERC20InvalidSpender(address(0)); + } + _allowances[owner][spender] = value; + emitApproval(address(owner), address(spender), uint256(value)); + } + + /** + * @dev Updates `owner` s allowance for `spender` based on spent `value`. + * + * Does not update the allowance value in case of infinite allowance. + * Revert if not enough allowance is available. + * + * Does not emit an {Approval} event. + */ + function _spendAllowance(saddress owner, saddress spender, suint256 value) internal virtual { + suint256 currentAllowance = _allowances[owner][spender]; + if (currentAllowance < type(suint256).max) { + if (currentAllowance < value) { + revert ERC20InsufficientAllowance(address(spender), uint256(0), uint256(0)); // Zero values to protect privacy + } + unchecked { + _approve(owner, spender, currentAllowance - value); + } + } + } + + /** + * @dev Hook that is called before any transfer of tokens. This includes + * minting and burning. + * + * Calling conditions: + * + * - when `from` and `to` are both non-zero, `value` of ``from``'s tokens + * will be transferred to `to`. + * - when `from` is zero, `value` tokens will be minted for `to`. + * - when `to` is zero, `value` of ``from``'s tokens will be burned. + * - `from` and `to` are never both zero. + * + * Note: The `value` parameter is a shielded uint256 to maintain privacy. + * + * To learn more about hooks, head to xref:ROOT:extending-contracts.adoc#using-hooks[Using Hooks]. + */ + function _beforeTokenTransfer(saddress from, saddress to, suint256 value) internal virtual {} + + /** + * @dev Hook that is called after any transfer of tokens. This includes + * minting and burning. + * + * Calling conditions: + * + * - when `from` and `to` are both non-zero, `value` of ``from``'s tokens + * has been transferred to `to`. + * - when `from` is zero, `value` tokens have been minted for `to`. + * - when `to` is zero, `value` of ``from``'s tokens have been burned. + * - `from` and `to` are never both zero. + * + * Note: The `value` parameter is a shielded uint256 to maintain privacy. + * + * To learn more about hooks, head to xref:ROOT:extending-contracts.adoc#using-hooks[Using Hooks]. + */ + function _afterTokenTransfer(saddress from, saddress to, suint256 value) internal virtual {} + + /** + * @dev Implementation of emitTransfer. No-op by default for privacy. + * Can be overridden to implement custom event emission behavior. + */ + function emitTransfer(address from, address to, uint256 value) public virtual override { + // No-op by default + } + + /** + * @dev Implementation of emitApproval. No-op by default for privacy. + * Can be overridden to implement custom event emission behavior. + */ + function emitApproval(address owner, address spender, uint256 value) public virtual override { + // No-op by default + } +} diff --git a/dwell/src/USDY.sol b/dwell/src/USDY.sol new file mode 100644 index 0000000..fbcbdac --- /dev/null +++ b/dwell/src/USDY.sol @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +pragma solidity ^0.8.20; + +import {SRC20} from "./SRC20.sol"; + +/** + * @title USDY - Yield-bearing USD Stablecoin with Privacy Features + * @notice A yield-bearing stablecoin that uses shielded types for privacy protection + * @dev Implements SRC20 for shielded balances and transfers. This is a final implementation, not meant to be inherited from. + */ +contract USDY is SRC20 { + // Base value for rewardMultiplier (18 decimals) + uint256 private constant BASE = 1e18; + + // Current reward multiplier, represents accumulated yield + suint256 private rewardMultiplier; + + // Shielded shares storage + mapping(saddress => suint256) private _shares; + suint256 private _totalShares; + + // Access control roles + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); + bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE"); + bytes32 public constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); + bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; + + // Role management + mapping(bytes32 => mapping(address => bool)) private _roles; + + // Pause state + bool private _paused; + + // Events + event RewardMultiplierUpdated(uint256 newMultiplier); + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); + event Paused(address account); + event Unpaused(address account); + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + // Custom errors + error InvalidRewardMultiplier(uint256 multiplier); + error ZeroRewardIncrement(); + error MissingRole(bytes32 role, address account); + error TransferWhilePaused(); + error UnauthorizedView(); + + /** + * @notice Constructs the USDY contract + * @param admin The address that will have admin rights + */ + constructor(address admin) SRC20("USD Yield", "USDY") { + if (admin == address(0)) revert ERC20InvalidReceiver(admin); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + rewardMultiplier = suint256(BASE); // Initialize with 1.0 multiplier + } + + /** + * @notice Returns the number of decimals used to get its user representation. + */ + function decimals() public pure override returns (uint8) { + return 18; + } + + /** + * @notice Converts an amount of tokens to shares + * @param amount The amount of tokens to convert + * @return The equivalent amount of shares + */ + function convertToShares(suint256 amount) internal view returns (suint256) { + if (uint256(rewardMultiplier) == 0) return amount; + return (amount * suint256(BASE)) / rewardMultiplier; + } + + /** + * @notice Converts an amount of shares to tokens + * @param shares The amount of shares to convert + * @return The equivalent amount of tokens + */ + function convertToTokens(suint256 shares) internal view returns (suint256) { + return (shares * rewardMultiplier) / suint256(BASE); + } + + /** + * @notice Returns the total amount of shares + * @return The total amount of shares + */ + function totalShares() public view returns (uint256) { + return uint256(_totalShares); + } + + /** + * @notice Returns the total supply of tokens + * @return The total supply of tokens, accounting for yield + */ + function totalSupply() public view override returns (uint256) { + return uint256(convertToTokens(_totalShares)); + } + + /** + * @notice Returns the current reward multiplier + * @return The current reward multiplier value + */ + function getCurrentRewardMultiplier() public view returns (uint256) { + return uint256(rewardMultiplier); + } + + /** + * @notice Returns the amount of shares owned by the account + * @param account The account to check + * @return The amount of shares owned by the account, or 0 if caller is not the account owner + */ + function sharesOf(saddress account) public view returns (uint256) { + // Only return shares if caller is the account owner + if (account == saddress(_msgSender())) { + return uint256(_shares[account]); + } + return 0; + } + + /** + * @notice Override balanceOf to calculate balance based on shares and current reward multiplier + * @param account The account to check the balance of + * @return The current balance in tokens, accounting for yield + */ + function balanceOf(saddress account) public view override returns (uint256) { + // Only return balance if caller is the account owner + if (account == saddress(_msgSender())) { + return uint256(convertToTokens(_shares[account])); + } + return 0; + } + + /** + * @notice Modifier that checks if the caller has a specific role + */ + modifier onlyRole(bytes32 role) { + address sender = _msgSender(); + if (!hasRole(role, sender)) { + revert MissingRole(role, sender); + } + _; + } + + /** + * @notice Modifier to make a function callable only when the contract is not paused + */ + modifier whenNotPaused() { + if (_paused) revert TransferWhilePaused(); + _; + } + + /** + * @notice Returns true if `account` has been granted `role` + */ + function hasRole(bytes32 role, address account) public view returns (bool) { + if (account == address(0)) return false; + return _roles[role][account]; + } + + /** + * @notice Grants `role` to `account` + * @dev The caller must have the admin role + */ + function grantRole(bytes32 role, address account) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (account == address(0)) revert ERC20InvalidReceiver(account); + _grantRole(role, account); + } + + /** + * @notice Revokes `role` from `account` + * @dev The caller must have the admin role + */ + function revokeRole(bytes32 role, address account) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (account == address(0)) revert ERC20InvalidSender(account); + _revokeRole(role, account); + } + + /** + * @notice Internal function to grant a role to an account + */ + function _grantRole(bytes32 role, address account) internal { + if (!hasRole(role, account)) { + _roles[role][account] = true; + emit RoleGranted(role, account, _msgSender()); + } + } + + /** + * @notice Internal function to revoke a role from an account + */ + function _revokeRole(bytes32 role, address account) internal { + if (hasRole(role, account)) { + _roles[role][account] = false; + emit RoleRevoked(role, account, _msgSender()); + } + } + + /** + * @notice Returns true if the contract is paused, and false otherwise + */ + function paused() public view returns (bool) { + return _paused; + } + + /** + * @notice Updates the reward multiplier to reflect new yield + * @param increment The amount to increase the multiplier by + */ + function addRewardMultiplier(uint256 increment) external onlyRole(ORACLE_ROLE) { + if (increment == 0) revert ZeroRewardIncrement(); + + uint256 newMultiplierValue = uint256(rewardMultiplier) + increment; + if (newMultiplierValue < BASE) { + revert InvalidRewardMultiplier(newMultiplierValue); + } + + rewardMultiplier = suint256(newMultiplierValue); + emit RewardMultiplierUpdated(newMultiplierValue); + } + + /** + * @notice Mints new tokens to a shielded address + * @param to The shielded address to mint to + * @param amount The shielded amount to mint + */ + function mint(saddress to, suint256 amount) external onlyRole(MINTER_ROLE) whenNotPaused { + _mint(to, amount); + } + + /** + * @notice Burns tokens from a shielded address + * @param from The shielded address to burn from + * @param amount The shielded amount to burn + */ + function burn(saddress from, suint256 amount) external onlyRole(BURNER_ROLE) whenNotPaused { + _burn(from, amount); + } + + /** + * @notice Pauses all token transfers + */ + function pause() external onlyRole(PAUSE_ROLE) { + if (_paused) revert TransferWhilePaused(); + _paused = true; + emit Paused(_msgSender()); + } + + /** + * @notice Unpauses all token transfers + */ + function unpause() external onlyRole(PAUSE_ROLE) { + if (!_paused) revert TransferWhilePaused(); + _paused = false; + emit Unpaused(_msgSender()); + } + + /** + * @dev Override _update to account for shielded shares rather than raw token balances + * @param from The sender address + * @param to The recipient address + * @param value The amount of tokens to transfer + */ + function _update(saddress from, saddress to, suint256 value) internal override { + _beforeTokenTransfer(from, to, value); + + suint256 shares = convertToShares(value); + + if (from == saddress(address(0))) { + // Minting + _totalShares += shares; + _shares[to] += shares; + } else if (to == saddress(address(0))) { + // Burning + suint256 fromShares = _shares[from]; + if (fromShares < shares) { + revert ERC20InsufficientBalance(address(from), uint256(convertToTokens(fromShares)), uint256(value)); + } + unchecked { + _shares[from] = fromShares - shares; + _totalShares -= shares; + } + } else { + // Transfer + suint256 fromShares = _shares[from]; + if (fromShares < shares) { + revert ERC20InsufficientBalance(address(from), uint256(convertToTokens(fromShares)), uint256(value)); + } + unchecked { + _shares[from] = fromShares - shares; + _shares[to] += shares; + } + } + + emit Transfer(address(from), address(to), uint256(value)); + + _afterTokenTransfer(from, to, value); + } + + /** + * @notice Hook that is called before any transfer + * @dev Adds pausable functionality to transfers + */ + function _beforeTokenTransfer(saddress from, saddress to, suint256 value) internal override { + if (_paused) revert TransferWhilePaused(); + } + + /** + * @notice Hook that is called after any transfer + */ + function _afterTokenTransfer(saddress from, saddress to, suint256 value) internal override { + // No additional functionality needed after transfer + } + + /** + * @notice Transfers a specified number of tokens from the caller's address to the recipient. + * @dev Uses new _update for share-based accounting while maintaining token-based amount parameter + * @param to The shielded address to which tokens will be transferred. + * @param amount The shielded number of tokens to transfer. + * @return A boolean value indicating whether the operation succeeded. + */ + function transfer(saddress to, suint256 amount) public override whenNotPaused returns (bool) { + if (to == saddress(address(0))) { + revert ERC20InvalidReceiver(address(0)); + } + _update(saddress(_msgSender()), to, amount); + return true; + } + + /** + * @notice Transfers tokens from one address to another using the allowance mechanism + * @dev Uses new _update for share-based accounting while maintaining token-based amount parameter + * @param from The shielded address to transfer from + * @param to The shielded address to transfer to + * @param amount The shielded amount to transfer + * @return A boolean value indicating whether the operation succeeded + */ + function transferFrom(saddress from, saddress to, suint256 amount) public override whenNotPaused returns (bool) { + address spender = _msgSender(); + + if (from == saddress(address(0))) { + revert ERC20InvalidSender(address(0)); + } + if (to == saddress(address(0))) { + revert ERC20InvalidReceiver(address(0)); + } + + uint256 currentAllowance = allowance(from, saddress(spender)); + if (currentAllowance < uint256(amount)) { + revert ERC20InsufficientAllowance(spender, currentAllowance, uint256(amount)); + } + + _spendAllowance(from, saddress(spender), amount); + _update(from, to, amount); + return true; + } + + /** + * @dev See {ISRC20-allowance}. + * @notice Returns the amount of tokens the spender is allowed to spend on behalf of the owner. + * @dev Reverts with UnauthorizedView if the caller is neither the owner nor the spender. + */ + function allowance(saddress owner, saddress spender) public view override returns (uint256) { + if (owner != saddress(_msgSender()) && spender != saddress(_msgSender())) { + revert UnauthorizedView(); + } + return super.allowance(owner, spender); + } + + /** + * @notice Sets `amount` as the allowance of `spender` over the caller's tokens + * @dev Adds pausable functionality on top of SRC20's approve + * @param spender The address which will spend the funds + * @param amount The amount of tokens to be spent + * @return A boolean value indicating whether the operation succeeded + */ + function approve(saddress spender, suint256 amount) public override whenNotPaused returns (bool) { + address owner = _msgSender(); + if (owner == address(0)) revert ERC20InvalidSpender(address(0)); + return super.approve(spender, amount); + } + + /** + * @notice Atomically increases the allowance granted to `spender` by the caller + * @dev Adds pausable functionality on top of SRC20's increaseAllowance + * @param spender The address which will spend the funds + * @param addedValue The amount of tokens to increase the allowance by + * @return A boolean value indicating whether the operation succeeded + */ + function increaseAllowance(saddress spender, suint256 addedValue) public virtual override whenNotPaused returns (bool) { + return super.increaseAllowance(spender, addedValue); + } + + /** + * @notice Atomically decreases the allowance granted to `spender` by the caller + * @dev Adds pausable functionality on top of SRC20's decreaseAllowance + * @param spender The address which will spend the funds + * @param subtractedValue The amount of tokens to decrease the allowance by + * @return A boolean value indicating whether the operation succeeded + */ + function decreaseAllowance(saddress spender, suint256 subtractedValue) public virtual override whenNotPaused returns (bool) { + address owner = _msgSender(); + uint256 currentAllowance = allowance(saddress(owner), spender); + uint256 subtractedAmount = uint256(subtractedValue); + if (currentAllowance < subtractedAmount) { + revert ERC20InsufficientAllowance(address(spender), currentAllowance, subtractedAmount); + } + unchecked { + return super.decreaseAllowance(spender, subtractedValue); + } + } + + // Override the event emission functions to emit actual events + function emitTransfer(address from, address to, uint256 value) public virtual override { + emit Transfer(from, to, value); + } + + function emitApproval(address owner, address spender, uint256 value) public virtual override { + emit Approval(owner, spender, value); + } +} diff --git a/dwell/test/SRC20.t.sol b/dwell/test/SRC20.t.sol new file mode 100644 index 0000000..f441392 --- /dev/null +++ b/dwell/test/SRC20.t.sol @@ -0,0 +1,878 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {SRC20, UnauthorizedView} from "../src/SRC20.sol"; +import {IERC20Errors} from "../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; + +contract TestSRC20 is SRC20 { + constructor(string memory name, string memory symbol) SRC20(name, symbol) {} + + function mint(saddress account, suint256 value) public { + _mint(account, value); + } + + function burn(saddress account, suint256 value) public { + _burn(account, value); + } +} + +contract TestSRC20Decimals is SRC20 { + uint8 private immutable _decimals; + + constructor(string memory name, string memory symbol, uint8 decimals_) SRC20(name, symbol) { + _decimals = decimals_; + } + + function mint(saddress account, suint256 value) public { + _mint(account, value); + } + + function burn(saddress account, suint256 value) public { + _burn(account, value); + } + + function decimals() public view virtual override returns (uint8) { + return _decimals; + } +} + +contract TestSRC20WithEvents is TestSRC20 { + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory name, string memory symbol) TestSRC20(name, symbol) {} + + function emitTransfer(address from, address to, uint256 value) public virtual override { + emit Transfer(from, to, value); + } + + function emitApproval(address owner, address spender, uint256 value) public virtual override { + emit Approval(owner, spender, value); + } +} + +contract SRC20Test is Test { + TestSRC20WithEvents public token; + address public initialHolder; + address public recipient; + address public anotherAccount; + uint256 public initialSupply; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function setUp() public { + initialHolder = address(1); + recipient = address(2); + anotherAccount = address(3); + initialSupply = 100 * 10**18; // 100 tokens with 18 decimals + + token = new TestSRC20WithEvents("My Token", "MTKN"); + token.mint(saddress(initialHolder), suint256(initialSupply)); + } + + function test_Metadata() public view { + assertEq(token.name(), "My Token"); + assertEq(token.symbol(), "MTKN"); + assertEq(token.decimals(), 18); + } + + function test_TotalSupply() public view { + assertEq(token.totalSupply(), initialSupply); + } + + function test_BalanceOf() public { + // When checking own balance + vm.prank(initialHolder); + assertEq(token.balanceOf(saddress(initialHolder)), initialSupply); + + // When checking other's balance (should revert) + vm.expectRevert(UnauthorizedView.selector); + token.balanceOf(saddress(initialHolder)); + } + + function test_Transfer() public { + uint256 transferAmount = 50 * 10**18; + + vm.prank(initialHolder); + token.transfer(saddress(recipient), suint256(transferAmount)); + + // Check balances + vm.prank(initialHolder); + assertEq(token.balanceOf(saddress(initialHolder)), initialSupply - transferAmount); + + vm.prank(recipient); + assertEq(token.balanceOf(saddress(recipient)), transferAmount); + } + + function test_TransferFailsForInsufficientBalance() public { + uint256 transferAmount = initialSupply + 1; + + vm.prank(initialHolder); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, initialHolder, 0, 0)); + token.transfer(saddress(recipient), suint256(transferAmount)); + } + + function test_TransferFailsForZeroAddress() public { + vm.prank(initialHolder); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidReceiver.selector, address(0))); + token.transfer(saddress(address(0)), suint256(1)); + } + + function test_Approve() public { + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(initialSupply)); + + // Check allowance (visible only to owner or spender) + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(recipient)), initialSupply); + + vm.prank(recipient); + assertEq(token.allowance(saddress(initialHolder), saddress(recipient)), initialSupply); + + // Check allowance (should revert for others) + vm.prank(anotherAccount); + vm.expectRevert(UnauthorizedView.selector); + token.allowance(saddress(initialHolder), saddress(recipient)); + } + + function test_TransferFrom() public { + uint256 transferAmount = 50 * 10**18; + + // Approve first + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(transferAmount)); + + // Transfer using transferFrom + vm.prank(recipient); + token.transferFrom(saddress(initialHolder), saddress(anotherAccount), suint256(transferAmount)); + + // Check balances + vm.prank(initialHolder); + assertEq(token.balanceOf(saddress(initialHolder)), initialSupply - transferAmount); + + vm.prank(anotherAccount); + assertEq(token.balanceOf(saddress(anotherAccount)), transferAmount); + + // Check allowance is reduced + vm.prank(recipient); + assertEq(token.allowance(saddress(initialHolder), saddress(recipient)), 0); + } + + function test_TransferFromFailsWithoutAllowance() public { + vm.prank(recipient); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, recipient, 0, 0)); + token.transferFrom(saddress(initialHolder), saddress(anotherAccount), suint256(1)); + } + + function test_MintToZeroAddress() public { + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidReceiver.selector, address(0))); + token.mint(saddress(address(0)), suint256(1)); + } + + function test_BurnFromZeroAddressReverts() public { + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidSender.selector, address(0))); + token.burn(saddress(address(0)), suint256(100)); + } + + function test_BurnExceedingBalanceReverts() public { + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, initialHolder, 0, 0)); + token.burn(saddress(initialHolder), suint256(initialSupply + 1)); + } + + // Transfer Events Tests + + function test_TransferEmitsEvent() public { + uint256 transferAmount = 50 * 10**18; + + vm.expectEmit(true, true, false, true); + emit Transfer(initialHolder, recipient, transferAmount); + + vm.prank(initialHolder); + token.transfer(saddress(recipient), suint256(transferAmount)); + } + + function test_MintEmitsTransferEvent() public { + uint256 mintAmount = 100 * 10**18; + + vm.expectEmit(true, true, false, true); + emit Transfer(address(0), recipient, mintAmount); + + token.mint(saddress(recipient), suint256(mintAmount)); + } + + function test_BurnEmitsTransferEvent() public { + uint256 burnAmount = 50 * 10**18; + + vm.expectEmit(true, true, false, true); + emit Transfer(initialHolder, address(0), burnAmount); + + token.burn(saddress(initialHolder), suint256(burnAmount)); + } + + function test_TransferFromEmitsTransferEvent() public { + uint256 transferAmount = 50 * 10**18; + + // Approve first + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(transferAmount)); + + vm.expectEmit(true, true, false, true); + emit Transfer(initialHolder, anotherAccount, transferAmount); + + vm.prank(recipient); + token.transferFrom(saddress(initialHolder), saddress(anotherAccount), suint256(transferAmount)); + } + + function test_ZeroValueTransferEmitsEvent() public { + vm.expectEmit(true, true, false, true); + emit Transfer(initialHolder, recipient, 0); + + vm.prank(initialHolder); + token.transfer(saddress(recipient), suint256(0)); + } + + // Infinite Approval Tests + + function test_InfiniteApprovalRemainsUnchanged() public { + // Approve with max uint256 + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(type(uint256).max)); + + // Do a transferFrom + vm.prank(recipient); + token.transferFrom(saddress(initialHolder), saddress(anotherAccount), suint256(50 * 10**18)); + + // Check that allowance is still infinite + vm.prank(recipient); + assertEq(token.allowance(saddress(initialHolder), saddress(recipient)), type(uint256).max); + } + + function test_InfiniteApprovalNoEventOnTransferFrom() public { + // Setup infinite approval + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(type(uint256).max)); + + vm.expectEmit(true, true, false, true); + emit Transfer(initialHolder, anotherAccount, 50 * 10**18); + + vm.prank(recipient); + token.transferFrom(saddress(initialHolder), saddress(anotherAccount), suint256(50 * 10**18)); + } + + function test_InfiniteApprovalMultipleTransfers() public { + uint256 transferAmount = 20 * 10**18; + + // Setup infinite approval + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(type(uint256).max)); + + // Do multiple transfers + for(uint256 i = 0; i < 3; i++) { + vm.prank(recipient); + token.transferFrom(saddress(initialHolder), saddress(anotherAccount), suint256(transferAmount)); + + // Check allowance remains infinite + vm.prank(recipient); + assertEq(token.allowance(saddress(initialHolder), saddress(recipient)), type(uint256).max); + } + + // Verify final balances + vm.prank(initialHolder); + assertEq(token.balanceOf(saddress(initialHolder)), initialSupply - (transferAmount * 3)); + + vm.prank(anotherAccount); + assertEq(token.balanceOf(saddress(anotherAccount)), transferAmount * 3); + } + + function test_InfiniteApprovalFailsWithInsufficientBalance() public { + // Setup infinite approval + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(type(uint256).max)); + + // Try to transfer more than balance + vm.prank(recipient); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, initialHolder, 0, 0)); + token.transferFrom(saddress(initialHolder), saddress(anotherAccount), suint256(initialSupply + 1)); + + // Allowance should still be infinite + vm.prank(recipient); + assertEq(token.allowance(saddress(initialHolder), saddress(recipient)), type(uint256).max); + } + + // Approve Edge Cases Tests + + function test_ApproveEmitsNoEvent() public { + // No event expectation since emitApproval is a no-op by default + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(50 * 10**18)); + + // Verify the approval was still set + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(recipient)), 50 * 10**18); + } + + function test_ApproveFromZeroAddress() public { + vm.prank(address(0)); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidApprover.selector, address(0))); + token.approve(saddress(recipient), suint256(100)); + } + + function test_ApproveToZeroAddress() public { + vm.prank(initialHolder); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidSpender.selector, address(0))); + token.approve(saddress(address(0)), suint256(100)); + } + + function test_ApproveReplacesExistingValue() public { + // First approval + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(100)); + + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(recipient)), 100); + + // Replace with new value + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(200)); + + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(recipient)), 200); + } + + function test_ApproveZeroValue() public { + // Initial non-zero approval + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(100)); + + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(0)); + + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(recipient)), 0); + } + + function test_ApproveDoesNotRequireBalance() public { + uint256 largeAmount = initialSupply * 100; + + // Approve more than balance + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(largeAmount)); + + // Check allowance is set despite insufficient balance + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(recipient)), largeAmount); + + // But transferFrom should still fail + vm.prank(recipient); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, initialHolder, 0, 0)); + token.transferFrom(saddress(initialHolder), saddress(anotherAccount), suint256(largeAmount)); + } + + function test_ApproveTwiceNoEvents() public { + // No event expectations since emitApproval is a no-op by default + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(100)); + + vm.prank(initialHolder); + token.approve(saddress(recipient), suint256(200)); + + // Verify the final approval was set + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(recipient)), 200); + } +} + +contract SRC20DecimalsTest is Test { + TestSRC20Decimals public token6; + TestSRC20Decimals public token0; + address public holder; + address public recipient; + + event Transfer(address indexed from, address indexed to, uint256 value); + + function setUp() public { + holder = address(1); + recipient = address(2); + + // Create tokens with different decimal configurations + token6 = new TestSRC20Decimals("Six Decimals", "SIX", 6); + token0 = new TestSRC20Decimals("Zero Decimals", "ZERO", 0); + } + + function test_CustomDecimals() public view { + assertEq(token6.decimals(), 6); + assertEq(token0.decimals(), 0); + } + + function test_MintWithSixDecimals() public { + uint256 amount = 100 * 10**6; // 100 tokens with 6 decimals + token6.mint(saddress(holder), suint256(amount)); + + vm.prank(holder); + assertEq(token6.balanceOf(saddress(holder)), amount); + assertEq(token6.totalSupply(), amount); + } + + function test_MintWithZeroDecimals() public { + uint256 amount = 100; // 100 tokens with 0 decimals + token0.mint(saddress(holder), suint256(amount)); + + vm.prank(holder); + assertEq(token0.balanceOf(saddress(holder)), amount); + assertEq(token0.totalSupply(), amount); + } + + function test_TransferWithSixDecimals() public { + uint256 amount = 100 * 10**6; // 100 tokens with 6 decimals + token6.mint(saddress(holder), suint256(amount)); + + vm.prank(holder); + token6.transfer(saddress(recipient), suint256(50 * 10**6)); + + vm.prank(holder); + assertEq(token6.balanceOf(saddress(holder)), 50 * 10**6); + + vm.prank(recipient); + assertEq(token6.balanceOf(saddress(recipient)), 50 * 10**6); + } + + function test_TransferWithZeroDecimals() public { + uint256 amount = 100; // 100 tokens with 0 decimals + token0.mint(saddress(holder), suint256(amount)); + + vm.prank(holder); + token0.transfer(saddress(recipient), suint256(50)); + + vm.prank(holder); + assertEq(token0.balanceOf(saddress(holder)), 50); + + vm.prank(recipient); + assertEq(token0.balanceOf(saddress(recipient)), 50); + } + + function test_SmallestUnitTransferSixDecimals() public { + uint256 amount = 100 * 10**6; // 100 tokens with 6 decimals + token6.mint(saddress(holder), suint256(amount)); + + // Transfer 1 unit (0.000001 token) + vm.prank(holder); + token6.transfer(saddress(recipient), suint256(1)); + + vm.prank(holder); + assertEq(token6.balanceOf(saddress(holder)), amount - 1); + + vm.prank(recipient); + assertEq(token6.balanceOf(saddress(recipient)), 1); + } + + function test_SmallestUnitTransferZeroDecimals() public { + uint256 amount = 100; // 100 tokens with 0 decimals + token0.mint(saddress(holder), suint256(amount)); + + // Transfer 1 unit (1 whole token for 0 decimals) + vm.prank(holder); + token0.transfer(saddress(recipient), suint256(1)); + + vm.prank(holder); + assertEq(token0.balanceOf(saddress(holder)), amount - 1); + + vm.prank(recipient); + assertEq(token0.balanceOf(saddress(recipient)), 1); + } + + function test_MaxSupplyWithDifferentDecimals() public { + // Test max supply with 6 decimals + uint256 maxAmount6 = type(uint256).max; + token6.mint(saddress(holder), suint256(maxAmount6)); + assertEq(token6.totalSupply(), maxAmount6); + + // Test max supply with 0 decimals + uint256 maxAmount0 = type(uint256).max; + token0.mint(saddress(holder), suint256(maxAmount0)); + assertEq(token0.totalSupply(), maxAmount0); + } + + function test_BurnWithDifferentDecimals() public { + // Test burning with 6 decimals + uint256 amount6 = 100 * 10**6; + token6.mint(saddress(holder), suint256(amount6)); + token6.burn(saddress(holder), suint256(50 * 10**6)); + assertEq(token6.totalSupply(), 50 * 10**6); + + // Test burning with 0 decimals + uint256 amount0 = 100; + token0.mint(saddress(holder), suint256(amount0)); + token0.burn(saddress(holder), suint256(50)); + assertEq(token0.totalSupply(), 50); + } +} + +contract SRC20AllowanceTest is Test { + TestSRC20WithEvents public token; + address public initialHolder; + address public spender; + address public otherAccount; + uint256 public initialSupply; + + event Approval(address indexed owner, address indexed spender, uint256 value); + + function setUp() public { + initialHolder = address(1); + spender = address(2); + otherAccount = address(3); + initialSupply = 100 * 10**18; + + token = new TestSRC20WithEvents("My Token", "MTKN"); + token.mint(saddress(initialHolder), suint256(initialSupply)); + } + + // Basic Functionality Tests + + function test_IncreaseAllowance() public { + uint256 initialAllowance = 100; + uint256 addedValue = 50; + + // Set initial allowance + vm.prank(initialHolder); + token.approve(saddress(spender), suint256(initialAllowance)); + + vm.prank(initialHolder); + token.increaseAllowance(saddress(spender), suint256(addedValue)); + + // Check new allowance (visible to owner) + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), initialAllowance + addedValue); + } + + function test_DecreaseAllowance() public { + uint256 initialAllowance = 100; + uint256 subtractedValue = 50; + + // Set initial allowance + vm.prank(initialHolder); + token.approve(saddress(spender), suint256(initialAllowance)); + + vm.prank(initialHolder); + token.decreaseAllowance(saddress(spender), suint256(subtractedValue)); + + // Check new allowance (visible to owner) + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), initialAllowance - subtractedValue); + } + + // Privacy Tests + + function test_IncreaseAllowancePrivacy() public { + // Set and increase allowance + vm.prank(initialHolder); + token.approve(saddress(spender), suint256(100)); + + vm.prank(initialHolder); + token.increaseAllowance(saddress(spender), suint256(50)); + + // Owner can see allowance + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 150); + + // Spender can see allowance + vm.prank(spender); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 150); + + // Other accounts should revert + vm.prank(otherAccount); + vm.expectRevert(UnauthorizedView.selector); + token.allowance(saddress(initialHolder), saddress(spender)); + } + + function test_DecreaseAllowancePrivacy() public { + // Set and decrease allowance + vm.prank(initialHolder); + token.approve(saddress(spender), suint256(100)); + + vm.prank(initialHolder); + token.decreaseAllowance(saddress(spender), suint256(50)); + + // Owner can see allowance + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 50); + + // Spender can see allowance + vm.prank(spender); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 50); + + // Other accounts should revert + vm.prank(otherAccount); + vm.expectRevert(UnauthorizedView.selector); + token.allowance(saddress(initialHolder), saddress(spender)); + } + + // Edge Cases + + function test_DecreaseAllowanceBelowZeroFails() public { + // Set initial allowance + vm.prank(initialHolder); + token.approve(saddress(spender), suint256(100)); + + // Try to decrease by more than current allowance + vm.prank(initialHolder); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, spender, 0, 0)); + token.decreaseAllowance(saddress(spender), suint256(101)); + + // Allowance should remain unchanged + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 100); + } + + function test_IncreaseAllowanceToMax() public { + // Start with some allowance + vm.prank(initialHolder); + token.approve(saddress(spender), suint256(100)); + + // Increase to max + vm.prank(initialHolder); + token.increaseAllowance(saddress(spender), suint256(type(uint256).max - 100)); + + // Check max allowance + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), type(uint256).max); + } + + function test_MultipleAllowanceUpdates() public { + // Multiple increases + vm.startPrank(initialHolder); + token.approve(saddress(spender), suint256(100)); + token.increaseAllowance(saddress(spender), suint256(50)); + token.increaseAllowance(saddress(spender), suint256(75)); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 225); + + // Multiple decreases + token.decreaseAllowance(saddress(spender), suint256(25)); + token.decreaseAllowance(saddress(spender), suint256(50)); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 150); + vm.stopPrank(); + } + + function test_ZeroValueAllowanceUpdates() public { + vm.startPrank(initialHolder); + + token.increaseAllowance(saddress(spender), suint256(0)); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 0); + + // Set non-zero allowance + token.approve(saddress(spender), suint256(100)); + + token.decreaseAllowance(saddress(spender), suint256(0)); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 100); + + vm.stopPrank(); + } + + function test_AllowanceUpdatesWithTransfers() public { + uint256 transferAmount = 50; + + // Setup allowance + vm.prank(initialHolder); + token.approve(saddress(spender), suint256(100)); + + // Increase allowance and perform transfer + vm.prank(initialHolder); + token.increaseAllowance(saddress(spender), suint256(50)); + + vm.prank(spender); + token.transferFrom(saddress(initialHolder), saddress(otherAccount), suint256(transferAmount)); + + // Check remaining allowance + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 100); // 150 - 50 + } + + // Additional Edge Cases for Increase/Decrease Allowance + + function test_DecreaseAllowanceBelowZeroReverts() public { + // Try to decrease allowance when there was no approved amount before + vm.prank(initialHolder); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, spender, 0, 0)); + token.decreaseAllowance(saddress(spender), suint256(1)); + + // Set initial allowance + vm.prank(initialHolder); + token.approve(saddress(spender), suint256(100)); + + // Try to decrease by more than current allowance + vm.prank(initialHolder); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, spender, 0, 0)); + token.decreaseAllowance(saddress(spender), suint256(101)); + + // Allowance should remain unchanged + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 100); + } + + function test_IncreaseAllowanceOverflow() public { + // Set high initial allowance + vm.prank(initialHolder); + token.approve(saddress(spender), suint256(type(uint256).max - 1)); + + // Try to increase allowance which would cause overflow + vm.prank(initialHolder); + vm.expectRevert(); // Should revert on overflow + token.increaseAllowance(saddress(spender), suint256(2)); + } + + function test_AllowanceUpdatesWithZeroTransfer() public { + vm.prank(initialHolder); + token.approve(saddress(spender), suint256(100)); + + // Zero value transfer should NOT decrease allowance + vm.prank(spender); + token.transferFrom(saddress(initialHolder), saddress(otherAccount), suint256(0)); + + // Allowance should remain unchanged + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 100); + } + + function test_IncreaseAllowanceWithZeroInitial() public { + // Increase allowance when there was no approved amount before + vm.prank(initialHolder); + token.increaseAllowance(saddress(spender), suint256(100)); + + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 100); + } + + function test_DecreaseAllowanceToZero() public { + // Set initial allowance + vm.prank(initialHolder); + token.approve(saddress(spender), suint256(100)); + + // Decrease allowance to exactly zero + vm.prank(initialHolder); + token.decreaseAllowance(saddress(spender), suint256(100)); + + vm.prank(initialHolder); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 0); + } + + function test_ConsecutiveAllowanceUpdates() public { + vm.startPrank(initialHolder); + + // Multiple increases + token.increaseAllowance(saddress(spender), suint256(50)); + token.increaseAllowance(saddress(spender), suint256(30)); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 80); + + // Multiple decreases + token.decreaseAllowance(saddress(spender), suint256(20)); + token.decreaseAllowance(saddress(spender), suint256(10)); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 50); + + // Mix of increases and decreases + token.increaseAllowance(saddress(spender), suint256(25)); + token.decreaseAllowance(saddress(spender), suint256(15)); + assertEq(token.allowance(saddress(initialHolder), saddress(spender)), 60); + + vm.stopPrank(); + } +} + +contract SRC20MetadataTest is Test { + TestSRC20WithEvents public token; + string constant NAME = "Test Token"; + string constant SYMBOL = "TST"; + uint8 constant DECIMALS = 18; + + function setUp() public { + token = new TestSRC20WithEvents(NAME, SYMBOL); + } + + function test_TokenName() public view { + assertEq(token.name(), NAME); + } + + function test_TokenSymbol() public view { + assertEq(token.symbol(), SYMBOL); + } + + function test_TokenDecimals() public view { + assertEq(token.decimals(), DECIMALS); + } +} + +contract SRC20MintBurnTest is Test { + TestSRC20WithEvents public token; + address public initialHolder = address(1); + address public recipient = address(2); + uint256 public initialSupply = 100; + + function setUp() public { + token = new TestSRC20WithEvents("Test Token", "TST"); + token.mint(saddress(initialHolder), suint256(initialSupply)); + } + + function test_MintToZeroAddressReverts() public { + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidReceiver.selector, address(0))); + token.mint(saddress(address(0)), suint256(1)); + } + + function test_BurnFromZeroAddressReverts() public { + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidSender.selector, address(0))); + token.burn(saddress(address(0)), suint256(100)); + } + + function test_BurnExceedingBalanceReverts() public { + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, initialHolder, 0, 0)); + token.burn(saddress(initialHolder), suint256(initialSupply + 1)); + } + + function test_MintIncrementsTotalSupply() public { + uint256 amount = 50; + uint256 previousSupply = token.totalSupply(); + + token.mint(saddress(recipient), suint256(amount)); + assertEq(token.totalSupply(), previousSupply + amount); + } + + function test_BurnDecrementsTotalSupply() public { + uint256 amount = 50; + uint256 previousSupply = token.totalSupply(); + + token.burn(saddress(initialHolder), suint256(amount)); + assertEq(token.totalSupply(), previousSupply - amount); + } + + function test_MintToExistingBalance() public { + uint256 amount = 50; + + // Need to be the account owner to see the balance + vm.prank(initialHolder); + uint256 previousBalance = token.balanceOf(saddress(initialHolder)); + + token.mint(saddress(initialHolder), suint256(amount)); + + // Need to be the account owner to see the updated balance + vm.prank(initialHolder); + assertEq(token.balanceOf(saddress(initialHolder)), previousBalance + amount); + } + + function test_BurnEntireBalance() public { + token.burn(saddress(initialHolder), suint256(initialSupply)); + + vm.prank(initialHolder); + (bool success, uint256 balance) = token.safeBalanceOf(saddress(initialHolder)); + assertTrue(success); + assertEq(balance, 0); + } + + function test_MintMaxUintValue() public { + uint256 remainingSupply = type(uint256).max - token.totalSupply(); + // Should not revert + token.mint(saddress(recipient), suint256(remainingSupply)); + + // Should revert on next mint due to overflow + vm.expectRevert(); + token.mint(saddress(recipient), suint256(1)); + } +} diff --git a/dwell/test/USDY.t.sol b/dwell/test/USDY.t.sol new file mode 100644 index 0000000..b6c8e0d --- /dev/null +++ b/dwell/test/USDY.t.sol @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test, console, stdError} from "forge-std/Test.sol"; +import {USDY} from "../src/USDY.sol"; +import {IERC20Errors} from "../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; + +contract USDYTest is Test { + USDY public token; + address public admin; + address public minter; + address public burner; + address public oracle; + address public pauser; + address public user1; + address public user2; + + uint256 public constant BASE = 1e18; + uint256 public constant INITIAL_MINT = 1000 * 1e18; + + event RewardMultiplierUpdated(uint256 newMultiplier); + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); + event Paused(address account); + event Unpaused(address account); + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function setUp() public { + admin = address(1); + minter = address(2); + burner = address(3); + oracle = address(4); + pauser = address(5); + user1 = address(6); + user2 = address(7); + + // Deploy with admin + token = new USDY(admin); + + // Setup roles + vm.startPrank(admin); + token.grantRole(token.MINTER_ROLE(), minter); + token.grantRole(token.BURNER_ROLE(), burner); + token.grantRole(token.ORACLE_ROLE(), oracle); + token.grantRole(token.PAUSE_ROLE(), pauser); + vm.stopPrank(); + + // Initial mint + vm.prank(minter); + token.mint(saddress(user1), suint256(INITIAL_MINT)); + } + + // Basic Functionality Tests + + function test_Metadata() public view { + assertEq(token.name(), "USD Yield"); + assertEq(token.symbol(), "USDY"); + assertEq(token.decimals(), 18); + } + + function test_InitialState() public { + assertEq(token.totalSupply(), INITIAL_MINT); + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT); + } + + // Role Management Tests + + function test_RoleManagement() public view { + assertTrue(token.hasRole(token.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(token.hasRole(token.MINTER_ROLE(), minter)); + assertTrue(token.hasRole(token.BURNER_ROLE(), burner)); + assertTrue(token.hasRole(token.ORACLE_ROLE(), oracle)); + assertTrue(token.hasRole(token.PAUSE_ROLE(), pauser)); + } + + function test_RoleGrantRevoke() public { + address newMinter = address(10); + + vm.startPrank(admin); + + // Grant role + vm.expectEmit(true, true, true, true); + emit RoleGranted(token.MINTER_ROLE(), newMinter, admin); + token.grantRole(token.MINTER_ROLE(), newMinter); + assertTrue(token.hasRole(token.MINTER_ROLE(), newMinter)); + + // Revoke role + vm.expectEmit(true, true, true, true); + emit RoleRevoked(token.MINTER_ROLE(), newMinter, admin); + token.revokeRole(token.MINTER_ROLE(), newMinter); + assertFalse(token.hasRole(token.MINTER_ROLE(), newMinter)); + + vm.stopPrank(); + } + + /** + * @notice Tests that only admin can grant roles + * @dev This test verifies that: + * 1. A non-admin account cannot grant roles + * 2. The correct error is thrown with proper parameters + * 3. The DEFAULT_ADMIN_ROLE is required to grant any role + * + * The test flow: + * - Confirms caller doesn't have admin role + * - Attempts to grant MINTER_ROLE as non-admin + * - Verifies the attempt fails with MissingRole error + */ + function test_OnlyAdminCanGrantRoles() public { + address caller = user1; + + // Verify precondition: caller should not have admin role + assertFalse(token.hasRole(token.DEFAULT_ADMIN_ROLE(), caller)); + + // Cache the minter role to avoid additional calls during revert check + bytes32 minterRole = token.MINTER_ROLE(); + + // Set up the prank before expecting revert + vm.prank(caller); + + // Attempt to grant role should fail with MissingRole error + vm.expectRevert(); + token.grantRole(minterRole, user2); + } + + // Minting and Burning Tests + + function test_MintAndBurn() public { + uint256 amount = 100e18; + + // Test minting + vm.prank(minter); + token.mint(saddress(user1), suint256(amount)); + + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT + amount); + assertEq(token.totalSupply(), INITIAL_MINT + amount); + + // Test burning + vm.prank(burner); + token.burn(saddress(user1), suint256(amount)); + + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT); + assertEq(token.totalSupply(), INITIAL_MINT); + } + + function test_MintWithoutRole() public { + uint256 amount = 100e18; + + vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, token.MINTER_ROLE(), user1)); + vm.prank(user1); + token.mint(saddress(user1), suint256(amount)); + } + + function test_BurnWithoutRole() public { + uint256 amount = 100e18; + + // First mint some tokens + vm.prank(minter); + token.mint(saddress(user1), suint256(amount)); + + // Try to burn without role + vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, token.BURNER_ROLE(), user1)); + vm.prank(user1); + token.burn(saddress(user1), suint256(amount)); + } + + function test_MintWhenPaused() public { + uint256 amount = 100e18; + + // Pause the contract + vm.prank(pauser); + token.pause(); + + // Try to mint when paused + vm.expectRevert(USDY.TransferWhilePaused.selector); + vm.prank(minter); + token.mint(saddress(user1), suint256(amount)); + } + + function test_BurnWhenPaused() public { + uint256 amount = 100e18; + + // First mint some tokens + vm.prank(minter); + token.mint(saddress(user1), suint256(amount)); + + // Pause the contract + vm.prank(pauser); + token.pause(); + + // Try to burn when paused + vm.expectRevert(USDY.TransferWhilePaused.selector); + vm.prank(burner); + token.burn(saddress(user1), suint256(amount)); + } + + function test_PausedTransfers() public { + // Pause the contract + vm.prank(pauser); + token.pause(); + + // Try to mint + vm.prank(minter); + vm.expectRevert(USDY.TransferWhilePaused.selector); + token.mint(saddress(user1), suint256(100 * 1e18)); + + // Try to burn + vm.prank(burner); + vm.expectRevert(USDY.TransferWhilePaused.selector); + token.burn(saddress(user1), suint256(100 * 1e18)); + } + + // Yield/Reward Multiplier Tests + + function test_InitialRewardMultiplier() public { + // Transfer to check initial yield behavior + uint256 transferAmount = 100 * 1e18; + vm.prank(user1); + token.transfer(saddress(user2), suint256(transferAmount)); + + // Check balances reflect 1:1 ratio initially + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT - transferAmount); + vm.prank(user2); + assertEq(token.balanceOf(saddress(user2)), transferAmount); + } + + /** + * @notice Test reward multiplier updates and their effect on token balances + * @dev This test verifies that: + * 1. Reward multiplier updates correctly increase token values + * 2. Transfers after yield updates use proper share calculations + * 3. Final balances reflect both transferred amounts and accumulated yield + * + * The test flow: + * 1. Start with initial balance in user1's account + * 2. Add 10% yield through reward multiplier + * 3. Transfer tokens from user1 to user2 + * 4. Verify balances reflect both the transfer and yield + */ + function test_RewardMultiplierUpdate() public { + uint256 increment = 0.1e18; // 10% increase + uint256 initialBalance = INITIAL_MINT; + + // Add yield by increasing reward multiplier by 10% + vm.prank(oracle); + vm.expectEmit(true, true, true, true); + emit RewardMultiplierUpdated(BASE + increment); + token.addRewardMultiplier(increment); + + // Calculate initial balance after yield + // When yield is added, the same number of shares are worth more tokens + uint256 yieldAdjustedInitialBalance = (initialBalance * (BASE + increment)) / BASE; + + // Set up transfer amount and calculate shares + uint256 transferAmount = 100e18; + + // Calculate shares needed for transfer + // When transferring tokens with active yield: + // shares = tokens * (BASE / (BASE + yield)) + uint256 transferShares = (transferAmount * BASE) / (BASE + increment); + + // Calculate expected final balances for both users + // User1: Convert remaining shares to tokens using new yield rate + uint256 expectedUser1Shares = initialBalance - transferShares; + uint256 expectedUser1Balance = (expectedUser1Shares * (BASE + increment)) / BASE; + // User2: Convert received shares to tokens using new yield rate + uint256 expectedUser2Balance = (transferShares * (BASE + increment)) / BASE; + + // Perform transfer + vm.prank(user1); + token.transfer(saddress(user2), suint256(transferAmount)); + + // Verify final balances + vm.prank(user1); + uint256 user1Balance = token.balanceOf(saddress(user1)); + vm.prank(user2); + uint256 user2Balance = token.balanceOf(saddress(user2)); + + // Assert balances match expected values + assertEq(user1Balance, expectedUser1Balance, "User1 balance incorrect"); + assertEq(user2Balance, expectedUser2Balance, "User2 balance incorrect"); + + // Verify total supply reflects yield increase + assertEq(token.totalSupply(), yieldAdjustedInitialBalance, "Total supply incorrect"); + } + + function test_OnlyOracleCanUpdateRewardMultiplier() public { + address caller = user1; + + // Attempt to update reward multiplier should fail + vm.startPrank(caller); + vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, token.ORACLE_ROLE(), caller)); + token.addRewardMultiplier(0.1e18); + vm.stopPrank(); + } + + function test_CannotSetZeroRewardIncrement() public { + vm.prank(oracle); + vm.expectRevert(USDY.ZeroRewardIncrement.selector); + token.addRewardMultiplier(0); + } + + function test_RewardMultiplierOverflow() public { + vm.prank(oracle); + vm.expectRevert(stdError.arithmeticError); + token.addRewardMultiplier(type(uint256).max); + } + + // Pause Functionality Tests + + function test_Pause() public { + vm.prank(pauser); + token.pause(); + assertTrue(token.paused()); + + // Transfers should fail while paused + vm.prank(user1); + vm.expectRevert(USDY.TransferWhilePaused.selector); + token.transfer(saddress(user2), suint256(100 * 1e18)); + } + + function test_Unpause() public { + // Pause first + vm.prank(pauser); + token.pause(); + + // Then unpause + vm.prank(pauser); + token.unpause(); + assertFalse(token.paused()); + + // Transfers should work again + uint256 transferAmount = 100 * 1e18; + vm.prank(user1); + token.transfer(saddress(user2), suint256(transferAmount)); + + vm.prank(user2); + assertEq(token.balanceOf(saddress(user2)), transferAmount); + } + + function test_OnlyPauserCanPauseUnpause() public { + address caller = user1; + + // Attempt to pause should fail + vm.startPrank(caller); + vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, token.PAUSE_ROLE(), caller)); + token.pause(); + vm.stopPrank(); + } + + // Privacy Tests + + function test_BalancePrivacy() public { + // Other users can't see balance + assertEq(token.balanceOf(saddress(user1)), 0); + + // Owner can see their balance + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT); + } + + function test_TransferPrivacy() public { + uint256 transferAmount = 100e18; + + // Transfer should emit event with actual value for transparency + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, user2, transferAmount); + token.transfer(saddress(user2), suint256(transferAmount)); + } + + function test_ApprovalPrivacy() public { + uint256 approvalAmount = 100e18; + + // Approve should emit event with actual value for transparency + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit Approval(user1, user2, approvalAmount); + token.approve(saddress(user2), suint256(approvalAmount)); + } + + // Combined Functionality Tests + + function test_PausedMintingAndBurning() public { + vm.prank(pauser); + token.pause(); + + vm.prank(minter); + vm.expectRevert(USDY.TransferWhilePaused.selector); + token.mint(saddress(user1), suint256(100 * 1e18)); + + vm.prank(burner); + vm.expectRevert(USDY.TransferWhilePaused.selector); + token.burn(saddress(user1), suint256(100 * 1e18)); + } + + /** + * @notice Test yield accumulation behavior with multiple transfers + * @dev This test verifies that: + * 1. Yield is correctly applied to all token holders when reward multiplier increases + * 2. Transfers correctly handle share calculations with active yield + * 3. Final balances reflect both transferred amounts and accumulated yield + * + * The key mechanism being tested: + * - Token balances are stored internally as shares + * - Initially, shares are 1:1 with tokens + * - When yield is added, the shares remain constant but are worth more tokens + * - Transfers convert token amounts to shares using current yield rate + * - Final balances are calculated by converting shares back to tokens using yield rate + */ + function test_YieldAccumulationWithTransfers() public { + // Initial state has user1 with INITIAL_MINT tokens (and thus INITIAL_MINT shares) + // and user2 with 0 tokens/shares + + // Add 10% yield by increasing reward multiplier + vm.prank(oracle); + token.addRewardMultiplier(0.1e18); + // Now each share is worth 1.1 tokens + + // Set up transfer amount and calculate corresponding shares + uint256 transferAmount = 100e18; + // When transferring 100 tokens with 1.1 yield rate: + // 100 tokens = x shares * 1.1 + // x shares = 100 * (1/1.1) = 90.909... shares + uint256 expectedShares = (transferAmount * BASE) / (BASE + 0.1e18); + + // Calculate expected final balances + // User1 starts with INITIAL_MINT shares (1:1 at initial mint) + // After two transfers of expectedShares each: + uint256 expectedUser1FinalShares = INITIAL_MINT - (2 * expectedShares); + // Convert final shares to tokens using yield rate: + uint256 expectedUser1FinalBalance = (expectedUser1FinalShares * (BASE + 0.1e18)) / BASE; + // User2 receives 2 * expectedShares, convert to tokens using yield rate: + uint256 expectedUser2FinalBalance = (2 * expectedShares * (BASE + 0.1e18)) / BASE; + + // Perform first transfer + vm.prank(user1); + token.transfer(saddress(user2), suint256(transferAmount)); + + // Perform second transfer + vm.prank(user1); + token.transfer(saddress(user2), suint256(transferAmount)); + + // Verify final balances + vm.prank(user1); + uint256 finalUser1Balance = uint256(token.balanceOf(saddress(user1))); + vm.prank(user2); + uint256 finalUser2Balance = uint256(token.balanceOf(saddress(user2))); + + // Assert that balances match expected values + assertEq(finalUser1Balance, expectedUser1FinalBalance, "User1 balance mismatch"); + assertEq(finalUser2Balance, expectedUser2FinalBalance, "User2 balance mismatch"); + } +} diff --git a/dwell/test/USDY/USDY.Allowance.t.sol b/dwell/test/USDY/USDY.Allowance.t.sol new file mode 100644 index 0000000..b5ba45b --- /dev/null +++ b/dwell/test/USDY/USDY.Allowance.t.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {USDY} from "../../src/USDY.sol"; + +contract USDYAllowanceTest is Test { + USDY public token; + address public admin; + address public minter; + address public oracle; + address public owner; + address public spender; + address public recipient; + uint256 public constant BASE = 1e18; + uint256 public constant INITIAL_MINT = 1000 * 1e18; + + event Transfer(address indexed from, address indexed to, uint256 value); + event RewardMultiplierUpdated(uint256 newMultiplier); + + function setUp() public { + admin = address(1); + minter = address(2); + oracle = address(3); + owner = address(4); + spender = address(5); + recipient = address(6); + + token = new USDY(admin); + + vm.startPrank(admin); + token.grantRole(token.MINTER_ROLE(), minter); + token.grantRole(token.ORACLE_ROLE(), oracle); + vm.stopPrank(); + + vm.prank(minter); + token.mint(saddress(owner), suint256(INITIAL_MINT)); + } + + function test_AllowanceWithYield() public { + uint256 allowanceAmount = 100 * 1e18; + uint256 yieldIncrement = 0.1e18; // 10% yield + + // Set initial allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(allowanceAmount)); + + // Add yield + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Check allowance remains unchanged despite yield + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), allowanceAmount); + + // Calculate shares needed for transfer with yield + uint256 transferAmount = 50 * 1e18; + + // Transfer using allowance + vm.prank(spender); + token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); + + // Verify allowance is reduced by token amount, not shares + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), allowanceAmount - transferAmount); + + // Verify recipient received correct amount with yield + vm.prank(recipient); + // use 1 wei tolerance to account for integer rounding errors / precision loss + assertApproxEqAbs(token.balanceOf(saddress(recipient)), transferAmount, 1); + } + + function test_AllowancePrivacyWithYield() public { + uint256 allowanceAmount = 100 * 1e18; + uint256 yieldIncrement = 0.1e18; + + // Set allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(allowanceAmount)); + + // Add yield + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Owner can see allowance + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), allowanceAmount); + + // Spender can see allowance + vm.prank(spender); + assertEq(token.allowance(saddress(owner), saddress(spender)), allowanceAmount); + + // Others cannot see allowance (reverts with UnauthorizedView) + vm.prank(recipient); + vm.expectRevert(USDY.UnauthorizedView.selector); + token.allowance(saddress(owner), saddress(spender)); + } + + function test_InfiniteAllowanceWithYield() public { + // Set infinite allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(type(uint256).max)); + + // Add yield multiple times + vm.startPrank(oracle); + token.addRewardMultiplier(0.1e18); // +10% + token.addRewardMultiplier(0.05e18); // +5% + token.addRewardMultiplier(0.15e18); // +15% + vm.stopPrank(); + + // Transfer using allowance + vm.prank(spender); + token.transferFrom(saddress(owner), saddress(recipient), suint256(50 * 1e18)); + + // Verify allowance remains infinite + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), type(uint256).max); + } + + function test_AllowanceUpdatesWithMultipleYieldChanges() public { + uint256 allowanceAmount = 100 * 1e18; + + // Set initial allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(allowanceAmount)); + + // Multiple yield changes + vm.startPrank(oracle); + token.addRewardMultiplier(0.1e18); // +10% + token.addRewardMultiplier(0.05e18); // +5% + vm.stopPrank(); + + // Transfer half of allowance + uint256 transferAmount = allowanceAmount / 2; + vm.prank(spender); + token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); + + // Verify allowance is reduced correctly + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), allowanceAmount - transferAmount); + + // More yield changes + vm.prank(oracle); + token.addRewardMultiplier(0.15e18); // +15% + + // Transfer remaining allowance + vm.prank(spender); + token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); + + // Verify allowance is zero + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), 0); + } +} \ No newline at end of file diff --git a/dwell/test/USDY/USDY.Approve.t.sol b/dwell/test/USDY/USDY.Approve.t.sol new file mode 100644 index 0000000..5a4f436 --- /dev/null +++ b/dwell/test/USDY/USDY.Approve.t.sol @@ -0,0 +1,691 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {USDY} from "../../src/USDY.sol"; +import {IERC20Errors} from "../../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; + +contract USDYApproveTest is Test { + USDY public token; + address public admin; + address public minter; + address public owner; + address public spender; + address public observer; + address public recipient; + address public oracle; + uint256 public constant BASE = 1e18; + uint256 public constant INITIAL_MINT = 1000 * 1e18; + + event Approval(address indexed owner, address indexed spender, uint256 value); + event Transfer(address indexed from, address indexed to, uint256 value); + + function setUp() public { + admin = address(1); + minter = address(2); + owner = address(3); + spender = address(4); + observer = address(5); + recipient = address(6); + oracle = address(7); + token = new USDY(admin); + + vm.startPrank(admin); + token.grantRole(token.MINTER_ROLE(), minter); + token.grantRole(token.ORACLE_ROLE(), oracle); + vm.stopPrank(); + + // Initial mint for testing approvals + vm.prank(minter); + token.mint(saddress(owner), suint256(INITIAL_MINT)); + } + + function test_ApproveFromZeroAddressReverts() public { + // Set approval amount + uint256 amount = 1 * 1e18; + + // Attempt to approve tokens from zero address + // This should revert since zero address cannot approve tokens + vm.prank(address(0)); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidSpender.selector, address(0))); + token.approve(saddress(spender), suint256(amount)); + } + + function test_ApproveToZeroAddressReverts() public { + uint256 amount = 1 * 1e18; + + // Try to approve to zero address + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidSpender.selector, address(0))); + token.approve(saddress(address(0)), suint256(amount)); + } + + function test_ApproveEmitsEvent() public { + uint256 amount = 100e18; + + vm.prank(owner); + vm.expectEmit(true, true, false, true); + emit Approval(owner, spender, amount); + token.approve(saddress(spender), suint256(amount)); + } + + function test_ApproveAmount() public { + uint256 amount = 1 * 1e18; + + // Approve amount + vm.prank(owner); + token.approve(saddress(spender), suint256(amount)); + + // Check allowance (from owner's view) + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), amount); + + // Check allowance (from spender's view) + vm.prank(spender); + assertEq(token.allowance(saddress(owner), saddress(spender)), amount); + } + + function test_ApproveReplacePreviousAmount() public { + uint256 amount = 1 * 1e18; + + // First approval + vm.prank(owner); + token.approve(saddress(spender), suint256(amount + 1)); + + // Replace with new amount + vm.prank(owner); + token.approve(saddress(spender), suint256(amount)); + + // Check new allowance + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), amount); + } + + function test_ApprovePrivacy() public { + uint256 amount = 1 * 1e18; + + // Set approval + vm.prank(owner); + token.approve(saddress(spender), suint256(amount)); + + // Owner can see allowance + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), amount); + + // Spender can see allowance + vm.prank(spender); + assertEq(token.allowance(saddress(owner), saddress(spender)), amount); + + // Other accounts cannot see allowance + vm.prank(observer); + vm.expectRevert(USDY.UnauthorizedView.selector); + token.allowance(saddress(owner), saddress(spender)); + } + + function test_ApproveZeroAmount() public { + uint256 amount = 1 * 1e18; + + // Initial non-zero approval + vm.prank(owner); + token.approve(saddress(spender), suint256(amount)); + + // Approve zero amount + vm.prank(owner); + token.approve(saddress(spender), suint256(0)); + + // Check allowance is zero + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), 0); + } + + function test_ApproveDoesNotRequireBalance() public { + uint256 largeAmount = INITIAL_MINT * 2; + + // Approve more than balance + vm.prank(owner); + token.approve(saddress(spender), suint256(largeAmount)); + + // Check allowance is set despite insufficient balance + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), largeAmount); + } + + function test_ApproveTwiceEmitsEvents() public { + uint256 amount1 = 100 * 1e18; + uint256 amount2 = 200 * 1e18; + + // First approval should emit event + vm.prank(owner); + vm.expectEmit(true, true, false, true); + emit Approval(owner, spender, amount1); + token.approve(saddress(spender), suint256(amount1)); + + // Second approval should also emit event + vm.prank(owner); + vm.expectEmit(true, true, false, true); + emit Approval(owner, spender, amount2); + token.approve(saddress(spender), suint256(amount2)); + } + + function test_ApproveMultipleSpenders() public { + address spender2 = address(6); + uint256 amount1 = 100 * 1e18; + uint256 amount2 = 200 * 1e18; + + // Approve different amounts to different spenders + vm.startPrank(owner); + token.approve(saddress(spender), suint256(amount1)); + token.approve(saddress(spender2), suint256(amount2)); + vm.stopPrank(); + + // Check allowances are set correctly + vm.prank(owner); + uint256 allowance1 = token.allowance(saddress(owner), saddress(spender)); + assertEq(allowance1, amount1, "First allowance mismatch"); + + vm.prank(owner); + uint256 allowance2 = token.allowance(saddress(owner), saddress(spender2)); + assertEq(allowance2, amount2, "Second allowance mismatch"); + } + + function test_ApproveMaxUint() public { + uint256 maxAmount = type(uint256).max; + + // Approve max uint256 + vm.prank(owner); + token.approve(saddress(spender), suint256(maxAmount)); + + // Check allowance is set to max + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), maxAmount); + } + + function test_ApproveAllowanceIndependentOfBalance() public { + uint256 amount = 100 * 1e18; + + // Approve amount + vm.prank(owner); + token.approve(saddress(spender), suint256(amount)); + + // Burn all tokens (should not affect allowance) + bytes32 burner_role = token.BURNER_ROLE(); + vm.prank(admin); + token.grantRole(burner_role, admin); + + vm.prank(admin); + token.burn(saddress(owner), suint256(INITIAL_MINT)); + + // Verify allowance remains unchanged after burning balance + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), amount, "Allowance changed after burning balance"); + } + + // Transfer From Tests + + function test_TransferFromWithInfiniteAllowance() public { + uint256 transferAmount = 1 * 1e18; + + // Set infinite allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(type(uint256).max)); + + // Record initial allowance + vm.prank(owner); + uint256 initialAllowance = token.allowance(saddress(owner), saddress(spender)); + + // Transfer should not emit Approval event since allowance is infinite + vm.prank(spender); + token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); + + // Verify allowance remains infinite + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), initialAllowance); + } + + function test_TransferFromWithSufficientAllowance() public { + uint256 transferAmount = 1 * 1e18; + + // Set allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(transferAmount)); + + // Record initial balances + vm.prank(owner); + uint256 initialOwnerBalance = token.balanceOf(saddress(owner)); + vm.prank(recipient); + uint256 initialRecipientBalance = token.balanceOf(saddress(recipient)); + + // Transfer + vm.prank(spender); + token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); + + // Verify balances + vm.prank(owner); + assertEq(token.balanceOf(saddress(owner)), initialOwnerBalance - transferAmount); + vm.prank(recipient); + assertEq(token.balanceOf(saddress(recipient)), initialRecipientBalance + transferAmount); + } + + function test_TransferFromDecreasesAllowance() public { + uint256 initialAllowance = 2 * 1e18; + uint256 transferAmount = 1 * 1e18; + + // Set allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(initialAllowance)); + + // Transfer + vm.prank(spender); + token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); + + // Verify allowance decreased + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), initialAllowance - transferAmount); + } + + function test_TransferFromRevertsWithInsufficientAllowance() public { + uint256 allowance = 1 * 1e18; + uint256 transferAmount = 2 * 1e18; + + // Set allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(allowance)); + + // Attempt transfer with insufficient allowance + vm.prank(spender); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, spender, allowance, transferAmount)); + token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); + } + + function test_TransferFromEmitsTransferEvent() public { + uint256 transferAmount = 1 * 1e18; + + // Set allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(transferAmount)); + + // Transfer should emit Transfer event + vm.prank(spender); + vm.expectEmit(true, true, false, true); + emit Transfer(owner, recipient, transferAmount); + token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); + } + + function test_TransferFromRevertsWithInsufficientBalance() public { + uint256 excessAmount = INITIAL_MINT + 1; + + // Set high allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(excessAmount)); + + // Attempt transfer with insufficient balance + vm.prank(spender); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, owner, INITIAL_MINT, excessAmount)); + token.transferFrom(saddress(owner), saddress(recipient), suint256(excessAmount)); + } + + /** + * @notice Tests that allowances are correctly decreased by token amount (not shares) when yield is active + * @dev This test verifies that: + * 1. When yield is active, allowances are tracked in tokens, not shares + * 2. A transfer of X tokens reduces the allowance by X tokens, regardless of yield + * 3. The actual transfer uses shares internally for accurate accounting + * 4. The allowance is fully consumed after the transfer + */ + function test_TransferFromWithYieldDecreasesAllowanceByTokens() public { + // Set up test amounts + uint256 transferAmount = 100 * 1e18; // Transfer 100 tokens + uint256 yieldIncrement = 0.0004e18; // Add 0.04% yield (4 bps) + + // First approve spender to spend transferAmount tokens + vm.prank(owner); + token.approve(saddress(spender), suint256(transferAmount)); + + // Add yield to the system + // This makes each share worth more tokens, but shouldn't affect allowances + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Perform transfer using allowance + // Even though shares are worth more tokens now, the allowance should still + // be decreased by the original token amount + vm.prank(spender); + token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); + + // Verify allowance is reduced by token amount (should be 0 after full use) + vm.prank(owner); + uint256 finalAllowance = token.allowance(saddress(owner), saddress(spender)); + assertEq(finalAllowance, 0, "Allowance should be zero after transfer"); + } + + function test_TransferFromPrivacy() public { + uint256 transferAmount = 1 * 1e18; + + // Set allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(transferAmount)); + + // Transfer + vm.prank(spender); + token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); + + // Owner can see their reduced balance + vm.prank(owner); + assertEq(token.balanceOf(saddress(owner)), INITIAL_MINT - transferAmount); + + // Recipient can see their increased balance + vm.prank(recipient); + assertEq(token.balanceOf(saddress(recipient)), transferAmount); + + // Other accounts cannot see balances + vm.prank(observer); + assertEq(token.balanceOf(saddress(owner)), 0); + assertEq(token.balanceOf(saddress(recipient)), 0); + } + + function test_TransferFromZeroAmount() public { + // Set small allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(1)); + + // Check initial balance + vm.prank(owner); + uint256 initialBalance = token.balanceOf(saddress(owner)); + + // Check allowance (from owner's view) + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), 1); + + // Transfer zero amount + vm.prank(spender); + token.transferFrom(saddress(owner), saddress(recipient), suint256(0)); + + // Check balance unchanged + vm.prank(owner); + assertEq(token.balanceOf(saddress(owner)), initialBalance); + + // Check allowance unchanged (from owner's view) + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), 1); + } + + function test_IncreaseAllowanceAmount() public { + uint256 amount = 100 * 1e18; + + // Increase allowance + vm.prank(owner); + token.increaseAllowance(saddress(spender), suint256(amount)); + + // Check allowance is set correctly + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), amount); + + // Spender can also see allowance + vm.prank(spender); + assertEq(token.allowance(saddress(owner), saddress(spender)), amount); + } + + function test_IncreaseAllowanceAddsToExisting() public { + uint256 amount = 100 * 1e18; + + // Set initial allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(amount)); + + // Increase allowance + vm.prank(owner); + token.increaseAllowance(saddress(spender), suint256(amount)); + + // Check allowance is increased correctly + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), amount * 2); + } + + function test_IncreaseAllowanceEmitsEvent() public { + uint256 amount = 100e18; + + vm.prank(owner); + vm.expectEmit(true, true, false, true); + emit Approval(owner, spender, amount); + token.increaseAllowance(saddress(spender), suint256(amount)); + } + + function test_IncreaseAllowanceToZeroAddressReverts() public { + uint256 amount = 100 * 1e18; + + // Try to increase allowance for zero address + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidSpender.selector, address(0))); + token.increaseAllowance(saddress(address(0)), suint256(amount)); + } + + function test_IncreaseAllowancePrivacy() public { + uint256 initialAmount = 100 * 1e18; + uint256 increaseAmount = 100 * 1e18; + + // Set initial approval + vm.prank(owner); + token.approve(saddress(spender), suint256(initialAmount)); + + // Increase allowance + vm.prank(owner); + token.increaseAllowance(saddress(spender), suint256(increaseAmount)); + + // Owner can see allowance + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), initialAmount + increaseAmount); + + // Spender can see allowance + vm.prank(spender); + assertEq(token.allowance(saddress(owner), saddress(spender)), initialAmount + increaseAmount); + + // Other accounts cannot see allowance + vm.prank(observer); + vm.expectRevert(USDY.UnauthorizedView.selector); + token.allowance(saddress(owner), saddress(spender)); + } + + function test_IncreaseAllowanceWithZeroValue() public { + uint256 initialAmount = 100 * 1e18; + + // Set initial allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(initialAmount)); + + // Increase by zero + vm.prank(owner); + token.increaseAllowance(saddress(spender), suint256(0)); + + // Check allowance remains unchanged + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), initialAmount); + } + + function test_IncreaseAllowanceMultipleTimes() public { + uint256 amount = 100 * 1e18; + + // Multiple increases + vm.startPrank(owner); + token.increaseAllowance(saddress(spender), suint256(amount)); + token.increaseAllowance(saddress(spender), suint256(amount / 2)); + token.increaseAllowance(saddress(spender), suint256(amount / 4)); + vm.stopPrank(); + + // Check final allowance + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), amount + (amount / 2) + (amount / 4)); + } + + function test_IncreaseAllowanceAfterSpending() public { + uint256 amount = 100 * 1e18; + uint256 spendAmount = 40 * 1e18; + + // Set initial allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(amount)); + + // Spend some of the allowance + vm.prank(spender); + token.transferFrom(saddress(owner), saddress(recipient), suint256(spendAmount)); + + // Increase allowance + vm.prank(owner); + token.increaseAllowance(saddress(spender), suint256(amount)); + + // Check new allowance + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), (amount - spendAmount) + amount); + } + + function test_DecreaseAllowanceRevertsWithoutApproval() public { + uint256 amount = 1 * 1e18; + + // Try to decrease allowance without prior approval + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, spender, 0, amount)); + token.decreaseAllowance(saddress(spender), suint256(amount)); + } + + function test_DecreaseAllowanceSubtractsAmount() public { + uint256 initialAmount = 2 * 1e18; + uint256 decreaseAmount = 1 * 1e18; + + // Set initial allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(initialAmount)); + + // Decrease allowance + vm.prank(owner); + token.decreaseAllowance(saddress(spender), suint256(decreaseAmount)); + + // Check new allowance + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), initialAmount - decreaseAmount); + } + + function test_DecreaseAllowanceToZero() public { + uint256 amount = 1 * 1e18; + + // Set initial allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(amount)); + + // Decrease entire allowance + vm.prank(owner); + token.decreaseAllowance(saddress(spender), suint256(amount)); + + // Check allowance is zero + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), 0); + } + + function test_DecreaseAllowanceRevertsWhenExceedingAllowance() public { + uint256 initialAmount = 1 * 1e18; + uint256 decreaseAmount = 2 * 1e18; + + // Set initial allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(initialAmount)); + + // Try to decrease by more than allowed + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, spender, initialAmount, decreaseAmount)); + token.decreaseAllowance(saddress(spender), suint256(decreaseAmount)); + } + + function test_DecreaseAllowanceEmitsEvent() public { + uint256 initialAmount = 100e18; + uint256 decreaseAmount = 50e18; + + vm.prank(owner); + token.approve(saddress(spender), suint256(initialAmount)); + + vm.prank(owner); + vm.expectEmit(true, true, false, true); + emit Approval(owner, spender, initialAmount - decreaseAmount); + token.decreaseAllowance(saddress(spender), suint256(decreaseAmount)); + } + + function test_DecreaseAllowancePrivacy() public { + uint256 initialAmount = 100 * 1e18; + uint256 decreaseAmount = 40 * 1e18; + + // Set initial approval + vm.prank(owner); + token.approve(saddress(spender), suint256(initialAmount)); + + // Decrease allowance + vm.prank(owner); + token.decreaseAllowance(saddress(spender), suint256(decreaseAmount)); + + // Owner can see allowance + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), initialAmount - decreaseAmount); + + // Spender can see allowance + vm.prank(spender); + assertEq(token.allowance(saddress(owner), saddress(spender)), initialAmount - decreaseAmount); + + // Other accounts cannot see allowance + vm.prank(observer); + vm.expectRevert(USDY.UnauthorizedView.selector); + token.allowance(saddress(owner), saddress(spender)); + } + + function test_DecreaseAllowanceWithZeroValue() public { + uint256 initialAmount = 100 * 1e18; + + // Set initial allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(initialAmount)); + + // Decrease by zero + vm.prank(owner); + token.decreaseAllowance(saddress(spender), suint256(0)); + + // Check allowance remains unchanged + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), initialAmount); + } + + function test_DecreaseAllowanceMultipleTimes() public { + uint256 initialAmount = 100 * 1e18; + + // Set initial allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(initialAmount)); + + // Multiple decreases + vm.startPrank(owner); + token.decreaseAllowance(saddress(spender), suint256(20 * 1e18)); + token.decreaseAllowance(saddress(spender), suint256(30 * 1e18)); + token.decreaseAllowance(saddress(spender), suint256(10 * 1e18)); + vm.stopPrank(); + + // Check final allowance + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), 40 * 1e18); + } + + function test_DecreaseAllowanceAfterSpending() public { + uint256 initialAmount = 100 * 1e18; + uint256 spendAmount = 40 * 1e18; + uint256 decreaseAmount = 30 * 1e18; + + // Set initial allowance + vm.prank(owner); + token.approve(saddress(spender), suint256(initialAmount)); + + // Spend some of the allowance + vm.prank(spender); + token.transferFrom(saddress(owner), saddress(recipient), suint256(spendAmount)); + + // Decrease allowance + vm.prank(owner); + token.decreaseAllowance(saddress(spender), suint256(decreaseAmount)); + + // Check new allowance + vm.prank(owner); + assertEq(token.allowance(saddress(owner), saddress(spender)), initialAmount - spendAmount - decreaseAmount); + } +} \ No newline at end of file diff --git a/dwell/test/USDY/USDY.Balance.t.sol b/dwell/test/USDY/USDY.Balance.t.sol new file mode 100644 index 0000000..0e43be8 --- /dev/null +++ b/dwell/test/USDY/USDY.Balance.t.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {USDY} from "../../src/USDY.sol"; + + +contract USDYBalanceAndSharesTest is Test { + USDY public token; + address public admin; + address public minter; + address public oracle; + address public user1; + address public user2; + uint256 public constant BASE = 1e18; + uint256 public constant INITIAL_MINT = 1000 * 1e18; + + event Transfer(address indexed from, address indexed to, uint256 value); + event RewardMultiplierUpdated(uint256 newMultiplier); + + function setUp() public { + admin = address(1); + minter = address(2); + oracle = address(3); + user1 = address(4); + user2 = address(5); + + token = new USDY(admin); + + vm.startPrank(admin); + token.grantRole(token.MINTER_ROLE(), minter); + token.grantRole(token.ORACLE_ROLE(), oracle); + vm.stopPrank(); + } + + function test_BalanceReturnsTokensNotShares() public { + uint256 tokensAmount = 10 * 1e18; + uint256 yieldIncrement = 0.0001e18; // 0.01% yield + + // Mint tokens first + vm.prank(minter); + token.mint(saddress(user1), suint256(tokensAmount)); + + // Add yield + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Check balance reflects tokens with yield + vm.prank(user1); + assertEq( + token.balanceOf(saddress(user1)), + (tokensAmount * (BASE + yieldIncrement)) / BASE + ); + } + + function test_ZeroBalanceAndSharesForNewAccounts() public { + // Check balance + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), 0); + + // Check shares + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), 0); + } + + function test_SharesUnchangedWithYield() public { + uint256 sharesAmount = 1 * 1e18; + + // Mint initial shares + vm.prank(minter); + token.mint(saddress(user1), suint256(sharesAmount)); + + // Record initial shares + vm.prank(user1); + uint256 initialShares = token.sharesOf(saddress(user1)); + + // Add yield multiple times + vm.startPrank(oracle); + token.addRewardMultiplier(0.0001e18); // +0.01% + token.addRewardMultiplier(0.0002e18); // +0.02% + token.addRewardMultiplier(0.0003e18); // +0.03% + vm.stopPrank(); + + // Verify shares remain unchanged + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), initialShares); + } + + function test_SharesPrivacy() public { + uint256 amount = 100 * 1e18; + + // Mint tokens to user1 + vm.prank(minter); + token.mint(saddress(user1), suint256(amount)); + + // User1 can see their own shares + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), amount); + + // User2 cannot see user1's shares (should see 0) + vm.prank(user2); + assertEq(token.sharesOf(saddress(user1)), 0); + } + + function test_SharesWithTransfers() public { + uint256 initialAmount = 100 * 1e18; + uint256 transferAmount = 40 * 1e18; + uint256 yieldIncrement = 0.0001e18; // 0.01% yield + + // Mint initial tokens + vm.prank(minter); + token.mint(saddress(user1), suint256(initialAmount)); + + // Add yield + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Calculate shares for transfer + uint256 transferShares = (transferAmount * BASE) / (BASE + yieldIncrement); + + // Transfer tokens + vm.prank(user1); + token.transfer(saddress(user2), suint256(transferAmount)); + + // Verify shares + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), initialAmount - transferShares); + + vm.prank(user2); + assertEq(token.sharesOf(saddress(user2)), transferShares); + } + + function test_SharesWithMintingAfterYield() public { + uint256 initialAmount = 100 * 1e18; + uint256 mintAmount = 50 * 1e18; + uint256 yieldIncrement = 0.0001e18; // 0.01% yield + + // Mint initial tokens + vm.prank(minter); + token.mint(saddress(user1), suint256(initialAmount)); + + // Add yield + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Mint more tokens + vm.prank(minter); + token.mint(saddress(user2), suint256(mintAmount)); + + // Verify shares + // User1's shares should remain unchanged + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), initialAmount); + + // User2's shares should be calculated with current yield + vm.prank(user2); + assertEq(token.sharesOf(saddress(user2)), (mintAmount * BASE) / (BASE + yieldIncrement)); + } + + function test_TotalShares() public { + uint256 amount1 = 100 * 1e18; + uint256 amount2 = 50 * 1e18; + uint256 yieldIncrement = 0.0001e18; // 0.01% yield + + // Mint to first user + vm.prank(minter); + token.mint(saddress(user1), suint256(amount1)); + + // Add yield + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Mint to second user + vm.prank(minter); + token.mint(saddress(user2), suint256(amount2)); + + // Calculate expected total shares + uint256 shares1 = amount1; // First mint is 1:1 + uint256 shares2 = (amount2 * BASE) / (BASE + yieldIncrement); // Second mint accounts for yield + uint256 expectedTotalShares = shares1 + shares2; + + // Verify total shares + assertEq(token.totalShares(), expectedTotalShares); + } +} \ No newline at end of file diff --git a/dwell/test/USDY/USDY.Burn.t.sol b/dwell/test/USDY/USDY.Burn.t.sol new file mode 100644 index 0000000..beb3020 --- /dev/null +++ b/dwell/test/USDY/USDY.Burn.t.sol @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {USDY} from "../../src/USDY.sol"; +import {IERC20Errors} from "../../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; + + +contract USDYBurnTest is Test { + USDY public token; + address public admin; + address public minter; + address public burner; + address public oracle; + address public user1; + address public user2; + uint256 public constant BASE = 1e18; + uint256 public constant INITIAL_MINT = 1000 * 1e18; + + event Transfer(address indexed from, address indexed to, uint256 value); + event RewardMultiplierUpdated(uint256 newMultiplier); + + function setUp() public { + admin = address(1); + minter = address(2); + burner = address(3); + oracle = address(4); + user1 = address(5); + user2 = address(6); + + token = new USDY(admin); + + vm.startPrank(admin); + token.grantRole(token.MINTER_ROLE(), minter); + token.grantRole(token.BURNER_ROLE(), burner); + token.grantRole(token.ORACLE_ROLE(), oracle); + vm.stopPrank(); + + // Initial mint for testing burns + vm.prank(minter); + token.mint(saddress(user1), suint256(INITIAL_MINT)); + } + + function test_BurnDecrementsAccountShares() public { + uint256 burnAmount = 1 * 1e18; + + // Record initial shares + vm.prank(user1); + uint256 initialShares = token.sharesOf(saddress(user1)); + + // Burn tokens + vm.prank(burner); + token.burn(saddress(user1), suint256(burnAmount)); + + // Verify shares were reduced + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), initialShares - burnAmount); + } + + function test_BurnDecrementsTotalShares() public { + uint256 burnAmount = 1 * 1e18; + + // Record initial total shares + uint256 initialTotalShares = token.totalShares(); + + // Burn tokens + vm.prank(burner); + token.burn(saddress(user1), suint256(burnAmount)); + + // Verify total shares were reduced + assertEq(token.totalShares(), initialTotalShares - burnAmount); + } + + function test_BurnFromZeroAddressReverts() public { + uint256 burnAmount = 1 * 1e18; + + // Attempt to burn from zero address should revert + vm.prank(burner); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidSender.selector, address(0))); + token.burn(saddress(address(0)), suint256(burnAmount)); + } + + function test_BurnExceedingBalanceReverts() public { + // Get current balance + vm.prank(user1); + uint256 balance = token.balanceOf(saddress(user1)); + uint256 burnAmount = balance + 1; + + // Attempt to burn more than balance should revert + vm.prank(burner); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, user1, balance, burnAmount)); + token.burn(saddress(user1), suint256(burnAmount)); + } + + function test_BurnEmitsTransferEvent() public { + uint256 burnAmount = 1 * 1e18; + + // Burn should emit Transfer event to zero address + vm.prank(burner); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, address(0), burnAmount); + token.burn(saddress(user1), suint256(burnAmount)); + } + + function test_BurnEmitsTransferEventWithTokensNotShares() public { + uint256 burnAmount = 1000 * 1e18; + uint256 yieldIncrement = 0.0001e18; // 0.01% yield + + // Add yield first + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Burn should emit Transfer event with token amount, not shares + vm.prank(burner); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, address(0), burnAmount); + token.burn(saddress(user1), suint256(burnAmount)); + } + + function test_BurnWithYieldCalculatesSharesCorrectly() public { + uint256 burnAmount = 100 * 1e18; + uint256 yieldIncrement = 0.1e18; // 10% yield + + // Add yield first + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Calculate expected shares to burn + uint256 sharesToBurn = (burnAmount * BASE) / (BASE + yieldIncrement); + + // Record initial shares + vm.prank(user1); + uint256 initialShares = token.sharesOf(saddress(user1)); + + // Burn tokens + vm.prank(burner); + token.burn(saddress(user1), suint256(burnAmount)); + + // Verify correct number of shares were burned + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), initialShares - sharesToBurn); + } + + function test_BurnZeroAmount() public { + // Initial state check + vm.prank(user1); + uint256 initialShares = token.sharesOf(saddress(user1)); + vm.prank(user1); + uint256 initialBalance = token.balanceOf(saddress(user1)); + + // Burn zero amount + vm.prank(burner); + token.burn(saddress(user1), suint256(0)); + + // Verify shares and balance remain unchanged + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), initialShares); + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), initialBalance); + } + + function test_MultipleBurns() public { + uint256[] memory amounts = new uint256[](3); + amounts[0] = 100 * 1e18; + amounts[1] = 50 * 1e18; + amounts[2] = 75 * 1e18; + + // Get initial shares after setup mint + vm.prank(user1); + uint256 totalShares = token.sharesOf(saddress(user1)); + uint256 currentMultiplier = BASE; + + // Perform multiple burns with yield changes in between + for(uint256 i = 0; i < amounts.length; i++) { + if(i > 0) { + // Add some yield before subsequent burns + uint256 yieldIncrement = 0.0001e18 * (i + 1); + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + currentMultiplier += yieldIncrement; + } + + // Calculate shares to burn for this amount + uint256 sharesToBurn = (amounts[i] * BASE) / currentMultiplier; + require(sharesToBurn <= totalShares, "Not enough shares to burn"); + + vm.prank(burner); + token.burn(saddress(user1), suint256(amounts[i])); + + // Update remaining shares + totalShares -= sharesToBurn; + } + + // Calculate expected final balance based on remaining shares and final multiplier + uint256 expectedBalance = (totalShares * currentMultiplier) / BASE; + + // Verify final balance + vm.prank(user1); + uint256 actualBalance = token.balanceOf(saddress(user1)); + assertEq(actualBalance, expectedBalance); + + // Verify shares are calculated correctly + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), totalShares); + } + + function test_BurnPrivacy() public { + uint256 burnAmount = 100 * 1e18; + + // Record initial balance (only visible to owner) + vm.prank(user1); + uint256 initialBalance = token.balanceOf(saddress(user1)); + + // Burn tokens + vm.prank(burner); + token.burn(saddress(user1), suint256(burnAmount)); + + // Owner can see reduced balance + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), initialBalance - burnAmount); + + // Other users still see zero + vm.prank(user2); + assertEq(token.balanceOf(saddress(user1)), 0); + } + + function test_OnlyBurnerCanBurn() public { + uint256 amount = 100 * 1e18; + + // Non-burner cannot burn + bytes32 burnerRole = token.BURNER_ROLE(); + vm.prank(user2); + vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, burnerRole, user2)); + token.burn(saddress(user1), suint256(amount)); + + // Burner can burn + vm.prank(burner); + token.burn(saddress(user1), suint256(amount)); + + // Verify burn was successful + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT - amount); + } +} \ No newline at end of file diff --git a/dwell/test/USDY/USDY.Mint.t.sol b/dwell/test/USDY/USDY.Mint.t.sol new file mode 100644 index 0000000..fd9ca76 --- /dev/null +++ b/dwell/test/USDY/USDY.Mint.t.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {USDY} from "../../src/USDY.sol"; +import {IERC20Errors} from "../../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; + + +contract USDYMintTest is Test { + USDY public token; + address public admin; + address public minter; + address public oracle; + address public user1; + address public user2; + uint256 public constant BASE = 1e18; + uint256 public constant INITIAL_MINT = 1000 * 1e18; + + event Transfer(address indexed from, address indexed to, uint256 value); + event RewardMultiplierUpdated(uint256 newMultiplier); + + function setUp() public { + admin = address(1); + minter = address(2); + oracle = address(3); + user1 = address(4); + user2 = address(5); + + token = new USDY(admin); + + vm.startPrank(admin); + token.grantRole(token.MINTER_ROLE(), minter); + token.grantRole(token.ORACLE_ROLE(), oracle); + vm.stopPrank(); + } + + function test_MintIncrementsTotalShares() public { + uint256 amount = 1 * 1e18; + + // Record initial total shares + uint256 initialTotalShares = token.totalShares(); + + // Mint tokens + vm.prank(minter); + token.mint(saddress(user1), suint256(amount)); + + // Verify total shares increased by mint amount + assertEq(token.totalShares(), initialTotalShares + amount); + } + + function test_MintIncrementsTotalSupply() public { + uint256 amount = 1 * 1e18; + + // Record initial total supply + uint256 initialTotalSupply = token.totalSupply(); + + // Mint tokens + vm.prank(minter); + token.mint(saddress(user1), suint256(amount)); + + // Verify total supply increased by mint amount + assertEq(token.totalSupply(), initialTotalSupply + amount); + } + + function test_MintEmitsTransferEvent() public { + uint256 amount = 1 * 1e18; + + // Mint should emit Transfer event from zero address + vm.prank(minter); + vm.expectEmit(true, true, false, true); + emit Transfer(address(0), user1, amount); + token.mint(saddress(user1), suint256(amount)); + } + + function test_MintEmitsTransferEventWithTokensNotShares() public { + uint256 amount = 1000 * 1e18; + uint256 yieldIncrement = 0.0001e18; // 0.01% yield + + // Add yield first + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Mint should emit Transfer event with token amount, not shares + vm.prank(minter); + vm.expectEmit(true, true, false, true); + emit Transfer(address(0), user1, amount); + token.mint(saddress(user1), suint256(amount)); + } + + function test_MintSharesAssignedToCorrectAddress() public { + uint256 amount = 1 * 1e18; + + // Mint tokens + vm.prank(minter); + token.mint(saddress(user1), suint256(amount)); + + // Verify shares were assigned to correct address + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), amount); + } + + function test_MintToZeroAddressReverts() public { + uint256 amount = 1 * 1e18; + + // Attempt to mint to zero address should revert + vm.prank(minter); + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidReceiver.selector, address(0))); + token.mint(saddress(address(0)), suint256(amount)); + } + + function test_MintWithYieldCalculatesSharesCorrectly() public { + uint256 amount = 100 * 1e18; + uint256 yieldIncrement = 0.1e18; // 10% yield + + // Add yield first + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Calculate expected shares + uint256 expectedShares = (amount * BASE) / (BASE + yieldIncrement); + + // Mint tokens + vm.prank(minter); + token.mint(saddress(user1), suint256(amount)); + + // Verify correct number of shares were minted + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), expectedShares); + + // Verify balance shows full token amount (allow 1 wei difference) + vm.prank(user1); + uint256 actualBalance = token.balanceOf(saddress(user1)); + assertApproxEqAbs(actualBalance, amount, 1); + } + + function test_MultipleMints() public { + uint256[] memory amounts = new uint256[](3); + amounts[0] = 100 * 1e18; + amounts[1] = 50 * 1e18; + amounts[2] = 75 * 1e18; + + uint256 totalShares = 0; + uint256 currentMultiplier = BASE; + + // Perform multiple mints with yield changes in between + for(uint256 i = 0; i < amounts.length; i++) { + if(i > 0) { + // Add some yield before subsequent mints + uint256 yieldIncrement = 0.0001e18 * (i + 1); + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + currentMultiplier += yieldIncrement; + } + + vm.prank(minter); + token.mint(saddress(user1), suint256(amounts[i])); + + // Calculate shares for this mint + totalShares += (amounts[i] * BASE) / currentMultiplier; + } + + // Calculate expected final balance based on total shares and final multiplier + uint256 expectedBalance = (totalShares * currentMultiplier) / BASE; + + // Verify final balance reflects total minted amount with yield + vm.prank(user1); + uint256 actualBalance = token.balanceOf(saddress(user1)); + assertEq(actualBalance, expectedBalance); + + // Verify shares are calculated correctly + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), totalShares); + } + + function test_MintPrivacy() public { + uint256 amount = 100 * 1e18; + + // Mint tokens + vm.prank(minter); + token.mint(saddress(user1), suint256(amount)); + + // Recipient can see their balance + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), amount); + + // Other users cannot see the balance + vm.prank(user2); + assertEq(token.balanceOf(saddress(user1)), 0); + } + + function test_OnlyMinterCanMint() public { + uint256 amount = 100 * 1e18; + + // Non-minter cannot mint + bytes32 minterRole = token.MINTER_ROLE(); + vm.startPrank(user1); + vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, minterRole, user1)); + token.mint(saddress(user2), suint256(amount)); + vm.stopPrank(); + + // Minter can mint + vm.prank(minter); + token.mint(saddress(user2), suint256(amount)); + + // Verify mint was successful + vm.prank(user2); + assertEq(token.balanceOf(saddress(user2)), amount); + } + + function test_MintZeroAmount() public { + // Minting zero amount should succeed but not change state + vm.prank(minter); + token.mint(saddress(user1), suint256(0)); + + // Verify no shares or balance were assigned + vm.prank(user1); + assertEq(token.sharesOf(saddress(user1)), 0); + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), 0); + } +} \ No newline at end of file diff --git a/dwell/test/USDY/USDY.Pause.t.sol b/dwell/test/USDY/USDY.Pause.t.sol new file mode 100644 index 0000000..e16d4e8 --- /dev/null +++ b/dwell/test/USDY/USDY.Pause.t.sol @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {USDY} from "../../src/USDY.sol"; + + +contract USDYPauseTest is Test { + USDY public token; + address public admin; + address public minter; + address public burner; + address public pauser; + address public user1; + address public user2; + uint256 public constant BASE = 1e18; + uint256 public constant INITIAL_MINT = 1000 * 1e18; + + event Paused(address account); + event Unpaused(address account); + event Transfer(address indexed from, address indexed to, uint256 value); + + function setUp() public { + admin = address(1); + minter = address(2); + burner = address(3); + pauser = address(4); + user1 = address(5); + user2 = address(6); + + token = new USDY(admin); + + vm.startPrank(admin); + token.grantRole(token.MINTER_ROLE(), minter); + token.grantRole(token.BURNER_ROLE(), burner); + token.grantRole(token.PAUSE_ROLE(), pauser); + vm.stopPrank(); + + // Initial mint to test transfers + vm.prank(minter); + token.mint(saddress(user1), suint256(INITIAL_MINT)); + } + + function test_MintingWhenUnpaused() public { + uint256 amount = 10 * 1e18; + + // Pause and then unpause + vm.startPrank(pauser); + token.pause(); + token.unpause(); + vm.stopPrank(); + + // Should be able to mint after unpausing + vm.prank(minter); + vm.expectEmit(true, true, false, true); + emit Transfer(address(0), user2, amount); + token.mint(saddress(user2), suint256(amount)); + + // Verify mint was successful + vm.prank(user2); + assertEq(token.balanceOf(saddress(user2)), amount); + } + + function test_MintingWhenPaused() public { + uint256 amount = 10 * 1e18; + + // Pause + vm.prank(pauser); + token.pause(); + + // Should not be able to mint while paused + vm.prank(minter); + vm.expectRevert(USDY.TransferWhilePaused.selector); + token.mint(saddress(user2), suint256(amount)); + } + + function test_BurningWhenUnpaused() public { + uint256 amount = 10 * 1e18; + + // Pause and then unpause + vm.startPrank(pauser); + token.pause(); + token.unpause(); + vm.stopPrank(); + + // Should be able to burn after unpausing + vm.prank(burner); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, address(0), amount); + token.burn(saddress(user1), suint256(amount)); + + // Verify burn was successful + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT - amount); + } + + function test_BurningWhenPaused() public { + uint256 amount = 10 * 1e18; + + // Pause + vm.prank(pauser); + token.pause(); + + // Should not be able to burn while paused + vm.prank(burner); + vm.expectRevert(USDY.TransferWhilePaused.selector); + token.burn(saddress(user1), suint256(amount)); + } + + function test_TransfersWhenUnpaused() public { + uint256 amount = 10 * 1e18; + + // Pause and then unpause + vm.startPrank(pauser); + token.pause(); + token.unpause(); + vm.stopPrank(); + + // Should be able to transfer after unpausing + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, user2, amount); + token.transfer(saddress(user2), suint256(amount)); + + // Verify transfer was successful + vm.prank(user2); + assertEq(token.balanceOf(saddress(user2)), amount); + } + + function test_TransfersWhenPaused() public { + uint256 amount = 10 * 1e18; + + // Pause + vm.prank(pauser); + token.pause(); + + // Should not be able to transfer while paused + vm.prank(user1); + vm.expectRevert(USDY.TransferWhilePaused.selector); + token.transfer(saddress(user2), suint256(amount)); + } + + function test_TransferFromWhenUnpaused() public { + uint256 amount = 10 * 1e18; + + // Setup approval + vm.prank(user1); + token.approve(saddress(user2), suint256(amount)); + + // Pause and then unpause + vm.startPrank(pauser); + token.pause(); + token.unpause(); + vm.stopPrank(); + + // Should be able to transferFrom after unpausing + vm.prank(user2); + token.transferFrom(saddress(user1), saddress(user2), suint256(amount)); + + // Verify transfer was successful + vm.prank(user2); + assertEq(token.balanceOf(saddress(user2)), amount); + } + + function test_TransferFromWhenPaused() public { + uint256 amount = 10 * 1e18; + + // Setup approval + vm.prank(user1); + token.approve(saddress(user2), suint256(amount)); + + // Pause + vm.prank(pauser); + token.pause(); + + // Should not be able to transferFrom while paused + vm.prank(user2); + vm.expectRevert(USDY.TransferWhilePaused.selector); + token.transferFrom(saddress(user1), saddress(user2), suint256(amount)); + } + + function test_PauseUnpauseEvents() public { + // Test pause event + vm.prank(pauser); + vm.expectEmit(true, true, true, true); + emit Paused(pauser); + token.pause(); + + // Test unpause event + vm.prank(pauser); + vm.expectEmit(true, true, true, true); + emit Unpaused(pauser); + token.unpause(); + } + + function test_OnlyPauserCanPauseUnpause() public { + // Non-pauser cannot pause + vm.startPrank(user1); + bytes32 pauseRole = token.PAUSE_ROLE(); + vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, pauseRole, user1)); + token.pause(); + vm.stopPrank(); + + // Pause with correct role + vm.prank(pauser); + token.pause(); + + // Non-pauser cannot unpause + vm.startPrank(user1); + vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, pauseRole, user1)); + token.unpause(); + vm.stopPrank(); + + // Unpause with correct role + vm.prank(pauser); + token.unpause(); + } + + function test_CannotPauseWhenPaused() public { + // Pause first time + vm.prank(pauser); + token.pause(); + + // Try to pause again + vm.prank(pauser); + vm.expectRevert(USDY.TransferWhilePaused.selector); + token.pause(); + } + + function test_CannotUnpauseWhenUnpaused() public { + // Try to unpause when not paused + vm.prank(pauser); + vm.expectRevert(USDY.TransferWhilePaused.selector); + token.unpause(); + } +} \ No newline at end of file diff --git a/dwell/test/USDY/USDY.Privacy.t.sol b/dwell/test/USDY/USDY.Privacy.t.sol new file mode 100644 index 0000000..13f7db2 --- /dev/null +++ b/dwell/test/USDY/USDY.Privacy.t.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {USDY} from "../../src/USDY.sol"; +import {IERC20Errors} from "../../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; + +contract USDYPrivacyTest is Test { + USDY public token; + address public admin; + address public minter; + address public oracle; + address public user1; + address public user2; + address public observer; + uint256 public constant BASE = 1e18; + uint256 public constant INITIAL_MINT = 1000 * 1e18; + + event Transfer(address indexed from, address indexed to, uint256 value); + + function setUp() public { + admin = address(1); + minter = address(2); + oracle = address(3); + user1 = address(4); + user2 = address(5); + observer = address(6); + + token = new USDY(admin); + + vm.startPrank(admin); + token.grantRole(token.MINTER_ROLE(), minter); + token.grantRole(token.ORACLE_ROLE(), oracle); + vm.stopPrank(); + + vm.prank(minter); + token.mint(saddress(user1), suint256(INITIAL_MINT)); + } + + function test_BalancePrivacyWithYield() public { + // Add yield + vm.prank(oracle); + token.addRewardMultiplier(0.1e18); // 10% yield + + // Owner can see actual balance with yield + vm.prank(user1); + uint256 actualBalance = token.balanceOf(saddress(user1)); + uint256 expectedBalance = (INITIAL_MINT * 11) / 10; + assertApproxEqAbs(actualBalance, expectedBalance, 1); // Allow 1 wei difference + + // Others see zero + vm.prank(observer); + assertEq(token.balanceOf(saddress(user1)), 0); + } + + function test_TransferPrivacyWithYield() public { + // Add yield + vm.prank(oracle); + token.addRewardMultiplier(0.1e18); // 10% yield + + uint256 transferAmount = 100 * 1e18; + + // Transfer should emit event with actual value + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, user2, transferAmount); + token.transfer(saddress(user2), suint256(transferAmount)); + + // Only recipient can see their balance + vm.prank(user2); + uint256 actualBalance = token.balanceOf(saddress(user2)); + assertApproxEqAbs(actualBalance, transferAmount, 1); // Allow 1 wei difference + + // Others (including sender) see zero + vm.prank(user1); + assertEq(token.balanceOf(saddress(user2)), 0); + vm.prank(observer); + assertEq(token.balanceOf(saddress(user2)), 0); + } + + function test_MintBurnPrivacy() public { + uint256 amount = 100 * 1e18; + + // Mint new tokens + vm.prank(minter); + token.mint(saddress(user2), suint256(amount)); + + // Only recipient can see minted amount + vm.prank(user2); + uint256 actualBalance = token.balanceOf(saddress(user2)); + assertApproxEqAbs(actualBalance, amount, 1); // Allow 1 wei difference + + // Others see zero + vm.prank(observer); + assertEq(token.balanceOf(saddress(user2)), 0); + + // Grant burner role for testing + vm.startPrank(admin); + token.grantRole(token.BURNER_ROLE(), admin); + + // Burn tokens + token.burn(saddress(user2), suint256(amount)); + vm.stopPrank(); + + // Balance should be zero after burn + vm.prank(user2); + assertEq(token.balanceOf(saddress(user2)), 0); + } + + function test_TotalSupplyPrivacy() public { + // Total supply should be visible to all + assertApproxEqAbs(token.totalSupply(), INITIAL_MINT, 1); + + // Add yield + vm.prank(oracle); + token.addRewardMultiplier(0.1e18); // 10% yield + + // Total supply should reflect yield + uint256 expectedSupply = (INITIAL_MINT * 11) / 10; + assertApproxEqAbs(token.totalSupply(), expectedSupply, 1); + + // Mint more tokens + vm.prank(minter); + token.mint(saddress(user2), suint256(100 * 1e18)); + + // Total supply should include new mint + expectedSupply = ((INITIAL_MINT * 11) / 10) + (100 * 1e18); + assertApproxEqAbs(token.totalSupply(), expectedSupply, 1); + } +} diff --git a/dwell/test/USDY/USDY.Transfer.t.sol b/dwell/test/USDY/USDY.Transfer.t.sol new file mode 100644 index 0000000..d35ade9 --- /dev/null +++ b/dwell/test/USDY/USDY.Transfer.t.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {USDY} from "../../src/USDY.sol"; +import {IERC20Errors} from "../../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; + +contract USDYTransferTest is Test { + USDY public token; + address public admin; + address public minter; + address public user1; + address public user2; + address public spender; + uint256 public constant INITIAL_MINT = 1000 * 1e18; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function setUp() public { + admin = vm.addr(1); + minter = vm.addr(2); + user1 = vm.addr(3); + user2 = vm.addr(4); + spender = vm.addr(5); + + // Start admin context + vm.startPrank(admin); + + // Deploy and setup roles + token = new USDY(admin); + token.grantRole(token.MINTER_ROLE(), minter); + + vm.stopPrank(); + + // Mint initial tokens + vm.prank(minter); + token.mint(saddress(user1), suint256(INITIAL_MINT)); + } + + function test_TransferEmitsEvent() public { + uint256 amount = 100e18; + + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, user2, amount); // Changed from 0 to actual amount + token.transfer(saddress(user2), suint256(amount)); + } + + function test_TransferFromEmitsEvent() public { + uint256 amount = 100e18; + + vm.prank(user1); + token.approve(saddress(spender), suint256(amount)); + + vm.prank(spender); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, user2, amount); // Changed from 0 to actual amount + token.transferFrom(saddress(user1), saddress(user2), suint256(amount)); + } +} \ No newline at end of file diff --git a/dwell/test/USDY/USDY.Yield.t.sol b/dwell/test/USDY/USDY.Yield.t.sol new file mode 100644 index 0000000..e5e572f --- /dev/null +++ b/dwell/test/USDY/USDY.Yield.t.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {USDY} from "../../src/USDY.sol"; +import {IERC20Errors} from "../../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; + +contract USDYYieldTest is Test { + USDY public token; + address public admin; + address public minter; + address public oracle; + address public user1; + address public user2; + uint256 public constant BASE = 1e18; + uint256 public constant INITIAL_MINT = 1000 * 1e18; + + event RewardMultiplierUpdated(uint256 newMultiplier); + event Transfer(address indexed from, address indexed to, uint256 value); + + function setUp() public { + admin = address(1); + minter = address(2); + oracle = address(3); + user1 = address(4); + user2 = address(5); + + token = new USDY(admin); + + vm.startPrank(admin); + token.grantRole(token.MINTER_ROLE(), minter); + token.grantRole(token.ORACLE_ROLE(), oracle); + vm.stopPrank(); + + vm.prank(minter); + token.mint(saddress(user1), suint256(INITIAL_MINT)); + } + + function test_YieldAccumulationWithSmallIncrements() public { + uint256[] memory increments = new uint256[](5); + increments[0] = 0.001e18; // 0.1% + increments[1] = 0.0005e18; // 0.05% + increments[2] = 0.002e18; // 0.2% + increments[3] = 0.0015e18; // 0.15% + increments[4] = 0.001e18; // 0.1% + + uint256 totalMultiplier = BASE; + + // Apply small yield increments + for(uint256 i = 0; i < increments.length; i++) { + vm.prank(oracle); + token.addRewardMultiplier(increments[i]); + totalMultiplier += increments[i]; + } + + // Calculate expected balance with accumulated yield + uint256 expectedBalance = (INITIAL_MINT * totalMultiplier) / BASE; + + // Check balance reflects all small yield increments + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), expectedBalance); + } + + function test_YieldAccumulationWithTransfers() public { + // Initial transfer to split tokens + uint256 transferAmount = INITIAL_MINT / 2; + vm.prank(user1); + token.transfer(saddress(user2), suint256(transferAmount)); + + // Add yield multiple times + uint256 totalYield = 0; + uint256[] memory increments = new uint256[](3); + increments[0] = 0.1e18; // 10% + increments[1] = 0.05e18; // 5% + increments[2] = 0.15e18; // 15% + + for(uint256 i = 0; i < increments.length; i++) { + vm.prank(oracle); + token.addRewardMultiplier(increments[i]); + totalYield += increments[i]; + } + + // Calculate expected balances + uint256 multiplier = BASE + totalYield; + uint256 expectedBalance = (transferAmount * multiplier) / BASE; + + // Verify both users' balances reflect accumulated yield + vm.prank(user1); + assertEq(token.balanceOf(saddress(user1)), expectedBalance); + vm.prank(user2); + assertEq(token.balanceOf(saddress(user2)), expectedBalance); + } + + function test_YieldAccumulationWithMinting() public { + uint256 yieldIncrement = 0.1e18; // 10% + uint256 mintAmount = 100 * 1e18; + + // Add yield first + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Mint new tokens + vm.prank(minter); + token.mint(saddress(user2), suint256(mintAmount)); + + // Calculate expected balance for new mint (should not include previous yield) + vm.prank(user2); + uint256 actualBalance = token.balanceOf(saddress(user2)); + assertApproxEqAbs(actualBalance, mintAmount, 1); // Allow 1 wei difference + + // Original holder's balance should include yield + vm.prank(user1); + uint256 expectedYieldBalance = (INITIAL_MINT * (BASE + yieldIncrement)) / BASE; + actualBalance = token.balanceOf(saddress(user1)); + assertApproxEqAbs(actualBalance, expectedYieldBalance, 1); // Allow 1 wei difference + } + + function test_YieldAccumulationWithBurning() public { + uint256 yieldIncrement = 0.1e18; // 10% + uint256 burnAmount = 100 * 1e18; + + // Add yield first + vm.prank(oracle); + token.addRewardMultiplier(yieldIncrement); + + // Grant burner role to admin for testing + vm.startPrank(admin); + token.grantRole(token.BURNER_ROLE(), admin); + + // Burn tokens directly - contract will handle share conversion internally + token.burn(saddress(user1), suint256(burnAmount)); + vm.stopPrank(); + + // Calculate expected remaining balance after burning + uint256 expectedBalance = (INITIAL_MINT * (BASE + yieldIncrement)) / BASE - burnAmount; + + // Verify remaining balance (allow for 1 wei rounding difference) + vm.prank(user1); + uint256 actualBalance = token.balanceOf(saddress(user1)); + assertApproxEqAbs(actualBalance, expectedBalance, 1); // Allow 1 wei difference + } +}