├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── dwell ├── .gitignore ├── README.md ├── foundry.toml ├── src │ ├── ISRC20.sol │ ├── ISRC20Metadata.sol │ ├── SRC20.sol │ └── USDY.sol └── test │ ├── SRC20.t.sol │ ├── USDY.t.sol │ └── USDY │ ├── USDY.Allowance.t.sol │ ├── USDY.Approve.t.sol │ ├── USDY.Balance.t.sol │ ├── USDY.Burn.t.sol │ ├── USDY.Mint.t.sol │ ├── USDY.Pause.t.sol │ ├── USDY.Privacy.t.sol │ ├── USDY.Transfer.t.sol │ └── USDY.Yield.t.sol ├── folio ├── .gitignore ├── README.md ├── foundry.toml ├── src │ ├── ChainTracker.sol │ ├── Competition.sol │ └── IStoreFront.sol └── test │ ├── Chain.t.sol │ ├── Competition.t.sol │ └── utils │ └── MockStore.sol ├── level ├── .gitignore ├── README.md ├── foundry.toml ├── src │ ├── InternalAMM.sol │ ├── Level.sol │ ├── SRC20.sol │ └── WDGSRC20.sol └── test │ ├── AMM.t.sol │ ├── Level.t.sol │ └── utils │ └── MockSrc20.sol ├── nibble ├── .gitignore ├── .gitmodules ├── .prettierrc ├── README.md ├── foundry.toml ├── src │ ├── ISRC20.sol │ ├── Nibble.sol │ └── Rewards20.sol └── test │ └── Nibble.t.sol └── riff ├── .gitignore ├── README.md ├── foundry.toml ├── src ├── Riff.sol ├── SRC20.sol └── ViolinCoin.sol └── test └── Riff.t.sol /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build-and-test: 11 | runs-on: self-hosted 12 | concurrency: 13 | group: global-self-hosted-runner 14 | 15 | env: 16 | SFOUNDRY_ROOT: /home/azureuser/prototypes/seismic-foundry 17 | 18 | steps: 19 | - name: Check out repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Build sforge 23 | run: | 24 | cd "$SFOUNDRY_ROOT" 25 | cargo build --bin sforge 26 | 27 | - name: Return to workspace 28 | run: cd "$GITHUB_WORKSPACE" 29 | 30 | - name: Check formatting for all projects 31 | run: | 32 | find . -maxdepth 2 -type f -name "foundry.toml" -execdir cargo run --bin sforge --manifest-path "$SFOUNDRY_ROOT/Cargo.toml" fmt --check \; 33 | 34 | - name: Check build for all projects 35 | run: | 36 | find . -maxdepth 2 -type f -name "foundry.toml" -execdir cargo run --bin sforge --manifest-path "$SFOUNDRY_ROOT/Cargo.toml" build \; 37 | 38 | - name: Run tests for all projects 39 | run: | 40 | find . -maxdepth 2 -type f -name "foundry.toml" -execdir cargo run --bin sforge --manifest-path "$SFOUNDRY_ROOT/Cargo.toml" test \; 41 | 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Docs 11 | docs/ 12 | 13 | # Dotenv file 14 | .env 15 | .DS_Store 16 | 17 | node_modules/ -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "folio/lib/forge-std"] 2 | path = folio/lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | 5 | [submodule "nibble/lib/forge-std"] 6 | path = nibble/lib/forge-std 7 | url = https://github.com/foundry-rs/forge-std 8 | 9 | [submodule "level/lib/forge-std"] 10 | path = level/lib/forge-std 11 | url = https://github.com/foundry-rs/forge-std 12 | [submodule "level/lib/openzeppelin-contracts"] 13 | path = level/lib/openzeppelin-contracts 14 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 15 | 16 | [submodule "rent/lib/forge-std"] 17 | path = dwell/lib/forge-std 18 | url = https://github.com/foundry-rs/forge-std 19 | [submodule "rent/lib/openzeppelin-contracts"] 20 | path = dwell/lib/openzeppelin-contracts 21 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 22 | 23 | [submodule "riff/lib/forge-std"] 24 | path = riff/lib/forge-std 25 | url = https://github.com/foundry-rs/forge-std 26 | [submodule "riff/lib/solmate"] 27 | path = riff/lib/solmate 28 | url = https://github.com/Rari-Capital/solmate 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Wagner Santos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prototypes 2 | 3 | Welcome to **Seismic**'s repository of prototypes! This repo is designed to help beta users build new projects on Seismic, while serving as a collection of example code for anyone interested in learning how to work with the `stype`. Each project in this repo lives in its own directory and comes with a dedicated README providing further details. 4 | 5 | ## Purpose 6 | 7 | This repository exists for: 8 | 9 | - **Reference & Examples:** Developers looking to understand Seismic or seeking best practices can explore these prototypes as real-world examples. 10 | - **Collaboration:** We encourage contributions, feedback, and discussions about all things Seismic. 11 | 12 | 13 | ## Descriptions 14 | 15 | Below is a quick summary of each prototype currently available in this repository: 16 | 17 | 1. **`LEVEL`** 18 | Stabilize your DePIN service. 19 | 1. **`DWELL`** 20 | Pay your rent with a yield-bearing stablecoin. 21 | 1. **`RIFF`** 22 | Listen to a bonding curve. 23 | 1. **`FOLIO`** 24 | Participate in a global pay-it-forward chain. 25 | 1. **`NIBBLE`** 26 | Earn revenue share in your favorite restaurant. 27 | 28 | If you've already [installed](https://docs.seismic.systems/onboarding/publish-your-docs) Seismic on your local machine, you can `cd` into each directory and run 29 | ``` 30 | sforge test 31 | ``` 32 | 33 | ## Contributing 34 | 35 | 1. **Fork** this repository. 36 | 2. **Create** a new branch for your prototype or feature. 37 | 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`. 38 | 4. **Open** a pull request describing your changes and why they're valuable. 39 | 40 | We're excited to see what you build and look forward to collaborating on the future of Seismic! 41 | 42 | ## Get in Touch 43 | 44 | - **Website:** [Seismic](https://www.seismic.systems) 45 | - **Twitter:** [@SeismicSys](https://x.com/SeismicSys) 46 | 47 | 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! 48 | -------------------------------------------------------------------------------- /dwell/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | broadcast/ 3 | cache/ 4 | out/ 5 | -------------------------------------------------------------------------------- /dwell/README.md: -------------------------------------------------------------------------------- 1 | # DWELL: Pay your rent with a yield-bearing stablecoin 2 | 3 | ## Overview 4 | 5 | **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. 6 | 7 | **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. 8 | 9 | **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. 10 | 11 | ## Architecture 12 | 13 | - `SRC20.sol`: Base privacy-preserving ERC20 implementation using shielded types 14 | - `ISRC20.sol`: Interface for shielded ERC20 functionality 15 | - `USDY.sol`: Yield-bearing USD stablecoin with privacy features 16 | - Comprehensive test suite in `test/` directory 17 | 18 | ## License 19 | 20 | AGPL-3.0-only 21 | -------------------------------------------------------------------------------- /dwell/foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | 6 | # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options 7 | -------------------------------------------------------------------------------- /dwell/src/ISRC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | 3 | pragma solidity ^0.8.20; 4 | 5 | /** 6 | * @dev Interface of the ERC-20 standard as defined in the ERC, modified for shielded types. 7 | */ 8 | interface ISRC20 { 9 | /** 10 | * @dev Function to emit when tokens are moved from one account to another. 11 | * Must be overridden by implementing contracts to define event emission behavior. 12 | * Default implementation is no-op for privacy. 13 | * 14 | * @param from The sender address 15 | * @param to The recipient address 16 | * @param value The transfer amount 17 | */ 18 | function emitTransfer(address from, address to, uint256 value) external; 19 | 20 | /** 21 | * @dev Function to emit when allowance is modified. 22 | * Must be overridden by implementing contracts to define event emission behavior. 23 | * Default implementation is no-op for privacy. 24 | * 25 | * @param owner The token owner 26 | * @param spender The approved spender 27 | * @param value The approved amount 28 | */ 29 | function emitApproval(address owner, address spender, uint256 value) external; 30 | 31 | /** 32 | * @dev Returns the value of tokens in existence. 33 | */ 34 | function totalSupply() external view returns (uint256); 35 | 36 | /** 37 | * @dev Returns the value of tokens owned by `account`. 38 | * For privacy reasons, returns actual balance only if caller is the account owner, 39 | * otherwise reverts. 40 | */ 41 | function balanceOf(saddress account) external view returns (uint256); 42 | 43 | /** 44 | * @dev Moves a shielded `value` amount of tokens from the caller's account to `to`. 45 | * 46 | * Returns a boolean value indicating whether the operation succeeded, 47 | * otherwise reverts. 48 | * 49 | * Expected that implementation calls emitTransfer. 50 | */ 51 | function transfer(saddress to, suint256 value) external returns (bool); 52 | 53 | /** 54 | * @dev Returns the remaining number of tokens that `spender` will be 55 | * allowed to spend on behalf of `owner` through {transferFrom}. This is 56 | * zero by default. 57 | * 58 | * This value changes when {approve} or {transferFrom} are called. 59 | * For privacy reasons, returns actual allowance only if caller is either owner or spender, 60 | * otherwise reverts. 61 | */ 62 | function allowance(saddress owner, saddress spender) external view returns (uint256); 63 | 64 | /** 65 | * @dev Sets a shielded `value` amount of tokens as the allowance of a shielded `spender` over the 66 | * caller's tokens. 67 | * 68 | * Returns a boolean value indicating whether the operation succeeded. 69 | * 70 | * Expected that implementation calls emitApproval. 71 | */ 72 | function approve(saddress spender, suint256 value) external returns (bool); 73 | 74 | /** 75 | * @dev Moves a shielded `value` amount of tokens from a shielded `from` address to a shielded `to` address using the 76 | * allowance mechanism. `value` is then deducted from the caller's 77 | * allowance. 78 | * 79 | * Returns a boolean value indicating whether the operation succeeded. 80 | * 81 | * Expected that implementation calls emitTransfer. 82 | */ 83 | function transferFrom(saddress from, saddress to, suint256 value) external returns (bool); 84 | } 85 | -------------------------------------------------------------------------------- /dwell/src/ISRC20Metadata.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | 3 | pragma solidity ^0.8.20; 4 | 5 | import {ISRC20} from "./ISRC20.sol"; 6 | 7 | /** 8 | * @dev Interface for the optional metadata functions from the ERC-20 standard. 9 | */ 10 | interface ISRC20Metadata is ISRC20 { 11 | /** 12 | * @dev Returns the name of the token. 13 | */ 14 | function name() external view returns (string memory); 15 | 16 | /** 17 | * @dev Returns the symbol of the token. 18 | */ 19 | function symbol() external view returns (string memory); 20 | 21 | /** 22 | * @dev Returns the decimals places of the token. 23 | */ 24 | function decimals() external view returns (uint8); 25 | } 26 | -------------------------------------------------------------------------------- /dwell/src/SRC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | 3 | pragma solidity ^0.8.20; 4 | 5 | import {ISRC20} from "./ISRC20.sol"; 6 | import {ISRC20Metadata} from "./ISRC20Metadata.sol"; 7 | import {Context} from "../lib/openzeppelin-contracts/contracts/utils/Context.sol"; 8 | import {IERC20Errors} from "../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; 9 | 10 | error UnauthorizedView(); 11 | 12 | /** 13 | * @dev Implementation of the {ISRC20} interface with privacy protections using shielded types. 14 | * Public view functions that would leak privacy are implemented as no-ops while maintaining interface compatibility. 15 | * Total supply remains public while individual balances and transfers are private. 16 | */ 17 | abstract contract SRC20 is Context, ISRC20, ISRC20Metadata, IERC20Errors { 18 | mapping(saddress account => suint256) private _balances; 19 | mapping(saddress account => mapping(saddress spender => suint256)) private _allowances; 20 | 21 | uint256 private _totalSupply; 22 | 23 | string private _name; 24 | string private _symbol; 25 | 26 | /** 27 | * @dev Sets the values for {name} and {symbol}. 28 | * 29 | * All two of these values are immutable: they can only be set once during 30 | * construction. 31 | */ 32 | constructor(string memory name_, string memory symbol_) { 33 | _name = name_; 34 | _symbol = symbol_; 35 | } 36 | 37 | /** 38 | * @dev Returns the name of the token. 39 | */ 40 | function name() public view virtual returns (string memory) { 41 | return _name; 42 | } 43 | 44 | /** 45 | * @dev Returns the symbol of the token, usually a shorter version of the 46 | * name. 47 | */ 48 | function symbol() public view virtual returns (string memory) { 49 | return _symbol; 50 | } 51 | 52 | /** 53 | * @dev Returns the number of decimals used to get its user representation. 54 | * For example, if `decimals` equals `2`, a balance of `505` tokens should 55 | * be displayed to a user as `5.05` (`505 / 10 ** 2`). 56 | * 57 | * Tokens usually opt for a value of 18, imitating the relationship between 58 | * Ether and Wei. This is the default value returned by this function, unless 59 | * it's overridden. 60 | * 61 | * NOTE: This information is only used for _display_ purposes: it in 62 | * no way affects any of the arithmetic of the contract, including 63 | * {ISRC20-balanceOf} and {ISRC20-transfer}. 64 | */ 65 | function decimals() public view virtual override returns (uint8) { 66 | return 18; 67 | } 68 | 69 | /** 70 | * @dev See {ISRC20-totalSupply}. 71 | */ 72 | function totalSupply() public view virtual returns (uint256) { 73 | return _totalSupply; 74 | } 75 | 76 | /** 77 | * @dev See {ISRC20-balanceOf}. 78 | * Reverts if caller is not the account owner to maintain privacy. 79 | */ 80 | function balanceOf(saddress account) public view virtual override returns (uint256) { 81 | if (account == saddress(_msgSender())) { 82 | return uint256(_balances[account]); 83 | } 84 | revert UnauthorizedView(); 85 | } 86 | 87 | /** 88 | * @dev Safe version of balanceOf that returns success boolean along with balance. 89 | * Returns (true, balance) if caller is the account owner, (false, 0) otherwise. 90 | */ 91 | function safeBalanceOf(saddress account) public view returns (bool, uint256) { 92 | if (account == saddress(_msgSender())) { 93 | return (true, uint256(_balances[account])); 94 | } 95 | return (false, 0); 96 | } 97 | 98 | /** 99 | * @dev Transfers a shielded `value` amount of tokens to a shielded `to` address. 100 | * 101 | * Requirements: 102 | * 103 | * - `to` cannot be the zero address. 104 | * - the caller must have a balance of at least `value`. 105 | * 106 | * Note: Both `to` and `value` are shielded to maintain privacy. 107 | */ 108 | function transfer(saddress to, suint256 value) public virtual override returns (bool) { 109 | saddress owner = saddress(_msgSender()); 110 | _transfer(owner, to, value); 111 | return true; 112 | } 113 | 114 | /** 115 | * @dev See {ISRC20-allowance}. 116 | * Reverts if caller is neither the owner nor the spender to maintain privacy. 117 | */ 118 | function allowance(saddress owner, saddress spender) public virtual view returns (uint256) { 119 | saddress caller = saddress(_msgSender()); 120 | if (caller == owner || caller == spender) { 121 | return uint256(_allowances[saddress(owner)][saddress(spender)]); 122 | } 123 | revert UnauthorizedView(); 124 | } 125 | 126 | /** 127 | * @dev Safe version of allowance that returns success boolean along with allowance. 128 | * Returns (true, allowance) if caller is owner or spender, (false, 0) otherwise. 129 | */ 130 | function safeAllowance(saddress owner, saddress spender) public view returns (bool, uint256) { 131 | saddress caller = saddress(_msgSender()); 132 | if (caller == owner || caller == spender) { 133 | return (true, uint256(_allowances[saddress(owner)][saddress(spender)])); 134 | } 135 | return (false, 0); 136 | } 137 | 138 | /** 139 | * @dev Approves a shielded `spender` to spend a shielded `value` amount of tokens on behalf of the caller. 140 | * 141 | * NOTE: If `value` is the maximum `suint256`, the allowance is not updated on 142 | * `transferFrom`. This is semantically equivalent to an infinite approval. 143 | * 144 | * WARNING: Changing an allowance with this method can have security implications. When changing an approved 145 | * allowance to a specific value, a race condition may occur if another transaction is submitted before 146 | * the original allowance change is confirmed. To safely adjust allowances, use {increaseAllowance} and 147 | * {decreaseAllowance} which provide atomic operations protected against such race conditions. 148 | * 149 | * Requirements: 150 | * 151 | * - `spender` cannot be the zero address. 152 | * 153 | * Note: Both `spender` and `value` are shielded to maintain privacy. 154 | */ 155 | function approve(saddress spender, suint256 value) public virtual override returns (bool) { 156 | saddress owner = saddress(_msgSender()); 157 | _approve(owner, spender, value); 158 | return true; 159 | } 160 | 161 | /** 162 | * @dev Transfers a shielded `value` amount of tokens from a shielded `from` address to a shielded `to` address. 163 | * 164 | * Skips emitting an {Approval} event indicating an allowance update to maintain privacy. 165 | * 166 | * NOTE: Does not update the allowance if the current allowance 167 | * is the maximum `suint256`. 168 | * 169 | * Requirements: 170 | * 171 | * - `from` and `to` cannot be the zero address. 172 | * - `from` must have a balance of at least `value`. 173 | * - the caller must have allowance for ``from``'s tokens of at least 174 | * `value`. 175 | * 176 | * Note: All parameters are shielded to maintain privacy. 177 | */ 178 | function transferFrom(saddress from, saddress to, suint256 value) public virtual returns (bool) { 179 | saddress spender = saddress(_msgSender()); 180 | _spendAllowance(from, spender, value); 181 | _transfer(from, to, value); 182 | return true; 183 | } 184 | 185 | /** 186 | * @dev Atomically increases the allowance granted to a shielded `spender` by a shielded `addedValue`. 187 | * 188 | * This is an alternative to {approve} that can be used as a mitigation for 189 | * problems described in {ISRC20-approve}. 190 | * 191 | * The operation is atomic - it directly accesses and modifies the underlying 192 | * shielded allowance mapping to prevent race conditions. 193 | * 194 | * Requirements: 195 | * 196 | * - `spender` cannot be the zero address. 197 | * - The sum of current allowance and `addedValue` must not overflow. 198 | * 199 | * Note: Both `spender` and `addedValue` are shielded to maintain privacy. 200 | */ 201 | function increaseAllowance(saddress spender, suint256 addedValue) public virtual returns (bool) { 202 | saddress owner = saddress(_msgSender()); 203 | suint256 currentAllowance = _allowances[owner][spender]; 204 | _approve(owner, spender, currentAllowance + addedValue); 205 | return true; 206 | } 207 | 208 | /** 209 | * @dev Atomically decreases the allowance granted to a shielded `spender` by a shielded `subtractedValue`. 210 | * 211 | * This is an alternative to {approve} that can be used as a mitigation for 212 | * problems described in {ISRC20-approve}. 213 | * 214 | * The operation is atomic - it directly accesses and modifies the underlying 215 | * shielded allowance mapping to prevent race conditions. 216 | * 217 | * Requirements: 218 | * 219 | * - `spender` cannot be the zero address. 220 | * - The current allowance must be greater than or equal to `subtractedValue`. 221 | * - The difference between the current allowance and `subtractedValue` must not underflow. 222 | * 223 | * Note: Both `spender` and `subtractedValue` are shielded to maintain privacy. 224 | */ 225 | function decreaseAllowance(saddress spender, suint256 subtractedValue) public virtual returns (bool) { 226 | saddress owner = saddress(_msgSender()); 227 | suint256 currentAllowance = _allowances[owner][spender]; 228 | if (currentAllowance < subtractedValue) { 229 | revert ERC20InsufficientAllowance(address(spender), 0, 0); 230 | } 231 | unchecked { 232 | _approve(owner, spender, currentAllowance - subtractedValue); 233 | } 234 | return true; 235 | } 236 | 237 | /** 238 | * @dev Moves a shielded `value` amount of tokens from a shielded `from` to a shielded `to` address. 239 | * 240 | * This internal function is equivalent to {transfer}, and can be used to 241 | * e.g. implement automatic token fees, slashing mechanisms, etc. 242 | * 243 | * Calls emitTransfer. 244 | * 245 | * NOTE: This function is not virtual, {_update} should be overridden instead. 246 | */ 247 | function _transfer(saddress from, saddress to, suint256 value) internal { 248 | if (from == saddress(address(0))) { 249 | revert ERC20InvalidSender(address(0)); 250 | } 251 | if (to == saddress(address(0))) { 252 | revert ERC20InvalidReceiver(address(0)); 253 | } 254 | _update(from, to, value); 255 | } 256 | 257 | /** 258 | * @dev Transfers a `value` amount of tokens from `from` to `to`, or alternatively mints (or burns) if `from` 259 | * (or `to`) is the zero address. All customizations to transfers, mints, and burns should be done by overriding 260 | * this function. 261 | * 262 | * Calls `emitTransferEvent`. 263 | */ 264 | function _update(saddress from, saddress to, suint256 value) internal virtual { 265 | _beforeTokenTransfer(from, to, value); 266 | 267 | if (from == saddress(address(0))) { 268 | // Convert from shielded to unshielded for total supply 269 | _totalSupply += uint256(value); 270 | } else { 271 | suint256 fromBalance = _balances[from]; 272 | if (fromBalance < value) { 273 | revert ERC20InsufficientBalance(address(from), uint256(0), uint256(0)); 274 | } 275 | unchecked { 276 | _balances[from] = fromBalance - value; 277 | } 278 | } 279 | 280 | if (to == saddress(address(0))) { 281 | unchecked { 282 | // Convert from shielded to unshielded for total supply 283 | _totalSupply -= uint256(value); 284 | } 285 | } else { 286 | unchecked { 287 | _balances[to] += value; 288 | } 289 | } 290 | 291 | emitTransfer(address(from), address(to), uint256(value)); 292 | 293 | _afterTokenTransfer(from, to, value); 294 | } 295 | 296 | /** 297 | * @dev Creates a shielded `value` amount of tokens and assigns them to a shielded `account`. 298 | * Relies on the `_update` mechanism. 299 | * 300 | * Calls emitTransfer. 301 | * 302 | * NOTE: This function is not virtual, {_update} should be overridden instead. 303 | */ 304 | function _mint(saddress account, suint256 value) internal { 305 | if (account == saddress(address(0))) { 306 | revert ERC20InvalidReceiver(address(0)); 307 | } 308 | _update(saddress(address(0)), account, value); 309 | } 310 | 311 | /** 312 | * @dev Destroys a shielded `value` amount of tokens from a shielded `account`, lowering the total supply. 313 | * Relies on the `_update` mechanism. 314 | * 315 | * Calls emitTransfer. 316 | * 317 | * NOTE: This function is not virtual, {_update} should be overridden instead 318 | */ 319 | function _burn(saddress account, suint256 value) internal { 320 | if (account == saddress(address(0))) { 321 | revert ERC20InvalidSender(address(0)); 322 | } 323 | _update(account, saddress(address(0)), value); 324 | } 325 | 326 | /** 327 | * @dev Sets `value` as the allowance of `spender` over the `owner` s tokens. 328 | * 329 | * This internal function is equivalent to `approve`, and can be used to 330 | * e.g. set automatic allowances for certain subsystems, etc. 331 | * 332 | * Calls emitApproval which is a no-op by default for privacy. 333 | * 334 | * Requirements: 335 | * 336 | * - `owner` cannot be the zero address. 337 | * - `spender` cannot be the zero address. 338 | */ 339 | function _approve(saddress owner, saddress spender, suint256 value) internal virtual { 340 | if (owner == saddress(address(0))) { 341 | revert ERC20InvalidApprover(address(0)); 342 | } 343 | if (spender == saddress(address(0))) { 344 | revert ERC20InvalidSpender(address(0)); 345 | } 346 | _allowances[owner][spender] = value; 347 | emitApproval(address(owner), address(spender), uint256(value)); 348 | } 349 | 350 | /** 351 | * @dev Updates `owner` s allowance for `spender` based on spent `value`. 352 | * 353 | * Does not update the allowance value in case of infinite allowance. 354 | * Revert if not enough allowance is available. 355 | * 356 | * Does not emit an {Approval} event. 357 | */ 358 | function _spendAllowance(saddress owner, saddress spender, suint256 value) internal virtual { 359 | suint256 currentAllowance = _allowances[owner][spender]; 360 | if (currentAllowance < type(suint256).max) { 361 | if (currentAllowance < value) { 362 | revert ERC20InsufficientAllowance(address(spender), uint256(0), uint256(0)); // Zero values to protect privacy 363 | } 364 | unchecked { 365 | _approve(owner, spender, currentAllowance - value); 366 | } 367 | } 368 | } 369 | 370 | /** 371 | * @dev Hook that is called before any transfer of tokens. This includes 372 | * minting and burning. 373 | * 374 | * Calling conditions: 375 | * 376 | * - when `from` and `to` are both non-zero, `value` of ``from``'s tokens 377 | * will be transferred to `to`. 378 | * - when `from` is zero, `value` tokens will be minted for `to`. 379 | * - when `to` is zero, `value` of ``from``'s tokens will be burned. 380 | * - `from` and `to` are never both zero. 381 | * 382 | * Note: The `value` parameter is a shielded uint256 to maintain privacy. 383 | * 384 | * To learn more about hooks, head to xref:ROOT:extending-contracts.adoc#using-hooks[Using Hooks]. 385 | */ 386 | function _beforeTokenTransfer(saddress from, saddress to, suint256 value) internal virtual {} 387 | 388 | /** 389 | * @dev Hook that is called after any transfer of tokens. This includes 390 | * minting and burning. 391 | * 392 | * Calling conditions: 393 | * 394 | * - when `from` and `to` are both non-zero, `value` of ``from``'s tokens 395 | * has been transferred to `to`. 396 | * - when `from` is zero, `value` tokens have been minted for `to`. 397 | * - when `to` is zero, `value` of ``from``'s tokens have been burned. 398 | * - `from` and `to` are never both zero. 399 | * 400 | * Note: The `value` parameter is a shielded uint256 to maintain privacy. 401 | * 402 | * To learn more about hooks, head to xref:ROOT:extending-contracts.adoc#using-hooks[Using Hooks]. 403 | */ 404 | function _afterTokenTransfer(saddress from, saddress to, suint256 value) internal virtual {} 405 | 406 | /** 407 | * @dev Implementation of emitTransfer. No-op by default for privacy. 408 | * Can be overridden to implement custom event emission behavior. 409 | */ 410 | function emitTransfer(address from, address to, uint256 value) public virtual override { 411 | // No-op by default 412 | } 413 | 414 | /** 415 | * @dev Implementation of emitApproval. No-op by default for privacy. 416 | * Can be overridden to implement custom event emission behavior. 417 | */ 418 | function emitApproval(address owner, address spender, uint256 value) public virtual override { 419 | // No-op by default 420 | } 421 | } 422 | -------------------------------------------------------------------------------- /dwell/src/USDY.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | 3 | pragma solidity ^0.8.20; 4 | 5 | import {SRC20} from "./SRC20.sol"; 6 | 7 | /** 8 | * @title USDY - Yield-bearing USD Stablecoin with Privacy Features 9 | * @notice A yield-bearing stablecoin that uses shielded types for privacy protection 10 | * @dev Implements SRC20 for shielded balances and transfers. This is a final implementation, not meant to be inherited from. 11 | */ 12 | contract USDY is SRC20 { 13 | // Base value for rewardMultiplier (18 decimals) 14 | uint256 private constant BASE = 1e18; 15 | 16 | // Current reward multiplier, represents accumulated yield 17 | suint256 private rewardMultiplier; 18 | 19 | // Shielded shares storage 20 | mapping(saddress => suint256) private _shares; 21 | suint256 private _totalShares; 22 | 23 | // Access control roles 24 | bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); 25 | bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); 26 | bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE"); 27 | bytes32 public constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); 28 | bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; 29 | 30 | // Role management 31 | mapping(bytes32 => mapping(address => bool)) private _roles; 32 | 33 | // Pause state 34 | bool private _paused; 35 | 36 | // Events 37 | event RewardMultiplierUpdated(uint256 newMultiplier); 38 | event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); 39 | event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); 40 | event Paused(address account); 41 | event Unpaused(address account); 42 | event Transfer(address indexed from, address indexed to, uint256 value); 43 | event Approval(address indexed owner, address indexed spender, uint256 value); 44 | 45 | // Custom errors 46 | error InvalidRewardMultiplier(uint256 multiplier); 47 | error ZeroRewardIncrement(); 48 | error MissingRole(bytes32 role, address account); 49 | error TransferWhilePaused(); 50 | error UnauthorizedView(); 51 | 52 | /** 53 | * @notice Constructs the USDY contract 54 | * @param admin The address that will have admin rights 55 | */ 56 | constructor(address admin) SRC20("USD Yield", "USDY") { 57 | if (admin == address(0)) revert ERC20InvalidReceiver(admin); 58 | _grantRole(DEFAULT_ADMIN_ROLE, admin); 59 | rewardMultiplier = suint256(BASE); // Initialize with 1.0 multiplier 60 | } 61 | 62 | /** 63 | * @notice Returns the number of decimals used to get its user representation. 64 | */ 65 | function decimals() public pure override returns (uint8) { 66 | return 18; 67 | } 68 | 69 | /** 70 | * @notice Converts an amount of tokens to shares 71 | * @param amount The amount of tokens to convert 72 | * @return The equivalent amount of shares 73 | */ 74 | function convertToShares(suint256 amount) internal view returns (suint256) { 75 | if (uint256(rewardMultiplier) == 0) return amount; 76 | return (amount * suint256(BASE)) / rewardMultiplier; 77 | } 78 | 79 | /** 80 | * @notice Converts an amount of shares to tokens 81 | * @param shares The amount of shares to convert 82 | * @return The equivalent amount of tokens 83 | */ 84 | function convertToTokens(suint256 shares) internal view returns (suint256) { 85 | return (shares * rewardMultiplier) / suint256(BASE); 86 | } 87 | 88 | /** 89 | * @notice Returns the total amount of shares 90 | * @return The total amount of shares 91 | */ 92 | function totalShares() public view returns (uint256) { 93 | return uint256(_totalShares); 94 | } 95 | 96 | /** 97 | * @notice Returns the total supply of tokens 98 | * @return The total supply of tokens, accounting for yield 99 | */ 100 | function totalSupply() public view override returns (uint256) { 101 | return uint256(convertToTokens(_totalShares)); 102 | } 103 | 104 | /** 105 | * @notice Returns the current reward multiplier 106 | * @return The current reward multiplier value 107 | */ 108 | function getCurrentRewardMultiplier() public view returns (uint256) { 109 | return uint256(rewardMultiplier); 110 | } 111 | 112 | /** 113 | * @notice Returns the amount of shares owned by the account 114 | * @param account The account to check 115 | * @return The amount of shares owned by the account, or 0 if caller is not the account owner 116 | */ 117 | function sharesOf(saddress account) public view returns (uint256) { 118 | // Only return shares if caller is the account owner 119 | if (account == saddress(_msgSender())) { 120 | return uint256(_shares[account]); 121 | } 122 | return 0; 123 | } 124 | 125 | /** 126 | * @notice Override balanceOf to calculate balance based on shares and current reward multiplier 127 | * @param account The account to check the balance of 128 | * @return The current balance in tokens, accounting for yield 129 | */ 130 | function balanceOf(saddress account) public view override returns (uint256) { 131 | // Only return balance if caller is the account owner 132 | if (account == saddress(_msgSender())) { 133 | return uint256(convertToTokens(_shares[account])); 134 | } 135 | return 0; 136 | } 137 | 138 | /** 139 | * @notice Modifier that checks if the caller has a specific role 140 | */ 141 | modifier onlyRole(bytes32 role) { 142 | address sender = _msgSender(); 143 | if (!hasRole(role, sender)) { 144 | revert MissingRole(role, sender); 145 | } 146 | _; 147 | } 148 | 149 | /** 150 | * @notice Modifier to make a function callable only when the contract is not paused 151 | */ 152 | modifier whenNotPaused() { 153 | if (_paused) revert TransferWhilePaused(); 154 | _; 155 | } 156 | 157 | /** 158 | * @notice Returns true if `account` has been granted `role` 159 | */ 160 | function hasRole(bytes32 role, address account) public view returns (bool) { 161 | if (account == address(0)) return false; 162 | return _roles[role][account]; 163 | } 164 | 165 | /** 166 | * @notice Grants `role` to `account` 167 | * @dev The caller must have the admin role 168 | */ 169 | function grantRole(bytes32 role, address account) external onlyRole(DEFAULT_ADMIN_ROLE) { 170 | if (account == address(0)) revert ERC20InvalidReceiver(account); 171 | _grantRole(role, account); 172 | } 173 | 174 | /** 175 | * @notice Revokes `role` from `account` 176 | * @dev The caller must have the admin role 177 | */ 178 | function revokeRole(bytes32 role, address account) external onlyRole(DEFAULT_ADMIN_ROLE) { 179 | if (account == address(0)) revert ERC20InvalidSender(account); 180 | _revokeRole(role, account); 181 | } 182 | 183 | /** 184 | * @notice Internal function to grant a role to an account 185 | */ 186 | function _grantRole(bytes32 role, address account) internal { 187 | if (!hasRole(role, account)) { 188 | _roles[role][account] = true; 189 | emit RoleGranted(role, account, _msgSender()); 190 | } 191 | } 192 | 193 | /** 194 | * @notice Internal function to revoke a role from an account 195 | */ 196 | function _revokeRole(bytes32 role, address account) internal { 197 | if (hasRole(role, account)) { 198 | _roles[role][account] = false; 199 | emit RoleRevoked(role, account, _msgSender()); 200 | } 201 | } 202 | 203 | /** 204 | * @notice Returns true if the contract is paused, and false otherwise 205 | */ 206 | function paused() public view returns (bool) { 207 | return _paused; 208 | } 209 | 210 | /** 211 | * @notice Updates the reward multiplier to reflect new yield 212 | * @param increment The amount to increase the multiplier by 213 | */ 214 | function addRewardMultiplier(uint256 increment) external onlyRole(ORACLE_ROLE) { 215 | if (increment == 0) revert ZeroRewardIncrement(); 216 | 217 | uint256 newMultiplierValue = uint256(rewardMultiplier) + increment; 218 | if (newMultiplierValue < BASE) { 219 | revert InvalidRewardMultiplier(newMultiplierValue); 220 | } 221 | 222 | rewardMultiplier = suint256(newMultiplierValue); 223 | emit RewardMultiplierUpdated(newMultiplierValue); 224 | } 225 | 226 | /** 227 | * @notice Mints new tokens to a shielded address 228 | * @param to The shielded address to mint to 229 | * @param amount The shielded amount to mint 230 | */ 231 | function mint(saddress to, suint256 amount) external onlyRole(MINTER_ROLE) whenNotPaused { 232 | _mint(to, amount); 233 | } 234 | 235 | /** 236 | * @notice Burns tokens from a shielded address 237 | * @param from The shielded address to burn from 238 | * @param amount The shielded amount to burn 239 | */ 240 | function burn(saddress from, suint256 amount) external onlyRole(BURNER_ROLE) whenNotPaused { 241 | _burn(from, amount); 242 | } 243 | 244 | /** 245 | * @notice Pauses all token transfers 246 | */ 247 | function pause() external onlyRole(PAUSE_ROLE) { 248 | if (_paused) revert TransferWhilePaused(); 249 | _paused = true; 250 | emit Paused(_msgSender()); 251 | } 252 | 253 | /** 254 | * @notice Unpauses all token transfers 255 | */ 256 | function unpause() external onlyRole(PAUSE_ROLE) { 257 | if (!_paused) revert TransferWhilePaused(); 258 | _paused = false; 259 | emit Unpaused(_msgSender()); 260 | } 261 | 262 | /** 263 | * @dev Override _update to account for shielded shares rather than raw token balances 264 | * @param from The sender address 265 | * @param to The recipient address 266 | * @param value The amount of tokens to transfer 267 | */ 268 | function _update(saddress from, saddress to, suint256 value) internal override { 269 | _beforeTokenTransfer(from, to, value); 270 | 271 | suint256 shares = convertToShares(value); 272 | 273 | if (from == saddress(address(0))) { 274 | // Minting 275 | _totalShares += shares; 276 | _shares[to] += shares; 277 | } else if (to == saddress(address(0))) { 278 | // Burning 279 | suint256 fromShares = _shares[from]; 280 | if (fromShares < shares) { 281 | revert ERC20InsufficientBalance(address(from), uint256(convertToTokens(fromShares)), uint256(value)); 282 | } 283 | unchecked { 284 | _shares[from] = fromShares - shares; 285 | _totalShares -= shares; 286 | } 287 | } else { 288 | // Transfer 289 | suint256 fromShares = _shares[from]; 290 | if (fromShares < shares) { 291 | revert ERC20InsufficientBalance(address(from), uint256(convertToTokens(fromShares)), uint256(value)); 292 | } 293 | unchecked { 294 | _shares[from] = fromShares - shares; 295 | _shares[to] += shares; 296 | } 297 | } 298 | 299 | emit Transfer(address(from), address(to), uint256(value)); 300 | 301 | _afterTokenTransfer(from, to, value); 302 | } 303 | 304 | /** 305 | * @notice Hook that is called before any transfer 306 | * @dev Adds pausable functionality to transfers 307 | */ 308 | function _beforeTokenTransfer(saddress from, saddress to, suint256 value) internal override { 309 | if (_paused) revert TransferWhilePaused(); 310 | } 311 | 312 | /** 313 | * @notice Hook that is called after any transfer 314 | */ 315 | function _afterTokenTransfer(saddress from, saddress to, suint256 value) internal override { 316 | // No additional functionality needed after transfer 317 | } 318 | 319 | /** 320 | * @notice Transfers a specified number of tokens from the caller's address to the recipient. 321 | * @dev Uses new _update for share-based accounting while maintaining token-based amount parameter 322 | * @param to The shielded address to which tokens will be transferred. 323 | * @param amount The shielded number of tokens to transfer. 324 | * @return A boolean value indicating whether the operation succeeded. 325 | */ 326 | function transfer(saddress to, suint256 amount) public override whenNotPaused returns (bool) { 327 | if (to == saddress(address(0))) { 328 | revert ERC20InvalidReceiver(address(0)); 329 | } 330 | _update(saddress(_msgSender()), to, amount); 331 | return true; 332 | } 333 | 334 | /** 335 | * @notice Transfers tokens from one address to another using the allowance mechanism 336 | * @dev Uses new _update for share-based accounting while maintaining token-based amount parameter 337 | * @param from The shielded address to transfer from 338 | * @param to The shielded address to transfer to 339 | * @param amount The shielded amount to transfer 340 | * @return A boolean value indicating whether the operation succeeded 341 | */ 342 | function transferFrom(saddress from, saddress to, suint256 amount) public override whenNotPaused returns (bool) { 343 | address spender = _msgSender(); 344 | 345 | if (from == saddress(address(0))) { 346 | revert ERC20InvalidSender(address(0)); 347 | } 348 | if (to == saddress(address(0))) { 349 | revert ERC20InvalidReceiver(address(0)); 350 | } 351 | 352 | uint256 currentAllowance = allowance(from, saddress(spender)); 353 | if (currentAllowance < uint256(amount)) { 354 | revert ERC20InsufficientAllowance(spender, currentAllowance, uint256(amount)); 355 | } 356 | 357 | _spendAllowance(from, saddress(spender), amount); 358 | _update(from, to, amount); 359 | return true; 360 | } 361 | 362 | /** 363 | * @dev See {ISRC20-allowance}. 364 | * @notice Returns the amount of tokens the spender is allowed to spend on behalf of the owner. 365 | * @dev Reverts with UnauthorizedView if the caller is neither the owner nor the spender. 366 | */ 367 | function allowance(saddress owner, saddress spender) public view override returns (uint256) { 368 | if (owner != saddress(_msgSender()) && spender != saddress(_msgSender())) { 369 | revert UnauthorizedView(); 370 | } 371 | return super.allowance(owner, spender); 372 | } 373 | 374 | /** 375 | * @notice Sets `amount` as the allowance of `spender` over the caller's tokens 376 | * @dev Adds pausable functionality on top of SRC20's approve 377 | * @param spender The address which will spend the funds 378 | * @param amount The amount of tokens to be spent 379 | * @return A boolean value indicating whether the operation succeeded 380 | */ 381 | function approve(saddress spender, suint256 amount) public override whenNotPaused returns (bool) { 382 | address owner = _msgSender(); 383 | if (owner == address(0)) revert ERC20InvalidSpender(address(0)); 384 | return super.approve(spender, amount); 385 | } 386 | 387 | /** 388 | * @notice Atomically increases the allowance granted to `spender` by the caller 389 | * @dev Adds pausable functionality on top of SRC20's increaseAllowance 390 | * @param spender The address which will spend the funds 391 | * @param addedValue The amount of tokens to increase the allowance by 392 | * @return A boolean value indicating whether the operation succeeded 393 | */ 394 | function increaseAllowance(saddress spender, suint256 addedValue) public virtual override whenNotPaused returns (bool) { 395 | return super.increaseAllowance(spender, addedValue); 396 | } 397 | 398 | /** 399 | * @notice Atomically decreases the allowance granted to `spender` by the caller 400 | * @dev Adds pausable functionality on top of SRC20's decreaseAllowance 401 | * @param spender The address which will spend the funds 402 | * @param subtractedValue The amount of tokens to decrease the allowance by 403 | * @return A boolean value indicating whether the operation succeeded 404 | */ 405 | function decreaseAllowance(saddress spender, suint256 subtractedValue) public virtual override whenNotPaused returns (bool) { 406 | address owner = _msgSender(); 407 | uint256 currentAllowance = allowance(saddress(owner), spender); 408 | uint256 subtractedAmount = uint256(subtractedValue); 409 | if (currentAllowance < subtractedAmount) { 410 | revert ERC20InsufficientAllowance(address(spender), currentAllowance, subtractedAmount); 411 | } 412 | unchecked { 413 | return super.decreaseAllowance(spender, subtractedValue); 414 | } 415 | } 416 | 417 | // Override the event emission functions to emit actual events 418 | function emitTransfer(address from, address to, uint256 value) public virtual override { 419 | emit Transfer(from, to, value); 420 | } 421 | 422 | function emitApproval(address owner, address spender, uint256 value) public virtual override { 423 | emit Approval(owner, spender, value); 424 | } 425 | } 426 | -------------------------------------------------------------------------------- /dwell/test/USDY.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test, console, stdError} from "forge-std/Test.sol"; 5 | import {USDY} from "../src/USDY.sol"; 6 | import {IERC20Errors} from "../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; 7 | 8 | contract USDYTest is Test { 9 | USDY public token; 10 | address public admin; 11 | address public minter; 12 | address public burner; 13 | address public oracle; 14 | address public pauser; 15 | address public user1; 16 | address public user2; 17 | 18 | uint256 public constant BASE = 1e18; 19 | uint256 public constant INITIAL_MINT = 1000 * 1e18; 20 | 21 | event RewardMultiplierUpdated(uint256 newMultiplier); 22 | event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); 23 | event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); 24 | event Paused(address account); 25 | event Unpaused(address account); 26 | event Transfer(address indexed from, address indexed to, uint256 value); 27 | event Approval(address indexed owner, address indexed spender, uint256 value); 28 | 29 | function setUp() public { 30 | admin = address(1); 31 | minter = address(2); 32 | burner = address(3); 33 | oracle = address(4); 34 | pauser = address(5); 35 | user1 = address(6); 36 | user2 = address(7); 37 | 38 | // Deploy with admin 39 | token = new USDY(admin); 40 | 41 | // Setup roles 42 | vm.startPrank(admin); 43 | token.grantRole(token.MINTER_ROLE(), minter); 44 | token.grantRole(token.BURNER_ROLE(), burner); 45 | token.grantRole(token.ORACLE_ROLE(), oracle); 46 | token.grantRole(token.PAUSE_ROLE(), pauser); 47 | vm.stopPrank(); 48 | 49 | // Initial mint 50 | vm.prank(minter); 51 | token.mint(saddress(user1), suint256(INITIAL_MINT)); 52 | } 53 | 54 | // Basic Functionality Tests 55 | 56 | function test_Metadata() public view { 57 | assertEq(token.name(), "USD Yield"); 58 | assertEq(token.symbol(), "USDY"); 59 | assertEq(token.decimals(), 18); 60 | } 61 | 62 | function test_InitialState() public { 63 | assertEq(token.totalSupply(), INITIAL_MINT); 64 | vm.prank(user1); 65 | assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT); 66 | } 67 | 68 | // Role Management Tests 69 | 70 | function test_RoleManagement() public view { 71 | assertTrue(token.hasRole(token.DEFAULT_ADMIN_ROLE(), admin)); 72 | assertTrue(token.hasRole(token.MINTER_ROLE(), minter)); 73 | assertTrue(token.hasRole(token.BURNER_ROLE(), burner)); 74 | assertTrue(token.hasRole(token.ORACLE_ROLE(), oracle)); 75 | assertTrue(token.hasRole(token.PAUSE_ROLE(), pauser)); 76 | } 77 | 78 | function test_RoleGrantRevoke() public { 79 | address newMinter = address(10); 80 | 81 | vm.startPrank(admin); 82 | 83 | // Grant role 84 | vm.expectEmit(true, true, true, true); 85 | emit RoleGranted(token.MINTER_ROLE(), newMinter, admin); 86 | token.grantRole(token.MINTER_ROLE(), newMinter); 87 | assertTrue(token.hasRole(token.MINTER_ROLE(), newMinter)); 88 | 89 | // Revoke role 90 | vm.expectEmit(true, true, true, true); 91 | emit RoleRevoked(token.MINTER_ROLE(), newMinter, admin); 92 | token.revokeRole(token.MINTER_ROLE(), newMinter); 93 | assertFalse(token.hasRole(token.MINTER_ROLE(), newMinter)); 94 | 95 | vm.stopPrank(); 96 | } 97 | 98 | /** 99 | * @notice Tests that only admin can grant roles 100 | * @dev This test verifies that: 101 | * 1. A non-admin account cannot grant roles 102 | * 2. The correct error is thrown with proper parameters 103 | * 3. The DEFAULT_ADMIN_ROLE is required to grant any role 104 | * 105 | * The test flow: 106 | * - Confirms caller doesn't have admin role 107 | * - Attempts to grant MINTER_ROLE as non-admin 108 | * - Verifies the attempt fails with MissingRole error 109 | */ 110 | function test_OnlyAdminCanGrantRoles() public { 111 | address caller = user1; 112 | 113 | // Verify precondition: caller should not have admin role 114 | assertFalse(token.hasRole(token.DEFAULT_ADMIN_ROLE(), caller)); 115 | 116 | // Cache the minter role to avoid additional calls during revert check 117 | bytes32 minterRole = token.MINTER_ROLE(); 118 | 119 | // Set up the prank before expecting revert 120 | vm.prank(caller); 121 | 122 | // Attempt to grant role should fail with MissingRole error 123 | vm.expectRevert(); 124 | token.grantRole(minterRole, user2); 125 | } 126 | 127 | // Minting and Burning Tests 128 | 129 | function test_MintAndBurn() public { 130 | uint256 amount = 100e18; 131 | 132 | // Test minting 133 | vm.prank(minter); 134 | token.mint(saddress(user1), suint256(amount)); 135 | 136 | vm.prank(user1); 137 | assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT + amount); 138 | assertEq(token.totalSupply(), INITIAL_MINT + amount); 139 | 140 | // Test burning 141 | vm.prank(burner); 142 | token.burn(saddress(user1), suint256(amount)); 143 | 144 | vm.prank(user1); 145 | assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT); 146 | assertEq(token.totalSupply(), INITIAL_MINT); 147 | } 148 | 149 | function test_MintWithoutRole() public { 150 | uint256 amount = 100e18; 151 | 152 | vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, token.MINTER_ROLE(), user1)); 153 | vm.prank(user1); 154 | token.mint(saddress(user1), suint256(amount)); 155 | } 156 | 157 | function test_BurnWithoutRole() public { 158 | uint256 amount = 100e18; 159 | 160 | // First mint some tokens 161 | vm.prank(minter); 162 | token.mint(saddress(user1), suint256(amount)); 163 | 164 | // Try to burn without role 165 | vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, token.BURNER_ROLE(), user1)); 166 | vm.prank(user1); 167 | token.burn(saddress(user1), suint256(amount)); 168 | } 169 | 170 | function test_MintWhenPaused() public { 171 | uint256 amount = 100e18; 172 | 173 | // Pause the contract 174 | vm.prank(pauser); 175 | token.pause(); 176 | 177 | // Try to mint when paused 178 | vm.expectRevert(USDY.TransferWhilePaused.selector); 179 | vm.prank(minter); 180 | token.mint(saddress(user1), suint256(amount)); 181 | } 182 | 183 | function test_BurnWhenPaused() public { 184 | uint256 amount = 100e18; 185 | 186 | // First mint some tokens 187 | vm.prank(minter); 188 | token.mint(saddress(user1), suint256(amount)); 189 | 190 | // Pause the contract 191 | vm.prank(pauser); 192 | token.pause(); 193 | 194 | // Try to burn when paused 195 | vm.expectRevert(USDY.TransferWhilePaused.selector); 196 | vm.prank(burner); 197 | token.burn(saddress(user1), suint256(amount)); 198 | } 199 | 200 | function test_PausedTransfers() public { 201 | // Pause the contract 202 | vm.prank(pauser); 203 | token.pause(); 204 | 205 | // Try to mint 206 | vm.prank(minter); 207 | vm.expectRevert(USDY.TransferWhilePaused.selector); 208 | token.mint(saddress(user1), suint256(100 * 1e18)); 209 | 210 | // Try to burn 211 | vm.prank(burner); 212 | vm.expectRevert(USDY.TransferWhilePaused.selector); 213 | token.burn(saddress(user1), suint256(100 * 1e18)); 214 | } 215 | 216 | // Yield/Reward Multiplier Tests 217 | 218 | function test_InitialRewardMultiplier() public { 219 | // Transfer to check initial yield behavior 220 | uint256 transferAmount = 100 * 1e18; 221 | vm.prank(user1); 222 | token.transfer(saddress(user2), suint256(transferAmount)); 223 | 224 | // Check balances reflect 1:1 ratio initially 225 | vm.prank(user1); 226 | assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT - transferAmount); 227 | vm.prank(user2); 228 | assertEq(token.balanceOf(saddress(user2)), transferAmount); 229 | } 230 | 231 | /** 232 | * @notice Test reward multiplier updates and their effect on token balances 233 | * @dev This test verifies that: 234 | * 1. Reward multiplier updates correctly increase token values 235 | * 2. Transfers after yield updates use proper share calculations 236 | * 3. Final balances reflect both transferred amounts and accumulated yield 237 | * 238 | * The test flow: 239 | * 1. Start with initial balance in user1's account 240 | * 2. Add 10% yield through reward multiplier 241 | * 3. Transfer tokens from user1 to user2 242 | * 4. Verify balances reflect both the transfer and yield 243 | */ 244 | function test_RewardMultiplierUpdate() public { 245 | uint256 increment = 0.1e18; // 10% increase 246 | uint256 initialBalance = INITIAL_MINT; 247 | 248 | // Add yield by increasing reward multiplier by 10% 249 | vm.prank(oracle); 250 | vm.expectEmit(true, true, true, true); 251 | emit RewardMultiplierUpdated(BASE + increment); 252 | token.addRewardMultiplier(increment); 253 | 254 | // Calculate initial balance after yield 255 | // When yield is added, the same number of shares are worth more tokens 256 | uint256 yieldAdjustedInitialBalance = (initialBalance * (BASE + increment)) / BASE; 257 | 258 | // Set up transfer amount and calculate shares 259 | uint256 transferAmount = 100e18; 260 | 261 | // Calculate shares needed for transfer 262 | // When transferring tokens with active yield: 263 | // shares = tokens * (BASE / (BASE + yield)) 264 | uint256 transferShares = (transferAmount * BASE) / (BASE + increment); 265 | 266 | // Calculate expected final balances for both users 267 | // User1: Convert remaining shares to tokens using new yield rate 268 | uint256 expectedUser1Shares = initialBalance - transferShares; 269 | uint256 expectedUser1Balance = (expectedUser1Shares * (BASE + increment)) / BASE; 270 | // User2: Convert received shares to tokens using new yield rate 271 | uint256 expectedUser2Balance = (transferShares * (BASE + increment)) / BASE; 272 | 273 | // Perform transfer 274 | vm.prank(user1); 275 | token.transfer(saddress(user2), suint256(transferAmount)); 276 | 277 | // Verify final balances 278 | vm.prank(user1); 279 | uint256 user1Balance = token.balanceOf(saddress(user1)); 280 | vm.prank(user2); 281 | uint256 user2Balance = token.balanceOf(saddress(user2)); 282 | 283 | // Assert balances match expected values 284 | assertEq(user1Balance, expectedUser1Balance, "User1 balance incorrect"); 285 | assertEq(user2Balance, expectedUser2Balance, "User2 balance incorrect"); 286 | 287 | // Verify total supply reflects yield increase 288 | assertEq(token.totalSupply(), yieldAdjustedInitialBalance, "Total supply incorrect"); 289 | } 290 | 291 | function test_OnlyOracleCanUpdateRewardMultiplier() public { 292 | address caller = user1; 293 | 294 | // Attempt to update reward multiplier should fail 295 | vm.startPrank(caller); 296 | vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, token.ORACLE_ROLE(), caller)); 297 | token.addRewardMultiplier(0.1e18); 298 | vm.stopPrank(); 299 | } 300 | 301 | function test_CannotSetZeroRewardIncrement() public { 302 | vm.prank(oracle); 303 | vm.expectRevert(USDY.ZeroRewardIncrement.selector); 304 | token.addRewardMultiplier(0); 305 | } 306 | 307 | function test_RewardMultiplierOverflow() public { 308 | vm.prank(oracle); 309 | vm.expectRevert(stdError.arithmeticError); 310 | token.addRewardMultiplier(type(uint256).max); 311 | } 312 | 313 | // Pause Functionality Tests 314 | 315 | function test_Pause() public { 316 | vm.prank(pauser); 317 | token.pause(); 318 | assertTrue(token.paused()); 319 | 320 | // Transfers should fail while paused 321 | vm.prank(user1); 322 | vm.expectRevert(USDY.TransferWhilePaused.selector); 323 | token.transfer(saddress(user2), suint256(100 * 1e18)); 324 | } 325 | 326 | function test_Unpause() public { 327 | // Pause first 328 | vm.prank(pauser); 329 | token.pause(); 330 | 331 | // Then unpause 332 | vm.prank(pauser); 333 | token.unpause(); 334 | assertFalse(token.paused()); 335 | 336 | // Transfers should work again 337 | uint256 transferAmount = 100 * 1e18; 338 | vm.prank(user1); 339 | token.transfer(saddress(user2), suint256(transferAmount)); 340 | 341 | vm.prank(user2); 342 | assertEq(token.balanceOf(saddress(user2)), transferAmount); 343 | } 344 | 345 | function test_OnlyPauserCanPauseUnpause() public { 346 | address caller = user1; 347 | 348 | // Attempt to pause should fail 349 | vm.startPrank(caller); 350 | vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, token.PAUSE_ROLE(), caller)); 351 | token.pause(); 352 | vm.stopPrank(); 353 | } 354 | 355 | // Privacy Tests 356 | 357 | function test_BalancePrivacy() public { 358 | // Other users can't see balance 359 | assertEq(token.balanceOf(saddress(user1)), 0); 360 | 361 | // Owner can see their balance 362 | vm.prank(user1); 363 | assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT); 364 | } 365 | 366 | function test_TransferPrivacy() public { 367 | uint256 transferAmount = 100e18; 368 | 369 | // Transfer should emit event with actual value for transparency 370 | vm.prank(user1); 371 | vm.expectEmit(true, true, false, true); 372 | emit Transfer(user1, user2, transferAmount); 373 | token.transfer(saddress(user2), suint256(transferAmount)); 374 | } 375 | 376 | function test_ApprovalPrivacy() public { 377 | uint256 approvalAmount = 100e18; 378 | 379 | // Approve should emit event with actual value for transparency 380 | vm.prank(user1); 381 | vm.expectEmit(true, true, false, true); 382 | emit Approval(user1, user2, approvalAmount); 383 | token.approve(saddress(user2), suint256(approvalAmount)); 384 | } 385 | 386 | // Combined Functionality Tests 387 | 388 | function test_PausedMintingAndBurning() public { 389 | vm.prank(pauser); 390 | token.pause(); 391 | 392 | vm.prank(minter); 393 | vm.expectRevert(USDY.TransferWhilePaused.selector); 394 | token.mint(saddress(user1), suint256(100 * 1e18)); 395 | 396 | vm.prank(burner); 397 | vm.expectRevert(USDY.TransferWhilePaused.selector); 398 | token.burn(saddress(user1), suint256(100 * 1e18)); 399 | } 400 | 401 | /** 402 | * @notice Test yield accumulation behavior with multiple transfers 403 | * @dev This test verifies that: 404 | * 1. Yield is correctly applied to all token holders when reward multiplier increases 405 | * 2. Transfers correctly handle share calculations with active yield 406 | * 3. Final balances reflect both transferred amounts and accumulated yield 407 | * 408 | * The key mechanism being tested: 409 | * - Token balances are stored internally as shares 410 | * - Initially, shares are 1:1 with tokens 411 | * - When yield is added, the shares remain constant but are worth more tokens 412 | * - Transfers convert token amounts to shares using current yield rate 413 | * - Final balances are calculated by converting shares back to tokens using yield rate 414 | */ 415 | function test_YieldAccumulationWithTransfers() public { 416 | // Initial state has user1 with INITIAL_MINT tokens (and thus INITIAL_MINT shares) 417 | // and user2 with 0 tokens/shares 418 | 419 | // Add 10% yield by increasing reward multiplier 420 | vm.prank(oracle); 421 | token.addRewardMultiplier(0.1e18); 422 | // Now each share is worth 1.1 tokens 423 | 424 | // Set up transfer amount and calculate corresponding shares 425 | uint256 transferAmount = 100e18; 426 | // When transferring 100 tokens with 1.1 yield rate: 427 | // 100 tokens = x shares * 1.1 428 | // x shares = 100 * (1/1.1) = 90.909... shares 429 | uint256 expectedShares = (transferAmount * BASE) / (BASE + 0.1e18); 430 | 431 | // Calculate expected final balances 432 | // User1 starts with INITIAL_MINT shares (1:1 at initial mint) 433 | // After two transfers of expectedShares each: 434 | uint256 expectedUser1FinalShares = INITIAL_MINT - (2 * expectedShares); 435 | // Convert final shares to tokens using yield rate: 436 | uint256 expectedUser1FinalBalance = (expectedUser1FinalShares * (BASE + 0.1e18)) / BASE; 437 | // User2 receives 2 * expectedShares, convert to tokens using yield rate: 438 | uint256 expectedUser2FinalBalance = (2 * expectedShares * (BASE + 0.1e18)) / BASE; 439 | 440 | // Perform first transfer 441 | vm.prank(user1); 442 | token.transfer(saddress(user2), suint256(transferAmount)); 443 | 444 | // Perform second transfer 445 | vm.prank(user1); 446 | token.transfer(saddress(user2), suint256(transferAmount)); 447 | 448 | // Verify final balances 449 | vm.prank(user1); 450 | uint256 finalUser1Balance = uint256(token.balanceOf(saddress(user1))); 451 | vm.prank(user2); 452 | uint256 finalUser2Balance = uint256(token.balanceOf(saddress(user2))); 453 | 454 | // Assert that balances match expected values 455 | assertEq(finalUser1Balance, expectedUser1FinalBalance, "User1 balance mismatch"); 456 | assertEq(finalUser2Balance, expectedUser2FinalBalance, "User2 balance mismatch"); 457 | } 458 | } 459 | -------------------------------------------------------------------------------- /dwell/test/USDY/USDY.Allowance.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | import {USDY} from "../../src/USDY.sol"; 6 | 7 | contract USDYAllowanceTest is Test { 8 | USDY public token; 9 | address public admin; 10 | address public minter; 11 | address public oracle; 12 | address public owner; 13 | address public spender; 14 | address public recipient; 15 | uint256 public constant BASE = 1e18; 16 | uint256 public constant INITIAL_MINT = 1000 * 1e18; 17 | 18 | event Transfer(address indexed from, address indexed to, uint256 value); 19 | event RewardMultiplierUpdated(uint256 newMultiplier); 20 | 21 | function setUp() public { 22 | admin = address(1); 23 | minter = address(2); 24 | oracle = address(3); 25 | owner = address(4); 26 | spender = address(5); 27 | recipient = address(6); 28 | 29 | token = new USDY(admin); 30 | 31 | vm.startPrank(admin); 32 | token.grantRole(token.MINTER_ROLE(), minter); 33 | token.grantRole(token.ORACLE_ROLE(), oracle); 34 | vm.stopPrank(); 35 | 36 | vm.prank(minter); 37 | token.mint(saddress(owner), suint256(INITIAL_MINT)); 38 | } 39 | 40 | function test_AllowanceWithYield() public { 41 | uint256 allowanceAmount = 100 * 1e18; 42 | uint256 yieldIncrement = 0.1e18; // 10% yield 43 | 44 | // Set initial allowance 45 | vm.prank(owner); 46 | token.approve(saddress(spender), suint256(allowanceAmount)); 47 | 48 | // Add yield 49 | vm.prank(oracle); 50 | token.addRewardMultiplier(yieldIncrement); 51 | 52 | // Check allowance remains unchanged despite yield 53 | vm.prank(owner); 54 | assertEq(token.allowance(saddress(owner), saddress(spender)), allowanceAmount); 55 | 56 | // Calculate shares needed for transfer with yield 57 | uint256 transferAmount = 50 * 1e18; 58 | 59 | // Transfer using allowance 60 | vm.prank(spender); 61 | token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); 62 | 63 | // Verify allowance is reduced by token amount, not shares 64 | vm.prank(owner); 65 | assertEq(token.allowance(saddress(owner), saddress(spender)), allowanceAmount - transferAmount); 66 | 67 | // Verify recipient received correct amount with yield 68 | vm.prank(recipient); 69 | // use 1 wei tolerance to account for integer rounding errors / precision loss 70 | assertApproxEqAbs(token.balanceOf(saddress(recipient)), transferAmount, 1); 71 | } 72 | 73 | function test_AllowancePrivacyWithYield() public { 74 | uint256 allowanceAmount = 100 * 1e18; 75 | uint256 yieldIncrement = 0.1e18; 76 | 77 | // Set allowance 78 | vm.prank(owner); 79 | token.approve(saddress(spender), suint256(allowanceAmount)); 80 | 81 | // Add yield 82 | vm.prank(oracle); 83 | token.addRewardMultiplier(yieldIncrement); 84 | 85 | // Owner can see allowance 86 | vm.prank(owner); 87 | assertEq(token.allowance(saddress(owner), saddress(spender)), allowanceAmount); 88 | 89 | // Spender can see allowance 90 | vm.prank(spender); 91 | assertEq(token.allowance(saddress(owner), saddress(spender)), allowanceAmount); 92 | 93 | // Others cannot see allowance (reverts with UnauthorizedView) 94 | vm.prank(recipient); 95 | vm.expectRevert(USDY.UnauthorizedView.selector); 96 | token.allowance(saddress(owner), saddress(spender)); 97 | } 98 | 99 | function test_InfiniteAllowanceWithYield() public { 100 | // Set infinite allowance 101 | vm.prank(owner); 102 | token.approve(saddress(spender), suint256(type(uint256).max)); 103 | 104 | // Add yield multiple times 105 | vm.startPrank(oracle); 106 | token.addRewardMultiplier(0.1e18); // +10% 107 | token.addRewardMultiplier(0.05e18); // +5% 108 | token.addRewardMultiplier(0.15e18); // +15% 109 | vm.stopPrank(); 110 | 111 | // Transfer using allowance 112 | vm.prank(spender); 113 | token.transferFrom(saddress(owner), saddress(recipient), suint256(50 * 1e18)); 114 | 115 | // Verify allowance remains infinite 116 | vm.prank(owner); 117 | assertEq(token.allowance(saddress(owner), saddress(spender)), type(uint256).max); 118 | } 119 | 120 | function test_AllowanceUpdatesWithMultipleYieldChanges() public { 121 | uint256 allowanceAmount = 100 * 1e18; 122 | 123 | // Set initial allowance 124 | vm.prank(owner); 125 | token.approve(saddress(spender), suint256(allowanceAmount)); 126 | 127 | // Multiple yield changes 128 | vm.startPrank(oracle); 129 | token.addRewardMultiplier(0.1e18); // +10% 130 | token.addRewardMultiplier(0.05e18); // +5% 131 | vm.stopPrank(); 132 | 133 | // Transfer half of allowance 134 | uint256 transferAmount = allowanceAmount / 2; 135 | vm.prank(spender); 136 | token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); 137 | 138 | // Verify allowance is reduced correctly 139 | vm.prank(owner); 140 | assertEq(token.allowance(saddress(owner), saddress(spender)), allowanceAmount - transferAmount); 141 | 142 | // More yield changes 143 | vm.prank(oracle); 144 | token.addRewardMultiplier(0.15e18); // +15% 145 | 146 | // Transfer remaining allowance 147 | vm.prank(spender); 148 | token.transferFrom(saddress(owner), saddress(recipient), suint256(transferAmount)); 149 | 150 | // Verify allowance is zero 151 | vm.prank(owner); 152 | assertEq(token.allowance(saddress(owner), saddress(spender)), 0); 153 | } 154 | } -------------------------------------------------------------------------------- /dwell/test/USDY/USDY.Balance.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | import {USDY} from "../../src/USDY.sol"; 6 | 7 | 8 | contract USDYBalanceAndSharesTest is Test { 9 | USDY public token; 10 | address public admin; 11 | address public minter; 12 | address public oracle; 13 | address public user1; 14 | address public user2; 15 | uint256 public constant BASE = 1e18; 16 | uint256 public constant INITIAL_MINT = 1000 * 1e18; 17 | 18 | event Transfer(address indexed from, address indexed to, uint256 value); 19 | event RewardMultiplierUpdated(uint256 newMultiplier); 20 | 21 | function setUp() public { 22 | admin = address(1); 23 | minter = address(2); 24 | oracle = address(3); 25 | user1 = address(4); 26 | user2 = address(5); 27 | 28 | token = new USDY(admin); 29 | 30 | vm.startPrank(admin); 31 | token.grantRole(token.MINTER_ROLE(), minter); 32 | token.grantRole(token.ORACLE_ROLE(), oracle); 33 | vm.stopPrank(); 34 | } 35 | 36 | function test_BalanceReturnsTokensNotShares() public { 37 | uint256 tokensAmount = 10 * 1e18; 38 | uint256 yieldIncrement = 0.0001e18; // 0.01% yield 39 | 40 | // Mint tokens first 41 | vm.prank(minter); 42 | token.mint(saddress(user1), suint256(tokensAmount)); 43 | 44 | // Add yield 45 | vm.prank(oracle); 46 | token.addRewardMultiplier(yieldIncrement); 47 | 48 | // Check balance reflects tokens with yield 49 | vm.prank(user1); 50 | assertEq( 51 | token.balanceOf(saddress(user1)), 52 | (tokensAmount * (BASE + yieldIncrement)) / BASE 53 | ); 54 | } 55 | 56 | function test_ZeroBalanceAndSharesForNewAccounts() public { 57 | // Check balance 58 | vm.prank(user1); 59 | assertEq(token.balanceOf(saddress(user1)), 0); 60 | 61 | // Check shares 62 | vm.prank(user1); 63 | assertEq(token.sharesOf(saddress(user1)), 0); 64 | } 65 | 66 | function test_SharesUnchangedWithYield() public { 67 | uint256 sharesAmount = 1 * 1e18; 68 | 69 | // Mint initial shares 70 | vm.prank(minter); 71 | token.mint(saddress(user1), suint256(sharesAmount)); 72 | 73 | // Record initial shares 74 | vm.prank(user1); 75 | uint256 initialShares = token.sharesOf(saddress(user1)); 76 | 77 | // Add yield multiple times 78 | vm.startPrank(oracle); 79 | token.addRewardMultiplier(0.0001e18); // +0.01% 80 | token.addRewardMultiplier(0.0002e18); // +0.02% 81 | token.addRewardMultiplier(0.0003e18); // +0.03% 82 | vm.stopPrank(); 83 | 84 | // Verify shares remain unchanged 85 | vm.prank(user1); 86 | assertEq(token.sharesOf(saddress(user1)), initialShares); 87 | } 88 | 89 | function test_SharesPrivacy() public { 90 | uint256 amount = 100 * 1e18; 91 | 92 | // Mint tokens to user1 93 | vm.prank(minter); 94 | token.mint(saddress(user1), suint256(amount)); 95 | 96 | // User1 can see their own shares 97 | vm.prank(user1); 98 | assertEq(token.sharesOf(saddress(user1)), amount); 99 | 100 | // User2 cannot see user1's shares (should see 0) 101 | vm.prank(user2); 102 | assertEq(token.sharesOf(saddress(user1)), 0); 103 | } 104 | 105 | function test_SharesWithTransfers() public { 106 | uint256 initialAmount = 100 * 1e18; 107 | uint256 transferAmount = 40 * 1e18; 108 | uint256 yieldIncrement = 0.0001e18; // 0.01% yield 109 | 110 | // Mint initial tokens 111 | vm.prank(minter); 112 | token.mint(saddress(user1), suint256(initialAmount)); 113 | 114 | // Add yield 115 | vm.prank(oracle); 116 | token.addRewardMultiplier(yieldIncrement); 117 | 118 | // Calculate shares for transfer 119 | uint256 transferShares = (transferAmount * BASE) / (BASE + yieldIncrement); 120 | 121 | // Transfer tokens 122 | vm.prank(user1); 123 | token.transfer(saddress(user2), suint256(transferAmount)); 124 | 125 | // Verify shares 126 | vm.prank(user1); 127 | assertEq(token.sharesOf(saddress(user1)), initialAmount - transferShares); 128 | 129 | vm.prank(user2); 130 | assertEq(token.sharesOf(saddress(user2)), transferShares); 131 | } 132 | 133 | function test_SharesWithMintingAfterYield() public { 134 | uint256 initialAmount = 100 * 1e18; 135 | uint256 mintAmount = 50 * 1e18; 136 | uint256 yieldIncrement = 0.0001e18; // 0.01% yield 137 | 138 | // Mint initial tokens 139 | vm.prank(minter); 140 | token.mint(saddress(user1), suint256(initialAmount)); 141 | 142 | // Add yield 143 | vm.prank(oracle); 144 | token.addRewardMultiplier(yieldIncrement); 145 | 146 | // Mint more tokens 147 | vm.prank(minter); 148 | token.mint(saddress(user2), suint256(mintAmount)); 149 | 150 | // Verify shares 151 | // User1's shares should remain unchanged 152 | vm.prank(user1); 153 | assertEq(token.sharesOf(saddress(user1)), initialAmount); 154 | 155 | // User2's shares should be calculated with current yield 156 | vm.prank(user2); 157 | assertEq(token.sharesOf(saddress(user2)), (mintAmount * BASE) / (BASE + yieldIncrement)); 158 | } 159 | 160 | function test_TotalShares() public { 161 | uint256 amount1 = 100 * 1e18; 162 | uint256 amount2 = 50 * 1e18; 163 | uint256 yieldIncrement = 0.0001e18; // 0.01% yield 164 | 165 | // Mint to first user 166 | vm.prank(minter); 167 | token.mint(saddress(user1), suint256(amount1)); 168 | 169 | // Add yield 170 | vm.prank(oracle); 171 | token.addRewardMultiplier(yieldIncrement); 172 | 173 | // Mint to second user 174 | vm.prank(minter); 175 | token.mint(saddress(user2), suint256(amount2)); 176 | 177 | // Calculate expected total shares 178 | uint256 shares1 = amount1; // First mint is 1:1 179 | uint256 shares2 = (amount2 * BASE) / (BASE + yieldIncrement); // Second mint accounts for yield 180 | uint256 expectedTotalShares = shares1 + shares2; 181 | 182 | // Verify total shares 183 | assertEq(token.totalShares(), expectedTotalShares); 184 | } 185 | } -------------------------------------------------------------------------------- /dwell/test/USDY/USDY.Burn.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | import {USDY} from "../../src/USDY.sol"; 6 | import {IERC20Errors} from "../../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; 7 | 8 | 9 | contract USDYBurnTest is Test { 10 | USDY public token; 11 | address public admin; 12 | address public minter; 13 | address public burner; 14 | address public oracle; 15 | address public user1; 16 | address public user2; 17 | uint256 public constant BASE = 1e18; 18 | uint256 public constant INITIAL_MINT = 1000 * 1e18; 19 | 20 | event Transfer(address indexed from, address indexed to, uint256 value); 21 | event RewardMultiplierUpdated(uint256 newMultiplier); 22 | 23 | function setUp() public { 24 | admin = address(1); 25 | minter = address(2); 26 | burner = address(3); 27 | oracle = address(4); 28 | user1 = address(5); 29 | user2 = address(6); 30 | 31 | token = new USDY(admin); 32 | 33 | vm.startPrank(admin); 34 | token.grantRole(token.MINTER_ROLE(), minter); 35 | token.grantRole(token.BURNER_ROLE(), burner); 36 | token.grantRole(token.ORACLE_ROLE(), oracle); 37 | vm.stopPrank(); 38 | 39 | // Initial mint for testing burns 40 | vm.prank(minter); 41 | token.mint(saddress(user1), suint256(INITIAL_MINT)); 42 | } 43 | 44 | function test_BurnDecrementsAccountShares() public { 45 | uint256 burnAmount = 1 * 1e18; 46 | 47 | // Record initial shares 48 | vm.prank(user1); 49 | uint256 initialShares = token.sharesOf(saddress(user1)); 50 | 51 | // Burn tokens 52 | vm.prank(burner); 53 | token.burn(saddress(user1), suint256(burnAmount)); 54 | 55 | // Verify shares were reduced 56 | vm.prank(user1); 57 | assertEq(token.sharesOf(saddress(user1)), initialShares - burnAmount); 58 | } 59 | 60 | function test_BurnDecrementsTotalShares() public { 61 | uint256 burnAmount = 1 * 1e18; 62 | 63 | // Record initial total shares 64 | uint256 initialTotalShares = token.totalShares(); 65 | 66 | // Burn tokens 67 | vm.prank(burner); 68 | token.burn(saddress(user1), suint256(burnAmount)); 69 | 70 | // Verify total shares were reduced 71 | assertEq(token.totalShares(), initialTotalShares - burnAmount); 72 | } 73 | 74 | function test_BurnFromZeroAddressReverts() public { 75 | uint256 burnAmount = 1 * 1e18; 76 | 77 | // Attempt to burn from zero address should revert 78 | vm.prank(burner); 79 | vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidSender.selector, address(0))); 80 | token.burn(saddress(address(0)), suint256(burnAmount)); 81 | } 82 | 83 | function test_BurnExceedingBalanceReverts() public { 84 | // Get current balance 85 | vm.prank(user1); 86 | uint256 balance = token.balanceOf(saddress(user1)); 87 | uint256 burnAmount = balance + 1; 88 | 89 | // Attempt to burn more than balance should revert 90 | vm.prank(burner); 91 | vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, user1, balance, burnAmount)); 92 | token.burn(saddress(user1), suint256(burnAmount)); 93 | } 94 | 95 | function test_BurnEmitsTransferEvent() public { 96 | uint256 burnAmount = 1 * 1e18; 97 | 98 | // Burn should emit Transfer event to zero address 99 | vm.prank(burner); 100 | vm.expectEmit(true, true, false, true); 101 | emit Transfer(user1, address(0), burnAmount); 102 | token.burn(saddress(user1), suint256(burnAmount)); 103 | } 104 | 105 | function test_BurnEmitsTransferEventWithTokensNotShares() public { 106 | uint256 burnAmount = 1000 * 1e18; 107 | uint256 yieldIncrement = 0.0001e18; // 0.01% yield 108 | 109 | // Add yield first 110 | vm.prank(oracle); 111 | token.addRewardMultiplier(yieldIncrement); 112 | 113 | // Burn should emit Transfer event with token amount, not shares 114 | vm.prank(burner); 115 | vm.expectEmit(true, true, false, true); 116 | emit Transfer(user1, address(0), burnAmount); 117 | token.burn(saddress(user1), suint256(burnAmount)); 118 | } 119 | 120 | function test_BurnWithYieldCalculatesSharesCorrectly() public { 121 | uint256 burnAmount = 100 * 1e18; 122 | uint256 yieldIncrement = 0.1e18; // 10% yield 123 | 124 | // Add yield first 125 | vm.prank(oracle); 126 | token.addRewardMultiplier(yieldIncrement); 127 | 128 | // Calculate expected shares to burn 129 | uint256 sharesToBurn = (burnAmount * BASE) / (BASE + yieldIncrement); 130 | 131 | // Record initial shares 132 | vm.prank(user1); 133 | uint256 initialShares = token.sharesOf(saddress(user1)); 134 | 135 | // Burn tokens 136 | vm.prank(burner); 137 | token.burn(saddress(user1), suint256(burnAmount)); 138 | 139 | // Verify correct number of shares were burned 140 | vm.prank(user1); 141 | assertEq(token.sharesOf(saddress(user1)), initialShares - sharesToBurn); 142 | } 143 | 144 | function test_BurnZeroAmount() public { 145 | // Initial state check 146 | vm.prank(user1); 147 | uint256 initialShares = token.sharesOf(saddress(user1)); 148 | vm.prank(user1); 149 | uint256 initialBalance = token.balanceOf(saddress(user1)); 150 | 151 | // Burn zero amount 152 | vm.prank(burner); 153 | token.burn(saddress(user1), suint256(0)); 154 | 155 | // Verify shares and balance remain unchanged 156 | vm.prank(user1); 157 | assertEq(token.sharesOf(saddress(user1)), initialShares); 158 | vm.prank(user1); 159 | assertEq(token.balanceOf(saddress(user1)), initialBalance); 160 | } 161 | 162 | function test_MultipleBurns() public { 163 | uint256[] memory amounts = new uint256[](3); 164 | amounts[0] = 100 * 1e18; 165 | amounts[1] = 50 * 1e18; 166 | amounts[2] = 75 * 1e18; 167 | 168 | // Get initial shares after setup mint 169 | vm.prank(user1); 170 | uint256 totalShares = token.sharesOf(saddress(user1)); 171 | uint256 currentMultiplier = BASE; 172 | 173 | // Perform multiple burns with yield changes in between 174 | for(uint256 i = 0; i < amounts.length; i++) { 175 | if(i > 0) { 176 | // Add some yield before subsequent burns 177 | uint256 yieldIncrement = 0.0001e18 * (i + 1); 178 | vm.prank(oracle); 179 | token.addRewardMultiplier(yieldIncrement); 180 | currentMultiplier += yieldIncrement; 181 | } 182 | 183 | // Calculate shares to burn for this amount 184 | uint256 sharesToBurn = (amounts[i] * BASE) / currentMultiplier; 185 | require(sharesToBurn <= totalShares, "Not enough shares to burn"); 186 | 187 | vm.prank(burner); 188 | token.burn(saddress(user1), suint256(amounts[i])); 189 | 190 | // Update remaining shares 191 | totalShares -= sharesToBurn; 192 | } 193 | 194 | // Calculate expected final balance based on remaining shares and final multiplier 195 | uint256 expectedBalance = (totalShares * currentMultiplier) / BASE; 196 | 197 | // Verify final balance 198 | vm.prank(user1); 199 | uint256 actualBalance = token.balanceOf(saddress(user1)); 200 | assertEq(actualBalance, expectedBalance); 201 | 202 | // Verify shares are calculated correctly 203 | vm.prank(user1); 204 | assertEq(token.sharesOf(saddress(user1)), totalShares); 205 | } 206 | 207 | function test_BurnPrivacy() public { 208 | uint256 burnAmount = 100 * 1e18; 209 | 210 | // Record initial balance (only visible to owner) 211 | vm.prank(user1); 212 | uint256 initialBalance = token.balanceOf(saddress(user1)); 213 | 214 | // Burn tokens 215 | vm.prank(burner); 216 | token.burn(saddress(user1), suint256(burnAmount)); 217 | 218 | // Owner can see reduced balance 219 | vm.prank(user1); 220 | assertEq(token.balanceOf(saddress(user1)), initialBalance - burnAmount); 221 | 222 | // Other users still see zero 223 | vm.prank(user2); 224 | assertEq(token.balanceOf(saddress(user1)), 0); 225 | } 226 | 227 | function test_OnlyBurnerCanBurn() public { 228 | uint256 amount = 100 * 1e18; 229 | 230 | // Non-burner cannot burn 231 | bytes32 burnerRole = token.BURNER_ROLE(); 232 | vm.prank(user2); 233 | vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, burnerRole, user2)); 234 | token.burn(saddress(user1), suint256(amount)); 235 | 236 | // Burner can burn 237 | vm.prank(burner); 238 | token.burn(saddress(user1), suint256(amount)); 239 | 240 | // Verify burn was successful 241 | vm.prank(user1); 242 | assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT - amount); 243 | } 244 | } -------------------------------------------------------------------------------- /dwell/test/USDY/USDY.Mint.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | import {USDY} from "../../src/USDY.sol"; 6 | import {IERC20Errors} from "../../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; 7 | 8 | 9 | contract USDYMintTest is Test { 10 | USDY public token; 11 | address public admin; 12 | address public minter; 13 | address public oracle; 14 | address public user1; 15 | address public user2; 16 | uint256 public constant BASE = 1e18; 17 | uint256 public constant INITIAL_MINT = 1000 * 1e18; 18 | 19 | event Transfer(address indexed from, address indexed to, uint256 value); 20 | event RewardMultiplierUpdated(uint256 newMultiplier); 21 | 22 | function setUp() public { 23 | admin = address(1); 24 | minter = address(2); 25 | oracle = address(3); 26 | user1 = address(4); 27 | user2 = address(5); 28 | 29 | token = new USDY(admin); 30 | 31 | vm.startPrank(admin); 32 | token.grantRole(token.MINTER_ROLE(), minter); 33 | token.grantRole(token.ORACLE_ROLE(), oracle); 34 | vm.stopPrank(); 35 | } 36 | 37 | function test_MintIncrementsTotalShares() public { 38 | uint256 amount = 1 * 1e18; 39 | 40 | // Record initial total shares 41 | uint256 initialTotalShares = token.totalShares(); 42 | 43 | // Mint tokens 44 | vm.prank(minter); 45 | token.mint(saddress(user1), suint256(amount)); 46 | 47 | // Verify total shares increased by mint amount 48 | assertEq(token.totalShares(), initialTotalShares + amount); 49 | } 50 | 51 | function test_MintIncrementsTotalSupply() public { 52 | uint256 amount = 1 * 1e18; 53 | 54 | // Record initial total supply 55 | uint256 initialTotalSupply = token.totalSupply(); 56 | 57 | // Mint tokens 58 | vm.prank(minter); 59 | token.mint(saddress(user1), suint256(amount)); 60 | 61 | // Verify total supply increased by mint amount 62 | assertEq(token.totalSupply(), initialTotalSupply + amount); 63 | } 64 | 65 | function test_MintEmitsTransferEvent() public { 66 | uint256 amount = 1 * 1e18; 67 | 68 | // Mint should emit Transfer event from zero address 69 | vm.prank(minter); 70 | vm.expectEmit(true, true, false, true); 71 | emit Transfer(address(0), user1, amount); 72 | token.mint(saddress(user1), suint256(amount)); 73 | } 74 | 75 | function test_MintEmitsTransferEventWithTokensNotShares() public { 76 | uint256 amount = 1000 * 1e18; 77 | uint256 yieldIncrement = 0.0001e18; // 0.01% yield 78 | 79 | // Add yield first 80 | vm.prank(oracle); 81 | token.addRewardMultiplier(yieldIncrement); 82 | 83 | // Mint should emit Transfer event with token amount, not shares 84 | vm.prank(minter); 85 | vm.expectEmit(true, true, false, true); 86 | emit Transfer(address(0), user1, amount); 87 | token.mint(saddress(user1), suint256(amount)); 88 | } 89 | 90 | function test_MintSharesAssignedToCorrectAddress() public { 91 | uint256 amount = 1 * 1e18; 92 | 93 | // Mint tokens 94 | vm.prank(minter); 95 | token.mint(saddress(user1), suint256(amount)); 96 | 97 | // Verify shares were assigned to correct address 98 | vm.prank(user1); 99 | assertEq(token.sharesOf(saddress(user1)), amount); 100 | } 101 | 102 | function test_MintToZeroAddressReverts() public { 103 | uint256 amount = 1 * 1e18; 104 | 105 | // Attempt to mint to zero address should revert 106 | vm.prank(minter); 107 | vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InvalidReceiver.selector, address(0))); 108 | token.mint(saddress(address(0)), suint256(amount)); 109 | } 110 | 111 | function test_MintWithYieldCalculatesSharesCorrectly() public { 112 | uint256 amount = 100 * 1e18; 113 | uint256 yieldIncrement = 0.1e18; // 10% yield 114 | 115 | // Add yield first 116 | vm.prank(oracle); 117 | token.addRewardMultiplier(yieldIncrement); 118 | 119 | // Calculate expected shares 120 | uint256 expectedShares = (amount * BASE) / (BASE + yieldIncrement); 121 | 122 | // Mint tokens 123 | vm.prank(minter); 124 | token.mint(saddress(user1), suint256(amount)); 125 | 126 | // Verify correct number of shares were minted 127 | vm.prank(user1); 128 | assertEq(token.sharesOf(saddress(user1)), expectedShares); 129 | 130 | // Verify balance shows full token amount (allow 1 wei difference) 131 | vm.prank(user1); 132 | uint256 actualBalance = token.balanceOf(saddress(user1)); 133 | assertApproxEqAbs(actualBalance, amount, 1); 134 | } 135 | 136 | function test_MultipleMints() public { 137 | uint256[] memory amounts = new uint256[](3); 138 | amounts[0] = 100 * 1e18; 139 | amounts[1] = 50 * 1e18; 140 | amounts[2] = 75 * 1e18; 141 | 142 | uint256 totalShares = 0; 143 | uint256 currentMultiplier = BASE; 144 | 145 | // Perform multiple mints with yield changes in between 146 | for(uint256 i = 0; i < amounts.length; i++) { 147 | if(i > 0) { 148 | // Add some yield before subsequent mints 149 | uint256 yieldIncrement = 0.0001e18 * (i + 1); 150 | vm.prank(oracle); 151 | token.addRewardMultiplier(yieldIncrement); 152 | currentMultiplier += yieldIncrement; 153 | } 154 | 155 | vm.prank(minter); 156 | token.mint(saddress(user1), suint256(amounts[i])); 157 | 158 | // Calculate shares for this mint 159 | totalShares += (amounts[i] * BASE) / currentMultiplier; 160 | } 161 | 162 | // Calculate expected final balance based on total shares and final multiplier 163 | uint256 expectedBalance = (totalShares * currentMultiplier) / BASE; 164 | 165 | // Verify final balance reflects total minted amount with yield 166 | vm.prank(user1); 167 | uint256 actualBalance = token.balanceOf(saddress(user1)); 168 | assertEq(actualBalance, expectedBalance); 169 | 170 | // Verify shares are calculated correctly 171 | vm.prank(user1); 172 | assertEq(token.sharesOf(saddress(user1)), totalShares); 173 | } 174 | 175 | function test_MintPrivacy() public { 176 | uint256 amount = 100 * 1e18; 177 | 178 | // Mint tokens 179 | vm.prank(minter); 180 | token.mint(saddress(user1), suint256(amount)); 181 | 182 | // Recipient can see their balance 183 | vm.prank(user1); 184 | assertEq(token.balanceOf(saddress(user1)), amount); 185 | 186 | // Other users cannot see the balance 187 | vm.prank(user2); 188 | assertEq(token.balanceOf(saddress(user1)), 0); 189 | } 190 | 191 | function test_OnlyMinterCanMint() public { 192 | uint256 amount = 100 * 1e18; 193 | 194 | // Non-minter cannot mint 195 | bytes32 minterRole = token.MINTER_ROLE(); 196 | vm.startPrank(user1); 197 | vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, minterRole, user1)); 198 | token.mint(saddress(user2), suint256(amount)); 199 | vm.stopPrank(); 200 | 201 | // Minter can mint 202 | vm.prank(minter); 203 | token.mint(saddress(user2), suint256(amount)); 204 | 205 | // Verify mint was successful 206 | vm.prank(user2); 207 | assertEq(token.balanceOf(saddress(user2)), amount); 208 | } 209 | 210 | function test_MintZeroAmount() public { 211 | // Minting zero amount should succeed but not change state 212 | vm.prank(minter); 213 | token.mint(saddress(user1), suint256(0)); 214 | 215 | // Verify no shares or balance were assigned 216 | vm.prank(user1); 217 | assertEq(token.sharesOf(saddress(user1)), 0); 218 | vm.prank(user1); 219 | assertEq(token.balanceOf(saddress(user1)), 0); 220 | } 221 | } -------------------------------------------------------------------------------- /dwell/test/USDY/USDY.Pause.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | import {USDY} from "../../src/USDY.sol"; 6 | 7 | 8 | contract USDYPauseTest is Test { 9 | USDY public token; 10 | address public admin; 11 | address public minter; 12 | address public burner; 13 | address public pauser; 14 | address public user1; 15 | address public user2; 16 | uint256 public constant BASE = 1e18; 17 | uint256 public constant INITIAL_MINT = 1000 * 1e18; 18 | 19 | event Paused(address account); 20 | event Unpaused(address account); 21 | event Transfer(address indexed from, address indexed to, uint256 value); 22 | 23 | function setUp() public { 24 | admin = address(1); 25 | minter = address(2); 26 | burner = address(3); 27 | pauser = address(4); 28 | user1 = address(5); 29 | user2 = address(6); 30 | 31 | token = new USDY(admin); 32 | 33 | vm.startPrank(admin); 34 | token.grantRole(token.MINTER_ROLE(), minter); 35 | token.grantRole(token.BURNER_ROLE(), burner); 36 | token.grantRole(token.PAUSE_ROLE(), pauser); 37 | vm.stopPrank(); 38 | 39 | // Initial mint to test transfers 40 | vm.prank(minter); 41 | token.mint(saddress(user1), suint256(INITIAL_MINT)); 42 | } 43 | 44 | function test_MintingWhenUnpaused() public { 45 | uint256 amount = 10 * 1e18; 46 | 47 | // Pause and then unpause 48 | vm.startPrank(pauser); 49 | token.pause(); 50 | token.unpause(); 51 | vm.stopPrank(); 52 | 53 | // Should be able to mint after unpausing 54 | vm.prank(minter); 55 | vm.expectEmit(true, true, false, true); 56 | emit Transfer(address(0), user2, amount); 57 | token.mint(saddress(user2), suint256(amount)); 58 | 59 | // Verify mint was successful 60 | vm.prank(user2); 61 | assertEq(token.balanceOf(saddress(user2)), amount); 62 | } 63 | 64 | function test_MintingWhenPaused() public { 65 | uint256 amount = 10 * 1e18; 66 | 67 | // Pause 68 | vm.prank(pauser); 69 | token.pause(); 70 | 71 | // Should not be able to mint while paused 72 | vm.prank(minter); 73 | vm.expectRevert(USDY.TransferWhilePaused.selector); 74 | token.mint(saddress(user2), suint256(amount)); 75 | } 76 | 77 | function test_BurningWhenUnpaused() public { 78 | uint256 amount = 10 * 1e18; 79 | 80 | // Pause and then unpause 81 | vm.startPrank(pauser); 82 | token.pause(); 83 | token.unpause(); 84 | vm.stopPrank(); 85 | 86 | // Should be able to burn after unpausing 87 | vm.prank(burner); 88 | vm.expectEmit(true, true, false, true); 89 | emit Transfer(user1, address(0), amount); 90 | token.burn(saddress(user1), suint256(amount)); 91 | 92 | // Verify burn was successful 93 | vm.prank(user1); 94 | assertEq(token.balanceOf(saddress(user1)), INITIAL_MINT - amount); 95 | } 96 | 97 | function test_BurningWhenPaused() public { 98 | uint256 amount = 10 * 1e18; 99 | 100 | // Pause 101 | vm.prank(pauser); 102 | token.pause(); 103 | 104 | // Should not be able to burn while paused 105 | vm.prank(burner); 106 | vm.expectRevert(USDY.TransferWhilePaused.selector); 107 | token.burn(saddress(user1), suint256(amount)); 108 | } 109 | 110 | function test_TransfersWhenUnpaused() public { 111 | uint256 amount = 10 * 1e18; 112 | 113 | // Pause and then unpause 114 | vm.startPrank(pauser); 115 | token.pause(); 116 | token.unpause(); 117 | vm.stopPrank(); 118 | 119 | // Should be able to transfer after unpausing 120 | vm.prank(user1); 121 | vm.expectEmit(true, true, false, true); 122 | emit Transfer(user1, user2, amount); 123 | token.transfer(saddress(user2), suint256(amount)); 124 | 125 | // Verify transfer was successful 126 | vm.prank(user2); 127 | assertEq(token.balanceOf(saddress(user2)), amount); 128 | } 129 | 130 | function test_TransfersWhenPaused() public { 131 | uint256 amount = 10 * 1e18; 132 | 133 | // Pause 134 | vm.prank(pauser); 135 | token.pause(); 136 | 137 | // Should not be able to transfer while paused 138 | vm.prank(user1); 139 | vm.expectRevert(USDY.TransferWhilePaused.selector); 140 | token.transfer(saddress(user2), suint256(amount)); 141 | } 142 | 143 | function test_TransferFromWhenUnpaused() public { 144 | uint256 amount = 10 * 1e18; 145 | 146 | // Setup approval 147 | vm.prank(user1); 148 | token.approve(saddress(user2), suint256(amount)); 149 | 150 | // Pause and then unpause 151 | vm.startPrank(pauser); 152 | token.pause(); 153 | token.unpause(); 154 | vm.stopPrank(); 155 | 156 | // Should be able to transferFrom after unpausing 157 | vm.prank(user2); 158 | token.transferFrom(saddress(user1), saddress(user2), suint256(amount)); 159 | 160 | // Verify transfer was successful 161 | vm.prank(user2); 162 | assertEq(token.balanceOf(saddress(user2)), amount); 163 | } 164 | 165 | function test_TransferFromWhenPaused() public { 166 | uint256 amount = 10 * 1e18; 167 | 168 | // Setup approval 169 | vm.prank(user1); 170 | token.approve(saddress(user2), suint256(amount)); 171 | 172 | // Pause 173 | vm.prank(pauser); 174 | token.pause(); 175 | 176 | // Should not be able to transferFrom while paused 177 | vm.prank(user2); 178 | vm.expectRevert(USDY.TransferWhilePaused.selector); 179 | token.transferFrom(saddress(user1), saddress(user2), suint256(amount)); 180 | } 181 | 182 | function test_PauseUnpauseEvents() public { 183 | // Test pause event 184 | vm.prank(pauser); 185 | vm.expectEmit(true, true, true, true); 186 | emit Paused(pauser); 187 | token.pause(); 188 | 189 | // Test unpause event 190 | vm.prank(pauser); 191 | vm.expectEmit(true, true, true, true); 192 | emit Unpaused(pauser); 193 | token.unpause(); 194 | } 195 | 196 | function test_OnlyPauserCanPauseUnpause() public { 197 | // Non-pauser cannot pause 198 | vm.startPrank(user1); 199 | bytes32 pauseRole = token.PAUSE_ROLE(); 200 | vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, pauseRole, user1)); 201 | token.pause(); 202 | vm.stopPrank(); 203 | 204 | // Pause with correct role 205 | vm.prank(pauser); 206 | token.pause(); 207 | 208 | // Non-pauser cannot unpause 209 | vm.startPrank(user1); 210 | vm.expectRevert(abi.encodeWithSelector(USDY.MissingRole.selector, pauseRole, user1)); 211 | token.unpause(); 212 | vm.stopPrank(); 213 | 214 | // Unpause with correct role 215 | vm.prank(pauser); 216 | token.unpause(); 217 | } 218 | 219 | function test_CannotPauseWhenPaused() public { 220 | // Pause first time 221 | vm.prank(pauser); 222 | token.pause(); 223 | 224 | // Try to pause again 225 | vm.prank(pauser); 226 | vm.expectRevert(USDY.TransferWhilePaused.selector); 227 | token.pause(); 228 | } 229 | 230 | function test_CannotUnpauseWhenUnpaused() public { 231 | // Try to unpause when not paused 232 | vm.prank(pauser); 233 | vm.expectRevert(USDY.TransferWhilePaused.selector); 234 | token.unpause(); 235 | } 236 | } -------------------------------------------------------------------------------- /dwell/test/USDY/USDY.Privacy.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | import {USDY} from "../../src/USDY.sol"; 6 | import {IERC20Errors} from "../../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; 7 | 8 | contract USDYPrivacyTest is Test { 9 | USDY public token; 10 | address public admin; 11 | address public minter; 12 | address public oracle; 13 | address public user1; 14 | address public user2; 15 | address public observer; 16 | uint256 public constant BASE = 1e18; 17 | uint256 public constant INITIAL_MINT = 1000 * 1e18; 18 | 19 | event Transfer(address indexed from, address indexed to, uint256 value); 20 | 21 | function setUp() public { 22 | admin = address(1); 23 | minter = address(2); 24 | oracle = address(3); 25 | user1 = address(4); 26 | user2 = address(5); 27 | observer = address(6); 28 | 29 | token = new USDY(admin); 30 | 31 | vm.startPrank(admin); 32 | token.grantRole(token.MINTER_ROLE(), minter); 33 | token.grantRole(token.ORACLE_ROLE(), oracle); 34 | vm.stopPrank(); 35 | 36 | vm.prank(minter); 37 | token.mint(saddress(user1), suint256(INITIAL_MINT)); 38 | } 39 | 40 | function test_BalancePrivacyWithYield() public { 41 | // Add yield 42 | vm.prank(oracle); 43 | token.addRewardMultiplier(0.1e18); // 10% yield 44 | 45 | // Owner can see actual balance with yield 46 | vm.prank(user1); 47 | uint256 actualBalance = token.balanceOf(saddress(user1)); 48 | uint256 expectedBalance = (INITIAL_MINT * 11) / 10; 49 | assertApproxEqAbs(actualBalance, expectedBalance, 1); // Allow 1 wei difference 50 | 51 | // Others see zero 52 | vm.prank(observer); 53 | assertEq(token.balanceOf(saddress(user1)), 0); 54 | } 55 | 56 | function test_TransferPrivacyWithYield() public { 57 | // Add yield 58 | vm.prank(oracle); 59 | token.addRewardMultiplier(0.1e18); // 10% yield 60 | 61 | uint256 transferAmount = 100 * 1e18; 62 | 63 | // Transfer should emit event with actual value 64 | vm.prank(user1); 65 | vm.expectEmit(true, true, false, true); 66 | emit Transfer(user1, user2, transferAmount); 67 | token.transfer(saddress(user2), suint256(transferAmount)); 68 | 69 | // Only recipient can see their balance 70 | vm.prank(user2); 71 | uint256 actualBalance = token.balanceOf(saddress(user2)); 72 | assertApproxEqAbs(actualBalance, transferAmount, 1); // Allow 1 wei difference 73 | 74 | // Others (including sender) see zero 75 | vm.prank(user1); 76 | assertEq(token.balanceOf(saddress(user2)), 0); 77 | vm.prank(observer); 78 | assertEq(token.balanceOf(saddress(user2)), 0); 79 | } 80 | 81 | function test_MintBurnPrivacy() public { 82 | uint256 amount = 100 * 1e18; 83 | 84 | // Mint new tokens 85 | vm.prank(minter); 86 | token.mint(saddress(user2), suint256(amount)); 87 | 88 | // Only recipient can see minted amount 89 | vm.prank(user2); 90 | uint256 actualBalance = token.balanceOf(saddress(user2)); 91 | assertApproxEqAbs(actualBalance, amount, 1); // Allow 1 wei difference 92 | 93 | // Others see zero 94 | vm.prank(observer); 95 | assertEq(token.balanceOf(saddress(user2)), 0); 96 | 97 | // Grant burner role for testing 98 | vm.startPrank(admin); 99 | token.grantRole(token.BURNER_ROLE(), admin); 100 | 101 | // Burn tokens 102 | token.burn(saddress(user2), suint256(amount)); 103 | vm.stopPrank(); 104 | 105 | // Balance should be zero after burn 106 | vm.prank(user2); 107 | assertEq(token.balanceOf(saddress(user2)), 0); 108 | } 109 | 110 | function test_TotalSupplyPrivacy() public { 111 | // Total supply should be visible to all 112 | assertApproxEqAbs(token.totalSupply(), INITIAL_MINT, 1); 113 | 114 | // Add yield 115 | vm.prank(oracle); 116 | token.addRewardMultiplier(0.1e18); // 10% yield 117 | 118 | // Total supply should reflect yield 119 | uint256 expectedSupply = (INITIAL_MINT * 11) / 10; 120 | assertApproxEqAbs(token.totalSupply(), expectedSupply, 1); 121 | 122 | // Mint more tokens 123 | vm.prank(minter); 124 | token.mint(saddress(user2), suint256(100 * 1e18)); 125 | 126 | // Total supply should include new mint 127 | expectedSupply = ((INITIAL_MINT * 11) / 10) + (100 * 1e18); 128 | assertApproxEqAbs(token.totalSupply(), expectedSupply, 1); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /dwell/test/USDY/USDY.Transfer.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | import {USDY} from "../../src/USDY.sol"; 6 | import {IERC20Errors} from "../../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; 7 | 8 | contract USDYTransferTest is Test { 9 | USDY public token; 10 | address public admin; 11 | address public minter; 12 | address public user1; 13 | address public user2; 14 | address public spender; 15 | uint256 public constant INITIAL_MINT = 1000 * 1e18; 16 | 17 | event Transfer(address indexed from, address indexed to, uint256 value); 18 | event Approval(address indexed owner, address indexed spender, uint256 value); 19 | 20 | function setUp() public { 21 | admin = vm.addr(1); 22 | minter = vm.addr(2); 23 | user1 = vm.addr(3); 24 | user2 = vm.addr(4); 25 | spender = vm.addr(5); 26 | 27 | // Start admin context 28 | vm.startPrank(admin); 29 | 30 | // Deploy and setup roles 31 | token = new USDY(admin); 32 | token.grantRole(token.MINTER_ROLE(), minter); 33 | 34 | vm.stopPrank(); 35 | 36 | // Mint initial tokens 37 | vm.prank(minter); 38 | token.mint(saddress(user1), suint256(INITIAL_MINT)); 39 | } 40 | 41 | function test_TransferEmitsEvent() public { 42 | uint256 amount = 100e18; 43 | 44 | vm.prank(user1); 45 | vm.expectEmit(true, true, false, true); 46 | emit Transfer(user1, user2, amount); // Changed from 0 to actual amount 47 | token.transfer(saddress(user2), suint256(amount)); 48 | } 49 | 50 | function test_TransferFromEmitsEvent() public { 51 | uint256 amount = 100e18; 52 | 53 | vm.prank(user1); 54 | token.approve(saddress(spender), suint256(amount)); 55 | 56 | vm.prank(spender); 57 | vm.expectEmit(true, true, false, true); 58 | emit Transfer(user1, user2, amount); // Changed from 0 to actual amount 59 | token.transferFrom(saddress(user1), saddress(user2), suint256(amount)); 60 | } 61 | } -------------------------------------------------------------------------------- /dwell/test/USDY/USDY.Yield.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.20; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | import {USDY} from "../../src/USDY.sol"; 6 | import {IERC20Errors} from "../../lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol"; 7 | 8 | contract USDYYieldTest is Test { 9 | USDY public token; 10 | address public admin; 11 | address public minter; 12 | address public oracle; 13 | address public user1; 14 | address public user2; 15 | uint256 public constant BASE = 1e18; 16 | uint256 public constant INITIAL_MINT = 1000 * 1e18; 17 | 18 | event RewardMultiplierUpdated(uint256 newMultiplier); 19 | event Transfer(address indexed from, address indexed to, uint256 value); 20 | 21 | function setUp() public { 22 | admin = address(1); 23 | minter = address(2); 24 | oracle = address(3); 25 | user1 = address(4); 26 | user2 = address(5); 27 | 28 | token = new USDY(admin); 29 | 30 | vm.startPrank(admin); 31 | token.grantRole(token.MINTER_ROLE(), minter); 32 | token.grantRole(token.ORACLE_ROLE(), oracle); 33 | vm.stopPrank(); 34 | 35 | vm.prank(minter); 36 | token.mint(saddress(user1), suint256(INITIAL_MINT)); 37 | } 38 | 39 | function test_YieldAccumulationWithSmallIncrements() public { 40 | uint256[] memory increments = new uint256[](5); 41 | increments[0] = 0.001e18; // 0.1% 42 | increments[1] = 0.0005e18; // 0.05% 43 | increments[2] = 0.002e18; // 0.2% 44 | increments[3] = 0.0015e18; // 0.15% 45 | increments[4] = 0.001e18; // 0.1% 46 | 47 | uint256 totalMultiplier = BASE; 48 | 49 | // Apply small yield increments 50 | for(uint256 i = 0; i < increments.length; i++) { 51 | vm.prank(oracle); 52 | token.addRewardMultiplier(increments[i]); 53 | totalMultiplier += increments[i]; 54 | } 55 | 56 | // Calculate expected balance with accumulated yield 57 | uint256 expectedBalance = (INITIAL_MINT * totalMultiplier) / BASE; 58 | 59 | // Check balance reflects all small yield increments 60 | vm.prank(user1); 61 | assertEq(token.balanceOf(saddress(user1)), expectedBalance); 62 | } 63 | 64 | function test_YieldAccumulationWithTransfers() public { 65 | // Initial transfer to split tokens 66 | uint256 transferAmount = INITIAL_MINT / 2; 67 | vm.prank(user1); 68 | token.transfer(saddress(user2), suint256(transferAmount)); 69 | 70 | // Add yield multiple times 71 | uint256 totalYield = 0; 72 | uint256[] memory increments = new uint256[](3); 73 | increments[0] = 0.1e18; // 10% 74 | increments[1] = 0.05e18; // 5% 75 | increments[2] = 0.15e18; // 15% 76 | 77 | for(uint256 i = 0; i < increments.length; i++) { 78 | vm.prank(oracle); 79 | token.addRewardMultiplier(increments[i]); 80 | totalYield += increments[i]; 81 | } 82 | 83 | // Calculate expected balances 84 | uint256 multiplier = BASE + totalYield; 85 | uint256 expectedBalance = (transferAmount * multiplier) / BASE; 86 | 87 | // Verify both users' balances reflect accumulated yield 88 | vm.prank(user1); 89 | assertEq(token.balanceOf(saddress(user1)), expectedBalance); 90 | vm.prank(user2); 91 | assertEq(token.balanceOf(saddress(user2)), expectedBalance); 92 | } 93 | 94 | function test_YieldAccumulationWithMinting() public { 95 | uint256 yieldIncrement = 0.1e18; // 10% 96 | uint256 mintAmount = 100 * 1e18; 97 | 98 | // Add yield first 99 | vm.prank(oracle); 100 | token.addRewardMultiplier(yieldIncrement); 101 | 102 | // Mint new tokens 103 | vm.prank(minter); 104 | token.mint(saddress(user2), suint256(mintAmount)); 105 | 106 | // Calculate expected balance for new mint (should not include previous yield) 107 | vm.prank(user2); 108 | uint256 actualBalance = token.balanceOf(saddress(user2)); 109 | assertApproxEqAbs(actualBalance, mintAmount, 1); // Allow 1 wei difference 110 | 111 | // Original holder's balance should include yield 112 | vm.prank(user1); 113 | uint256 expectedYieldBalance = (INITIAL_MINT * (BASE + yieldIncrement)) / BASE; 114 | actualBalance = token.balanceOf(saddress(user1)); 115 | assertApproxEqAbs(actualBalance, expectedYieldBalance, 1); // Allow 1 wei difference 116 | } 117 | 118 | function test_YieldAccumulationWithBurning() public { 119 | uint256 yieldIncrement = 0.1e18; // 10% 120 | uint256 burnAmount = 100 * 1e18; 121 | 122 | // Add yield first 123 | vm.prank(oracle); 124 | token.addRewardMultiplier(yieldIncrement); 125 | 126 | // Grant burner role to admin for testing 127 | vm.startPrank(admin); 128 | token.grantRole(token.BURNER_ROLE(), admin); 129 | 130 | // Burn tokens directly - contract will handle share conversion internally 131 | token.burn(saddress(user1), suint256(burnAmount)); 132 | vm.stopPrank(); 133 | 134 | // Calculate expected remaining balance after burning 135 | uint256 expectedBalance = (INITIAL_MINT * (BASE + yieldIncrement)) / BASE - burnAmount; 136 | 137 | // Verify remaining balance (allow for 1 wei rounding difference) 138 | vm.prank(user1); 139 | uint256 actualBalance = token.balanceOf(saddress(user1)); 140 | assertApproxEqAbs(actualBalance, expectedBalance, 1); // Allow 1 wei difference 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /folio/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Docs 11 | docs/ 12 | 13 | # Dotenv file 14 | .env 15 | -------------------------------------------------------------------------------- /folio/README.md: -------------------------------------------------------------------------------- 1 | # FOLIO: Participate in a global pay-it-forward chain 2 | 3 | ### Problem 4 | Traditional pay-it-forward chains can raise charitable funds, but they are often vulnerable to exploitation. Additionally, they tend to lack excitement, and most efforts remain local rather than global-limiting their overall impact. 5 | 6 | ### Insight 7 | By gamifying the pay-it-forward process, we can make chains more engaging and encourage increased donations. People are motivated when they believe they will be a part of something big. By using a blockchain, we can expand the scope to be worldwide, amplifying reach and participation. 8 | 9 | 10 | ### Solution 11 | Create a global pay-it-forward competition on consumer payment rails. The potential to win a portion of the prize pot adds a competitive spark, increasing excitement and contributions. By keeping the chain encrypted, we avoid the case where people exploit the system by only contributing to the winning chain. This way every participant (new or returning) will feel that they have a fair chance to be a winner. -------------------------------------------------------------------------------- /folio/foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | 6 | # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options 7 | -------------------------------------------------------------------------------- /folio/src/ChainTracker.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0 2 | pragma solidity ^0.8.13; 3 | 4 | /// @title Pay-it-Forward Chain Management Contract 5 | /// @notice This contract manages the process of tracking chains and chain details during a pay-it-forward competition. 6 | contract ChainTracker { 7 | /// @notice Sets up the manager 8 | address manager; 9 | 10 | constructor() { 11 | manager = msg.sender; 12 | } 13 | 14 | /// @notice Modifier to ensure only the competition contract can call certain functions 15 | modifier competitionOnly() { 16 | require(msg.sender == manager, "Only the competition smart contract can call this."); 17 | _; 18 | } 19 | 20 | /** 21 | * @dev Represents a "pay-it-forward" chain. 22 | * Tracks participants, their contributions, and overall chain statistics. 23 | */ 24 | struct ChainStats { 25 | suint chainId; // Unique identifier for the chain 26 | mapping(saddress => suint) links; // Tracks number of contributions for each participant/business 27 | suint uniqueBusinesses; // Total distinct businesses involved in the chain 28 | suint uniqueParticipants; // Total distinct participants in the chain 29 | suint chainLength; // Total number of transactions in the chain 30 | } 31 | 32 | /** 33 | * @dev Stores competition-wide statistics of terminated (nuked) chains. 34 | * Tracks scores, lengths, and the highest-scoring chain. 35 | */ 36 | struct CompStats { 37 | mapping(suint => suint) chainFinalScores; // Chain ID => Final score of that chain 38 | mapping(suint => suint) chainFinalLengths; // Chain ID => Total length of that chain 39 | suint overallWinningChainId; // The ID of the highest-scoring chain 40 | } 41 | 42 | /** 43 | * @dev Stores per-participant and per-business statistics for chain participation. 44 | * Tracks their latest chains, and best chains and their contributions. 45 | */ 46 | struct UserStats { 47 | mapping(saddress => suint) latestChain; // Address => Last chain participated in 48 | mapping(saddress => suint) bestChain; // Address => Best (longest) chain participated in 49 | mapping(saddress => suint) bestChainLinks; // Address => Number of contributions in the best chain 50 | } 51 | 52 | ChainStats activeChain; // The currently active chain 53 | CompStats comp; // Tracks global competition statistics 54 | UserStats user; // Tracks user-specific statistics 55 | 56 | /** 57 | * @notice Adds transaction details to the active chain. 58 | * Updates participation records for both the participant and the business. 59 | * If the participant or business is contributing for the first time, their records are updated accordingly. 60 | * @param pAddr The participant's address. 61 | * @param bAddr The business's address. 62 | */ 63 | function forgeLink(saddress pAddr, saddress bAddr) public competitionOnly { 64 | // If this is the participant's first time contributing to this chain, update records. 65 | if (user.latestChain[pAddr] != activeChain.chainId) { 66 | updateToCurrentChain(pAddr); 67 | activeChain.uniqueParticipants++; 68 | } 69 | activeChain.links[pAddr]++; // Increment participant’s contribution count 70 | 71 | // If this is the business's first time being added to this chain, update records. 72 | if (user.latestChain[bAddr] != activeChain.chainId) { 73 | updateToCurrentChain(bAddr); 74 | activeChain.uniqueBusinesses++; 75 | } 76 | activeChain.links[bAddr]++; // Increment business’s contribution count 77 | activeChain.chainLength++; // Increment chain length 78 | } 79 | 80 | /** 81 | * @dev Resets a participant/business’s contribution count and updates their latest chain. 82 | * @param addr The address of the participant/business. 83 | */ 84 | function updateToCurrentChain(saddress addr) internal { 85 | checkIsChainLongest(addr); // Check if their latest chain was their longest 86 | activeChain.links[addr] = suint(0); // Reset contribution count 87 | user.latestChain[addr] = activeChain.chainId; // Assign the latest chain 88 | } 89 | 90 | /** 91 | * @dev Checks if the participant/business’s latest chain had the highest score. 92 | * If so, update their best chain records. 93 | * This is public due to the need to check the final chain after the competition ends 94 | * @param addr The address of the participant/business. 95 | */ 96 | function checkIsChainLongest(saddress addr) public competitionOnly { 97 | suint latestScore = comp.chainFinalScores[user.latestChain[addr]]; 98 | suint bestScore = comp.chainFinalScores[user.bestChain[addr]]; 99 | if (latestScore > bestScore) { 100 | user.bestChain[addr] = user.latestChain[addr]; // Update best chain 101 | user.bestChainLinks[addr] = activeChain.links[addr]; // Update best chain's contribution count 102 | } 103 | } 104 | 105 | /** 106 | * @notice Ends the active chain, records its statistics, and resets it to be used as the next chain. 107 | * Updates the overall highest-scoring chain if applicable. 108 | */ 109 | function nuke() public competitionOnly { 110 | // Record final score and length of the nuked chain 111 | comp.chainFinalScores[activeChain.chainId] = 112 | calcChainScore(activeChain.uniqueParticipants, activeChain.uniqueBusinesses); 113 | comp.chainFinalLengths[activeChain.chainId] = activeChain.chainLength; 114 | 115 | // Update the overall winning chain if this one has a higher score 116 | if (comp.chainFinalScores[activeChain.chainId] > comp.chainFinalScores[comp.overallWinningChainId]) { 117 | comp.overallWinningChainId = activeChain.chainId; 118 | } 119 | 120 | // Reset the active chain for a fresh start 121 | activeChain.chainLength = suint(0); 122 | activeChain.uniqueParticipants = suint(0); 123 | activeChain.uniqueBusinesses = suint(0); 124 | activeChain.chainId++; 125 | } 126 | 127 | /** 128 | * @dev Calculates a chain's final score based on distinct participants and businesses. 129 | * @param pScore The number of distinct participants. 130 | * @param bScore The number of distinct businesses. 131 | * @return The calculated final score. 132 | */ 133 | function calcChainScore(suint pScore, suint bScore) internal pure returns (suint) { 134 | // Formula: finalScore = unique businesses * (1 + (unique participants / 10)) 135 | return bScore * (suint(1) + (pScore / suint(10))); 136 | } 137 | 138 | /// @notice Returns the ID of the highest-scoring chain. 139 | function getWinningChainId() public view competitionOnly returns (uint256) { 140 | return uint256(comp.overallWinningChainId); 141 | } 142 | 143 | /// @notice Returns the total length of the highest-scoring chain. 144 | function getWinningChainLength() public view competitionOnly returns (uint256) { 145 | return uint256(comp.chainFinalLengths[comp.overallWinningChainId]); 146 | } 147 | 148 | /// @notice Returns the best (highest-scoring) chain a participant/business has contributed to. 149 | /// @param addr The address of the participant/business. 150 | function getBestChainId(saddress addr) public view competitionOnly returns (uint256) { 151 | return uint256(user.bestChain[addr]); 152 | } 153 | 154 | /// @notice Returns the number of times a participant/business contributed to their best chain. 155 | /// @param addr The address of the participant/business. 156 | function getBestChainCount(saddress addr) public view competitionOnly returns (uint256) { 157 | return uint256(user.bestChainLinks[addr]); 158 | } 159 | 160 | /** 161 | * @dev Resets a participant/business contribution count on the winning chain 162 | * Prevents them from claiming rewards multiple times. 163 | * @param addr The address of the participant/business. 164 | */ 165 | function walletHasBeenPaid(saddress addr) public competitionOnly { 166 | user.bestChainLinks[addr] = suint(0); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /folio/src/Competition.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0 2 | pragma solidity ^0.8.13; 3 | 4 | import "./ChainTracker.sol"; 5 | import "./IStoreFront.sol"; 6 | 7 | /// @title Pay-it-Forward Competition Manager Contract 8 | /// @notice Manages the different phases of a pay-it-forward competition. 9 | contract Competition { 10 | // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~Constants and Modifiers~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 11 | 12 | /// @notice Creates the manager, chain, charity, and list of approved businesses. 13 | address manager; 14 | ChainTracker public chain; 15 | address charity; 16 | mapping(address => bool) approvedBusinesses; 17 | 18 | /** 19 | * @notice Details the preset competition rules, made public for verification sake /// outlining/verifying the competition rules. 20 | * @dev Initializes the public variables determining the competition framework including: 21 | * Setting the percent portion each group (participant, business, charity) gets of the prize pool 22 | * Setting the amount of eth per transaction that will be added to the prize pot 23 | * Setting up the phases of the competiton and the starting time and duration the competition will run for 24 | */ 25 | uint256 public constant participantPerc = 50; 26 | uint256 public constant businessPerc = 15; 27 | uint256 public constant charityPerc = 35; 28 | uint256 public constant prizePotAllotment = 1; 29 | CompetitionPhases public competition = CompetitionPhases.PRE; 30 | uint256 public competitionStartTime; 31 | uint256 public duration = 2592000; //this should be read as seconds, i.e. 2592000 seconds = 30 days 32 | 33 | /** 34 | * @notice Internal variables used for storing relevant competiton information. 35 | * @dev Initializes the variables assisting the competition, including: 36 | * Determining a single link's worth of payout at the end of a competition for participants and businesses 37 | * Determining the amount being donated to a charity 38 | * setting an important flag to keep track of the most recent transaction's decision to pay it forward 39 | */ 40 | uint256 pWinnings; 41 | uint256 bWinnings; 42 | uint256 donation; 43 | sbool lastPifChoice = sbool(false); 44 | 45 | /// @notice Enum to manage the stages of the competition. 46 | /// @dev The competition progresses through these phases: `PRE`, `DURING`, and `POST`. 47 | enum CompetitionPhases { 48 | PRE, 49 | DURING, 50 | POST 51 | } 52 | 53 | /// @notice Modifier to ensure only the organizer can call certain functions. 54 | /// Organizer is the person that selects the charity, approves businesses, and starts the competition 55 | modifier organizerOnly() { 56 | require(msg.sender == manager, "The competition manager must call this."); 57 | _; 58 | } 59 | 60 | /// @notice Modifier to ensure that the restricted functions are only involving approved businesses. 61 | modifier approvedBusinessesOnly(address businessAddr) { 62 | require(approvedBusinesses[businessAddr], "This business is not approved."); 63 | _; 64 | } 65 | 66 | /// @notice Modifier to ensure that the function is only called during the specified competition phase. 67 | modifier atStage(CompetitionPhases phase) { 68 | require(phase == competition, "This function is is invalid at this stage."); 69 | _; 70 | } 71 | 72 | // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~constructor~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 73 | constructor(address charityAddr) { 74 | manager = msg.sender; 75 | chain = new ChainTracker(); 76 | selectCharity(charityAddr); 77 | } 78 | 79 | // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~pre competition~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 80 | /** 81 | * @notice Sets the charity we will be donating to at the end of the competition. 82 | * @dev Checks that the charity address is not the burn address 83 | * @param charityAddr The address of the charity 84 | */ 85 | function selectCharity(address charityAddr) public organizerOnly atStage(CompetitionPhases.PRE) { 86 | require(charityAddr != address(0x0), "Invalid address."); 87 | charity = charityAddr; 88 | } 89 | 90 | /** 91 | * @notice Process to approve a business to participate in the competition. 92 | * @param businessAddr The address of the business being approved. 93 | * This prevents random addresses claiming themselves as businesses 94 | */ 95 | function businessApprovalProcess(address businessAddr) public organizerOnly atStage(CompetitionPhases.PRE) { 96 | approvedBusinesses[businessAddr] = true; 97 | } 98 | 99 | /// @notice Allows an approved business to donate to the prize pool, if they so choose. 100 | function businessContribution() 101 | external 102 | payable 103 | approvedBusinessesOnly(msg.sender) 104 | atStage(CompetitionPhases.PRE) 105 | { 106 | require(msg.value > 0, "Contribution has been rejected."); 107 | } 108 | 109 | /** 110 | * @notice Function for the manager to start the competition. 111 | * @dev Performs the necessary actions to begin the competition, specifically: 112 | * Checks that a charity has been selected 113 | * Changes the enum phase to DURING 114 | * Sets the competition starting time to the current time 115 | */ 116 | function startCompetition() public organizerOnly atStage(CompetitionPhases.PRE) { 117 | require(charity != address(0x0), "Charity has not been selected."); 118 | competitionStartTime = block.timestamp; 119 | competition = CompetitionPhases.DURING; 120 | } 121 | 122 | // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~during competition~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 123 | /** 124 | * @notice Processes the financial transactions customers purchase products. 125 | * @dev Handles transaction data for participants at businesses 126 | * Checks that the transaction occurred at an approved business 127 | * Processes the product price and sends the money to the business, via the storefront the business will have set up 128 | * Processes the money paid forward and the prize pot and gives rebate depending on the current state of the chain (determined by currentPifChoice and lastPifChoice) 129 | * Updates the chain to reflect the current transaction 130 | * Ends the competition if applicable 131 | * @param currentPifChoice The current transactions decision to pay it forward 132 | * @param business The address of the business the transaction is occurring at 133 | * @param prodID THe ID of the product being purchased in the transaction 134 | */ 135 | function makeCompTransaction(sbool currentPifChoice, address business, suint prodID) 136 | external 137 | payable 138 | approvedBusinessesOnly(business) 139 | atStage(CompetitionPhases.DURING) 140 | { 141 | if (lastPifChoice && currentPifChoice) { 142 | // case: last person pif, current person pif 143 | uint256 prodPrice = msg.value - (5 + (prizePotAllotment / 100)); //subtract pif amount and prize pot amount 144 | IStoreFront(business).purchase{value: prodPrice}(prodID); // store implements their own purchase logic 145 | 146 | // rebate: yes, last person pif 147 | (bool success,) = msg.sender.call{value: 5}(""); 148 | require(success, "Rebate failed."); 149 | 150 | // update chain with new pif information 151 | chain.forgeLink(saddress(msg.sender), saddress(business)); 152 | 153 | // don't update lastPifChoice because it is the same 154 | } else if (!lastPifChoice && currentPifChoice) { 155 | // case: last person did not pif, current person pif 156 | uint256 prodPrice = msg.value - (5 + (prizePotAllotment / 100)); //subtract pif amount and prize pot amount 157 | IStoreFront(business).purchase{value: prodPrice}(prodID); // store implements their own purchase logic 158 | 159 | // rebate: no, last person didn't pif 160 | 161 | // update chain with new pif information 162 | chain.forgeLink(saddress(msg.sender), saddress(business)); 163 | 164 | //update lastPifChoice to new choice 165 | lastPifChoice = currentPifChoice; 166 | } else if (lastPifChoice && !currentPifChoice) { 167 | // case: last person pif, current person did not pif 168 | uint256 prodPrice = msg.value; // amount sent is exact 169 | IStoreFront(business).purchase{value: prodPrice}(prodID); // store implements their own purchase logic 170 | 171 | // rebate: yes, last person pif 172 | (bool success,) = msg.sender.call{value: 5}(""); 173 | require(success, "Rebate failed."); 174 | 175 | // nuke the existing chain 176 | chain.nuke(); 177 | 178 | // update lastPifChoice to new choice 179 | lastPifChoice = currentPifChoice; 180 | } else { 181 | // case: last person did not pif, you did not pif 182 | uint256 prodPrice = msg.value; // since current person didn't pif, they send the exact amount 183 | IStoreFront(business).purchase{value: prodPrice}(prodID); // store implements their own purchase logic 184 | 185 | // rebate: no, last person didn't pif 186 | 187 | // no update/nuke because chain is default 188 | 189 | //don't update last PIF because it is the same 190 | } 191 | } 192 | /** 193 | * @notice Ends the competition if the set duration has passed. 194 | * @dev Check if required durtation has passed since the competitions starting timestamp 195 | * Changes the enum phase to POST 196 | */ 197 | 198 | function endCompetition() public organizerOnly() atStage(CompetitionPhases.DURING) { 199 | if (block.timestamp >= competitionStartTime + duration) { 200 | competition = CompetitionPhases.POST; 201 | setupPostCompetition(); 202 | } 203 | } 204 | 205 | // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~post competition~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 206 | /** 207 | * @notice Sets up the post competition phase. 208 | * @dev Executes final nuke to determine the overall highest-scoring chain 209 | * Sets up the payouts 210 | */ 211 | function setupPostCompetition() internal atStage(CompetitionPhases.POST) { 212 | chain.nuke(); 213 | setupPayout(); 214 | } 215 | 216 | /** 217 | * @dev Helper function to determine the amount of payout per link in the chain for participants and businesses 218 | * Calculates the charity donation 219 | */ 220 | function setupPayout() internal atStage(CompetitionPhases.POST) { 221 | // calc prize pot amount of a single link for participant/business 222 | pWinnings = ((address(this).balance * participantPerc) / (chain.getWinningChainLength() * 100)); 223 | bWinnings = ((address(this).balance * businessPerc) / (chain.getWinningChainLength() * 100)); 224 | 225 | // calc amount of prize pot being donated to charity 226 | donation = ((address(this).balance * charityPerc) / 100); 227 | } 228 | 229 | /** 230 | * @notice Claim function for the charity to recieve it's payout of the winning chain. 231 | * @dev Checks that the one calling the function is the charity 232 | * Checks again that the charity address is not the burn address 233 | * Sends the determined amount to the charity 234 | */ 235 | function claimDonation() external payable atStage(CompetitionPhases.POST) { 236 | // check if charity address if valid 237 | require(msg.sender == charity, "You are not the charity."); 238 | require(charity != address(0x0), "Invalid address."); 239 | // payout charity 240 | (bool paid,) = charity.call{value: donation}(""); 241 | require(paid, "Donation failed."); 242 | } 243 | 244 | /** 245 | * @notice Claim function for participants and businesses to recieve their payouts of the winning chain. 246 | * @dev Performs final check to determine the participants/businesses best chain 247 | * Checks if their best chain is the winning chain, and if so fetches the count of links they had in the winning chain 248 | * Checks whether the wallet is a participant or a business and pays out accordingly 249 | * Updates the number of links they had in the winning chain to prevent repeated payouts 250 | */ 251 | function payout() external payable atStage(CompetitionPhases.POST) { 252 | chain.checkIsChainLongest(saddress(msg.sender)); 253 | if (chain.getBestChainId(saddress(msg.sender)) == chain.getWinningChainId()) { 254 | uint256 linkCount = chain.getBestChainCount(saddress(msg.sender)); 255 | if (!approvedBusinesses[msg.sender]) { 256 | // payout to participant 257 | (bool paid,) = msg.sender.call{value: linkCount * pWinnings}(""); 258 | require(paid, "Payout failed."); 259 | } else { 260 | // payout to business 261 | (bool paid,) = msg.sender.call{value: linkCount * bWinnings}(""); 262 | require(paid, "Payout failed."); 263 | } 264 | chain.walletHasBeenPaid(saddress(msg.sender)); 265 | } 266 | } 267 | 268 | function getChainTrackerAddress() public view returns (address) { 269 | return address(chain); 270 | } 271 | 272 | function getManagerAddress() public view returns (address) { 273 | return address(manager); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /folio/src/IStoreFront.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | /// @title StoreFront Interface 5 | /// @dev This interface defines the function required for purchasing a product in a businesses storefront system. 6 | interface IStoreFront { 7 | /** 8 | * @notice Allows a business to set up their own personal storefront 9 | * @dev This function accepts a payment to complete the purchase of a product and requires the product ID as input to identify which product to purchase. 10 | * @param prodId The ID of the product to be purchased. 11 | */ 12 | function purchase(suint prodId) external payable; 13 | } 14 | -------------------------------------------------------------------------------- /folio/test/Chain.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT License 2 | pragma solidity ^0.8.13; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | import {ChainTracker} from "../src/ChainTracker.sol"; 6 | 7 | contract TestChain is Test { 8 | // create test Chain and dummy wallets to set transactions 9 | ChainTracker public chain; 10 | saddress participantA; 11 | saddress participantB; 12 | saddress businessA; 13 | saddress businessB; 14 | saddress rando; 15 | 16 | // set up new chain object as well as the dummy addresses we will be using 17 | // owner address is address(this) 18 | function setUp() public { 19 | chain = new ChainTracker(); 20 | participantA = saddress(0x111); 21 | participantB = saddress(0x222); 22 | businessA = saddress(0x100); 23 | businessB = saddress(0x200); 24 | rando = saddress(0x999); 25 | //print list of addresses for visibility 26 | console.log(address(this)); 27 | console.log(address(participantA)); 28 | console.log(address(participantB)); 29 | console.log(address(businessA)); 30 | console.log(address(businessB)); 31 | console.log(address(rando)); 32 | } 33 | 34 | // test our getter functions for logic and ownership 35 | function test_getters() public { 36 | // chain length getter 37 | chain.getWinningChainLength(); //should pass 38 | vm.expectRevert(); 39 | vm.prank(address(rando)); 40 | chain.getWinningChainLength(); // should fail 41 | // user best chain getter 42 | chain.getBestChainId(participantA); // should pass 43 | vm.expectRevert(); 44 | vm.prank(address(rando)); 45 | chain.getBestChainId(participantA); // should fail 46 | } 47 | 48 | //test the update function for logic and ownership 49 | function test_forgeLink() public { 50 | chain.forgeLink(participantA, businessA); // should pass and set new mapping entries 51 | chain.forgeLink(participantA, businessB); // should pass and set new business mapping entry 52 | chain.forgeLink(participantB, businessA); // should pass and set new participant mapping entry 53 | chain.forgeLink(participantB, businessB); // should pass and not set new mapping entries 54 | //check ownership 55 | vm.expectRevert(); 56 | vm.prank(address(rando)); 57 | chain.forgeLink(participantA, businessA); // should fail and not update the chain 58 | 59 | console.log(chain.getWinningChainLength()); 60 | console.log(chain.getBestChainId(participantA)); 61 | console.log(chain.getBestChainId(businessB)); 62 | console.log(chain.getBestChainId(saddress(0x555))); 63 | } 64 | 65 | // test the nuke function for logic and ownership 66 | function test_nuke() public { 67 | // Add links to the chain 68 | chain.forgeLink(participantA, businessA); 69 | chain.forgeLink(participantB, businessB); 70 | 71 | // Nuke the chain 72 | chain.nuke(); 73 | 74 | // Check that the active chain is reset 75 | uint256 winningChainId = chain.getWinningChainId(); 76 | assertEq(winningChainId, 0, "Winning chain ID should be 0 after nuking the first chain."); 77 | 78 | uint256 winningChainLength = chain.getWinningChainLength(); 79 | assertEq(winningChainLength, 2, "Winning chain length should be 2 after nuking the first chain."); 80 | 81 | // Check ownership 82 | vm.expectRevert(); 83 | vm.prank(address(rando)); 84 | chain.nuke(); // should fail and not nuke the chain 85 | } 86 | 87 | // test the walletHasBeenPaid function for logic and ownership 88 | function test_walletHasBeenPaid() public { 89 | // Add links to the chain 90 | chain.forgeLink(participantA, businessA); 91 | chain.forgeLink(participantA, businessB); 92 | 93 | // Mark the wallet as paid 94 | chain.walletHasBeenPaid(participantA); 95 | 96 | // Check that the best chain count is reset 97 | uint256 bestChainCount = chain.getBestChainCount(participantA); 98 | assertEq(bestChainCount, 0, "Best chain count should be 0 after walletHasBeenPaid."); 99 | 100 | // Check ownership 101 | vm.expectRevert(); 102 | vm.prank(address(rando)); 103 | chain.walletHasBeenPaid(participantA); // should fail and not reset the count 104 | } 105 | 106 | // test the checkIsChainLongest function for logic and ownership 107 | function test_checkIsChainLongest() public { 108 | // Add links to the chain 109 | chain.forgeLink(participantA, businessA); 110 | chain.forgeLink(participantA, businessB); 111 | 112 | // Check if the latest chain is the longest 113 | chain.checkIsChainLongest(participantA); 114 | 115 | // Check that the best chain is updated 116 | uint256 bestChainId = chain.getBestChainId(participantA); 117 | assertEq(bestChainId, 0, "Best chain ID should be 0 after first chain."); 118 | 119 | // Check ownership 120 | vm.expectRevert(); 121 | vm.prank(address(rando)); 122 | chain.checkIsChainLongest(participantA); // should fail and not update the best chain 123 | } 124 | } -------------------------------------------------------------------------------- /folio/test/Competition.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.13; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | import {Competition} from "../src/Competition.sol"; 6 | import {ChainTracker} from "../src/ChainTracker.sol"; 7 | import {IStoreFront} from "../src/IStoreFront.sol"; 8 | import {MockStore} from "./utils/MockStore.sol"; 9 | 10 | contract TestCompetition is Test { 11 | address managerAddress; 12 | address charityAddress = address(0x2); 13 | address participantAddress = address(0x4); 14 | MockStore store; 15 | ChainTracker chain; 16 | 17 | Competition public competition; 18 | 19 | function setUp() public { 20 | store = new MockStore(); 21 | competition = new Competition(charityAddress); 22 | competition.businessApprovalProcess(address(store)); 23 | competition.startCompetition(); 24 | chain = ChainTracker(competition.getChainTrackerAddress()); 25 | managerAddress = competition.getManagerAddress(); 26 | } 27 | 28 | // Test that only the manager can start the competition 29 | function testOnlyManagerCanStartCompetition() public { 30 | address nonManager = address(0x5); 31 | vm.prank(nonManager); 32 | vm.expectRevert("The competition manager must call this."); 33 | competition.startCompetition(); 34 | } 35 | 36 | // Test that only approved businesses can participate 37 | function testOnlyApprovedBusinessesCanParticipate() public { 38 | address unapprovedBusiness = address(0x6); 39 | vm.prank(participantAddress); 40 | vm.expectRevert("This business is not approved."); 41 | competition.makeCompTransaction(sbool(true), unapprovedBusiness, suint(1)); 42 | } 43 | 44 | // Test that a participant can make a transaction with an approved business 45 | function testMakeCompTransaction() public { 46 | vm.prank(participantAddress); 47 | // Initializes 40 ether to participant wallet address 48 | vm.deal(participantAddress, 40 ether); 49 | competition.makeCompTransaction{value: 10 ether}(sbool(true), address(store), suint(1)); 50 | 51 | // Verify that the chain has been updated 52 | vm.prank(address(competition)); 53 | uint256 bestChainId = chain.getBestChainId(saddress(participantAddress)); 54 | assertEq(bestChainId, 0, "Best chain ID should be 0 after first transaction."); 55 | } 56 | 57 | // Test that the competition can be ended after the duration has passed 58 | function testEndCompetition() public { 59 | // Initializes 40 ether to participant wallet address 60 | vm.deal(participantAddress, 40 ether); 61 | 62 | // Make a transaction to trigger the end of the competition 63 | vm.prank(participantAddress); 64 | competition.makeCompTransaction{value: 10 ether}(sbool(true), address(store), suint(1)); 65 | 66 | // Fast-forward time to the end of the competition 67 | vm.warp(block.timestamp + competition.duration()); 68 | 69 | // End the competition 70 | vm.prank(managerAddress); 71 | competition.endCompetition(); 72 | 73 | // Verify that the competition has ended 74 | assertEq(uint256(competition.competition()), 2, "Competition should be in POST phase."); 75 | } 76 | } -------------------------------------------------------------------------------- /folio/test/utils/MockStore.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT License 2 | pragma solidity ^0.8.13; 3 | 4 | import {IStoreFront} from "../../src/IStoreFront.sol"; 5 | import {Test, console} from "forge-std/Test.sol"; 6 | 7 | contract MockStore is IStoreFront { 8 | 9 | // fullfilling the IStoreFront interface 10 | function purchase(suint prodId) external payable override { 11 | console.log("entered purchase"); 12 | } 13 | 14 | // recieving external payments 15 | // this is neeeded for testing if a business can recieve a payout for winning the competition 16 | receive() external payable { 17 | console.log("received"); 18 | } 19 | 20 | } -------------------------------------------------------------------------------- /level/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Docs 11 | docs/ 12 | 13 | # Dotenv file 14 | .env 15 | -------------------------------------------------------------------------------- /level/README.md: -------------------------------------------------------------------------------- 1 | # LEVEL: Stabilize your DePIN service 2 | 3 | ## Overview 4 | 5 | LEVEL ensures stable network coverage for Decentralized Physical Infrastructure Networks (DePIN) independent of token price fluctuations during the initial bootstrapping phase. 6 | 7 | ### Problem 8 | 9 | DePIN projects that rely on token incentives to bootstrap service operators are highly vulnerable to token price fluctuations. When token prices surge, coverage rapidly expands, but can contract just as quickly during price downturns. This cyclical pattern makes it difficult to maintain consistent service quality and coverage. 10 | 11 | ### Insight 12 | 13 | We can decouple network coverage from token price volatility by temporarily obscuring the exchange rate during the critical bootstrapping period, while still guaranteeing a minimum withdrawal value. This allows the network to establish stable coverage patterns unencumbered by speculative responses to token movements. 14 | 15 | ### Solution 16 | 17 | Our project introduces a token with an obscured exchange rate during the initial bootstrapping phase, ensuring operators receive rewards above a base price set by the protocol each epoch to covering estimated operating costs. -------------------------------------------------------------------------------- /level/foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | 6 | # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options 7 | -------------------------------------------------------------------------------- /level/src/InternalAMM.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT License 2 | pragma solidity ^0.8.13; 3 | 4 | import {ISRC20} from "./SRC20.sol"; 5 | import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; 6 | 7 | /// @title InternalAMM - Restricted Access Automated Market Maker 8 | /// @notice A constant product AMM (x*y=k) that manages the liquidity pool for the DePIN price floor protocol 9 | /// @dev Uses shielded data types (suint256) for privacy-preserving calculations 10 | /// @dev All operations are restricted to the owner to so that even properties relevent to the caller, 11 | // e.g. price, can't be observed until the owner wishes to reveal them 12 | contract InternalAMM is Ownable(msg.sender) { 13 | ISRC20 public token0; 14 | ISRC20 public token1; 15 | 16 | suint256 reserve0; 17 | suint256 reserve1; 18 | 19 | suint256 totalSupply; 20 | mapping(address => suint256) balanceOf; 21 | 22 | /// @notice Initializes the AMM with token pair addresses 23 | /// @param _token0 Address of the first token (typically WDG) 24 | /// @param _token1 Address of the second token (typically USDC) 25 | constructor(address _token0, address _token1) { 26 | token0 = ISRC20(_token0); 27 | token1 = ISRC20(_token1); 28 | } 29 | 30 | function _mint(address _to, suint256 _amount) private { 31 | balanceOf[_to] += _amount; 32 | totalSupply += _amount; 33 | } 34 | 35 | function _burn(address _from, suint256 _amount) private { 36 | balanceOf[_from] -= _amount; 37 | totalSupply -= _amount; 38 | } 39 | 40 | function _update(suint256 _reserve0, suint256 _reserve1) internal { 41 | reserve0 = _reserve0; 42 | reserve1 = _reserve1; 43 | } 44 | 45 | /// @notice Calculates amount of token0 received after a liquidation 46 | /// @dev Uses constant product formula to determine price impact 47 | /// @param _amount0 Amount of token0 to be liquidated 48 | /// @return Average price of token0 denominated in token1 49 | function calcSwapOutput(suint256 _amount0) external view onlyOwner returns (uint256) { 50 | /* XY = K 51 | (X+dX)(Y-dY) = K 52 | dY = Y - K / (X+dX) 53 | dY = YdX/(X+dX) = Y(1-X/(X+dX)) 54 | 55 | Return dY = dX * Y / (X+dX) 56 | */ 57 | 58 | return uint256(reserve1 / (reserve0 / _amount0 + suint(1))); 59 | } 60 | 61 | /// @notice Calculates required input amount for desired output 62 | /// @dev Reverse calculation of constant product formula 63 | /// @param _tokenOut Address of token to receive 64 | /// @param _amount Desired output amount 65 | /// @return amountIn of input token required to achieve desired output 66 | function calcSwapInput(address _tokenOut, suint256 _amount) public view onlyOwner returns (uint256) { 67 | /* XY = K 68 | (X+dX)(Y-dY) = K 69 | dY = Y - K / (X+dX) 70 | dY = YdX/(X+dX) = Y(1-X/(X+dX)) 71 | 72 | Return dX = X dY / (Y-dY) 73 | */ 74 | suint256 amountIn = _tokenOut == address(token1) 75 | ? reserve0 / (reserve1 / _amount - suint(1)) 76 | : reserve1 / (reserve0 / _amount - suint(1)); 77 | 78 | return uint256(amountIn); 79 | } 80 | 81 | /// @notice Adds liquidity to the AMM pool 82 | /// @dev Maintains price ratio for existing pools 83 | /// @param _amount0 Amount of token0 to add 84 | /// @param _amount1 Amount of token1 to add 85 | /// @param originalSender Address to receive LP tokens 86 | function addLiquidity(suint256 _amount0, suint256 _amount1, address originalSender) external onlyOwner { 87 | token0.transferFrom(saddress(msg.sender), saddress(this), _amount0); 88 | token1.transferFrom(saddress(msg.sender), saddress(this), _amount1); 89 | 90 | if (reserve0 > suint256(0) || reserve1 > suint256(0)) { 91 | require( 92 | reserve0 * _amount1 == reserve1 * _amount0, //preserving price 93 | "x / y != dx / dy" 94 | ); 95 | } 96 | // if i wanted to put usdc into the pool, first swap until the ratios 97 | 98 | suint256 shares = totalSupply == suint(0) 99 | ? _sqrt(_amount0 * _amount1) 100 | : _min((_amount0 * totalSupply) / reserve0, (_amount1 * totalSupply) / reserve1); 101 | 102 | require(shares > suint256(0), "No shares to mint"); 103 | _mint(originalSender, shares); 104 | 105 | // recalculate k 106 | _update(suint256(token0.balanceOf()), suint256(token1.balanceOf())); 107 | } 108 | 109 | /// @notice Removes liquidity from the AMM pool 110 | /// @dev Burns LP tokens and returns underlying assets 111 | /// @param _shares Amount of LP tokens to burn 112 | /// @param originalSender Address that owns the LP tokens 113 | function removeLiquidity(suint256 _shares, address originalSender) external onlyOwner { 114 | require(balanceOf[originalSender] > _shares, "Insufficient shares"); 115 | suint256 amount0 = (_shares * reserve0) / totalSupply; 116 | suint256 amount1 = (_shares * reserve1) / totalSupply; 117 | require(amount0 > suint256(0) && amount1 > suint256(0), "amount0 or amount1 = 0"); 118 | 119 | _burn(originalSender, _shares); // burn LP shares 120 | _update(reserve0 - amount0, reserve1 - amount1); 121 | 122 | token0.transfer(saddress(msg.sender), amount0); 123 | token1.transfer(saddress(msg.sender), suint(amount1)); 124 | } 125 | 126 | /// @notice Executes token swap using constant product formula 127 | /// @dev Updates reserves after swap completion 128 | /// @param _tokenIn Address of input token 129 | /// @param _amountIn Amount of input token 130 | function swap(saddress _tokenIn, suint256 _amountIn) external onlyOwner { 131 | require(_amountIn > suint(0), "Invalid amount to swap"); 132 | require(_tokenIn == saddress(token0) || _tokenIn == saddress(token1), "Invalid token"); 133 | 134 | bool isToken0 = _tokenIn == saddress(token0); 135 | 136 | (ISRC20 tokenIn, ISRC20 tokenOut, suint256 reserveIn, suint256 reserveOut) = 137 | isToken0 ? (token0, token1, reserve0, reserve1) : (token1, token0, reserve1, reserve0); 138 | 139 | tokenIn.transferFrom(saddress(msg.sender), saddress(this), _amountIn); 140 | 141 | suint256 amountOut = reserveOut * _amountIn / (reserveIn + _amountIn); // still shielded 142 | 143 | tokenOut.approve(saddress(this), amountOut); 144 | tokenOut.transferFrom(saddress(this), saddress(msg.sender), amountOut); 145 | 146 | _update(suint256(token0.balanceOf()), suint256(token1.balanceOf())); 147 | } 148 | 149 | /// @notice Executes token swap by taking token out of the pool. 150 | /// This is ONLY called within operatorWithdraw, where the owed balance 151 | /// is first transferred to the AMM. 152 | /// @dev Updates reserves after swap completion. 153 | function swapOut(saddress _tokenOut, suint256 _amountOut) external onlyOwner { 154 | require(_tokenOut == saddress(token0) || _tokenOut == saddress(token1), "Invalid token"); 155 | 156 | bool isToken0 = _tokenOut == saddress(token0); 157 | 158 | (ISRC20 tokenOwed, ISRC20 tokenRm, suint256 reserveRm) = 159 | isToken0 ? (token1, token0, reserve0) : (token0, token1, reserve1); 160 | 161 | require(_amountOut <= reserveRm, "Invalid amount to extract."); 162 | 163 | suint256 amountOwed = suint256(calcSwapInput(address(tokenRm), _amountOut)); 164 | 165 | tokenOwed.transferFrom(saddress(msg.sender), saddress(this), amountOwed); 166 | 167 | tokenRm.approve(saddress(this), _amountOut); 168 | tokenRm.transferFrom(saddress(this), saddress(msg.sender), _amountOut); 169 | 170 | _update(suint256(token0.balanceOf()), suint256(token1.balanceOf())); 171 | } 172 | 173 | /// @notice Calculates square root using binary search 174 | /// @dev Used for initial LP token minting 175 | /// @param y Value to find square root of 176 | /// @return z Square root of input value 177 | function _sqrt(suint256 y) private pure returns (suint256 z) { 178 | if (y < suint256(3)) { 179 | z = y; 180 | suint256 x = y / suint256(2) + suint256(1); 181 | while (x < z) { 182 | z = x; 183 | x = (y / x + x) / suint256(2); 184 | } 185 | } else if (y != suint256(0)) { 186 | z = suint256(1); 187 | } 188 | } 189 | 190 | /// @notice Returns minimum of two values 191 | /// @param x First value 192 | /// @param y Second value 193 | /// @return Smaller of the two inputs 194 | function _min(suint256 x, suint256 y) private pure returns (suint256) { 195 | return x <= y ? x : y; 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /level/src/Level.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT License 2 | pragma solidity ^0.8.13; 3 | 4 | import {ISRC20} from "./SRC20.sol"; 5 | import {WDGSRC20} from "./WDGSRC20.sol"; 6 | import {InternalAMM} from "./InternalAMM.sol"; 7 | 8 | /// @title Level - DePIN Operator Reward Price Floor Protocol 9 | /// @notice This contract implements a minimum price guarantee mechanism for DePIN operator rewards, 10 | /// ensuring operators can liquidate their rewards at a guaranteed minimum price. 11 | /// @dev Uses an AMM to provide price guarantees and implements epoch-based withdrawal limits 12 | contract Level { 13 | address public rewardOracle; 14 | uint256 public constant BLOCKS_PER_EPOCH = 7200; // about a day 15 | suint256 private maxWithdrawalPerEpoch; // the max usdc that can be withdrawn per epoch 16 | 17 | InternalAMM public amm; 18 | WDGSRC20 public WDG; 19 | ISRC20 public USDC; 20 | 21 | mapping(saddress => suint256) epochWithdrawalAmt; 22 | mapping(saddress => suint256) lastWithdrawalEpoch; 23 | 24 | modifier onlyOracle() { 25 | require(msg.sender == rewardOracle, "Only the oracle can call this function"); 26 | _; 27 | } 28 | 29 | /// @notice Initializes the price floor protocol 30 | /// @param _wdg Address of the operator reward token (WDG) 31 | /// @param _usdc Address of the stablecoin used for payments/withdrawals 32 | /// @param _rewardOracle Address authorized to distribute operator rewards 33 | /// @param _maxWithdrawalPerEpoch Maximum USDC that can be withdrawn per epoch to manage protocol liquidity 34 | constructor( 35 | address _wdg, 36 | address _usdc, 37 | address _rewardOracle, 38 | suint256 _maxWithdrawalPerEpoch, 39 | suint256 _transferUnlockTime 40 | ) { 41 | rewardOracle = _rewardOracle; 42 | WDG = WDGSRC20(_wdg); 43 | USDC = ISRC20(_usdc); 44 | maxWithdrawalPerEpoch = _maxWithdrawalPerEpoch; 45 | amm = new InternalAMM(_wdg, _usdc); 46 | 47 | // set the wdg trusted addresses 48 | WDG.setDepinServiceAddress(address(this)); 49 | WDG.setAMMAddress(address(amm)); 50 | WDG.setTransferUnlockTime(_transferUnlockTime); 51 | } 52 | 53 | /// @notice Processes user payments for DePIN services 54 | /// @dev Payments are used to support the price guarantee through token buybacks 55 | /// @param usdcAmount Amount of USDC to pay for services 56 | function payForService(suint256 usdcAmount) public { 57 | // transfer USDC from user to this contract 58 | // it is assumed the transfer is approved before calling this function 59 | USDC.transferFrom(saddress(msg.sender), saddress(this), usdcAmount); 60 | 61 | // user payments are distributed to token holders / operators 62 | // through token buybacks in the AMM 63 | _serviceBuyback(usdcAmount); 64 | 65 | // 66 | // PLACEHOLDER 67 | // normally business logic would go here 68 | // but this is a dummy function 69 | // 70 | } 71 | 72 | /// @notice Internal buyback mechanism to support price guarantees 73 | /// @dev Converts service payments to WDG tokens and burns them, supporting token value 74 | /// @param usdcAmount Amount of USDC to use for buyback 75 | function _serviceBuyback(suint256 usdcAmount) internal { 76 | // 1) swap USDC into WDG through the AMM 77 | USDC.approve(saddress(amm), usdcAmount); 78 | amm.swap(saddress(USDC), usdcAmount); 79 | // 2) and burn the WDG token that is swapped out 80 | WDG.burn(saddress(this), suint(WDG.balanceOf())); // assumed there is no reason for this contract to have a WDG balance 81 | } 82 | 83 | /// @notice Distributes reward tokens to operators for their services 84 | /// @dev Only callable by the oracle which determines reward distribution 85 | /// @param operator Address of the DePIN operator 86 | /// @param amount Amount of WDG tokens to mint as reward 87 | function allocateReward(saddress operator, suint256 amount) external onlyOracle { 88 | WDG.mint(operator, amount); // double check this is the correct token 89 | } 90 | 91 | /// @notice Checks operator's remaining withdrawal capacity for the current epoch 92 | /// @dev Enforces epoch-based withdrawal limits to manage protocol liquidity 93 | /// @dev TODO: Future versions should decouple withdrawal caps from token sales to allow 94 | /// operators to manage their token exposure without affecting their withdrawal limits 95 | /// @return Maximum amount of USDC that can currently be withdrawn in current epoch 96 | function calcWithdrawalCap() internal returns (suint256) { 97 | // reset the withdrawal cap if the user has not withdrawn in the current epoch 98 | suint256 currentEpoch = suint(block.number) / suint(BLOCKS_PER_EPOCH); 99 | if (currentEpoch > lastWithdrawalEpoch[saddress(msg.sender)]) { 100 | epochWithdrawalAmt[saddress(msg.sender)] = suint(0); 101 | lastWithdrawalEpoch[saddress(msg.sender)] = currentEpoch; 102 | } else { 103 | require(epochWithdrawalAmt[saddress(msg.sender)] == suint(0), "Already withdrawn this period."); 104 | } 105 | 106 | suint256 usdcBalance = suint256(amm.calcSwapOutput(suint256(WDG.trustedBalanceOf(saddress(msg.sender))))); 107 | return _min(maxWithdrawalPerEpoch, usdcBalance); 108 | } 109 | 110 | /// @notice Returns the maximum amount of USDC an operator can currently withdraw 111 | /// @dev Provides a view into the operator's withdrawal capacity for the current epoch 112 | /// without modifying state. Useful for UIs and off-chain calculations. 113 | /// @return The maximum amount of USDC that can be withdrawn in the current epoch, 114 | /// limited by both the epoch withdrawal cap and the operator's WDG balance 115 | function viewWithdrawalCap() public returns (uint256) { 116 | return uint256(calcWithdrawalCap()); 117 | } 118 | 119 | /// @notice Allows operators to liquidate their reward tokens at the guaranteed price 120 | /// @dev Converts WDG to USDC through AMM at the protocol-guaranteed price 121 | /// @param _amount Amount of USDC to withdraw 122 | function operatorWithdraw(suint256 _amount) public { 123 | suint256 withdrawalCap = calcWithdrawalCap(); // max usdc that user can withdraw 124 | require(_amount <= withdrawalCap, "Overdrafting daily withdrawal limit or insufficient balance."); 125 | 126 | // calculate and swap amount of wdg for usdc 127 | suint256 amountWdgIn = suint256(amm.calcSwapInput(address(USDC), _amount)); 128 | WDG.transferFrom(saddress(msg.sender), saddress(this), amountWdgIn); 129 | 130 | amm.swapOut(saddress(USDC), _amount); // 131 | USDC.transfer(saddress(msg.sender), suint(USDC.balanceOf())); 132 | // USDC balance for this contract should be zero except during operatorWithdraw calls 133 | epochWithdrawalAmt[saddress(msg.sender)] += _amount; 134 | } 135 | 136 | /// @notice Utility function to return the minimum of two values 137 | /// @param x First value 138 | /// @param y Second value 139 | /// @return Minimum of x and y 140 | function _min(suint256 x, suint256 y) private pure returns (suint256) { 141 | return x <= y ? x : y; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /level/src/SRC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.13; 3 | 4 | /*////////////////////////////////////////////////////////////// 5 | // ISRC20 Interface 6 | //////////////////////////////////////////////////////////////*/ 7 | 8 | interface ISRC20 { 9 | /*////////////////////////////////////////////////////////////// 10 | METADATA FUNCTIONS 11 | //////////////////////////////////////////////////////////////*/ 12 | function name() external view returns (string memory); 13 | function symbol() external view returns (string memory); 14 | function decimals() external view returns (uint8); 15 | 16 | /*////////////////////////////////////////////////////////////// 17 | ERC20 FUNCTIONS 18 | //////////////////////////////////////////////////////////////*/ 19 | function balanceOf() external view returns (uint256); 20 | function approve(saddress spender, suint256 amount) external returns (bool); 21 | function transfer(saddress to, suint256 amount) external returns (bool); 22 | function transferFrom(saddress from, saddress to, suint256 amount) external returns (bool); 23 | } 24 | 25 | /*////////////////////////////////////////////////////////////// 26 | // SRC20 Contract 27 | //////////////////////////////////////////////////////////////*/ 28 | 29 | abstract contract SRC20 is ISRC20 { 30 | /*////////////////////////////////////////////////////////////// 31 | METADATA STORAGE 32 | //////////////////////////////////////////////////////////////*/ 33 | string public name; 34 | string public symbol; 35 | uint8 public immutable decimals; 36 | 37 | /*////////////////////////////////////////////////////////////// 38 | ERC20 STORAGE 39 | //////////////////////////////////////////////////////////////*/ 40 | // All storage variables that will be mutated must be confidential to 41 | // preserve functional privacy. 42 | suint256 internal totalSupply; 43 | mapping(saddress => suint256) internal balance; 44 | mapping(saddress => mapping(saddress => suint256)) internal allowance; 45 | 46 | /*////////////////////////////////////////////////////////////// 47 | CONSTRUCTOR 48 | //////////////////////////////////////////////////////////////*/ 49 | constructor(string memory _name, string memory _symbol, uint8 _decimals) { 50 | name = _name; 51 | symbol = _symbol; 52 | decimals = _decimals; 53 | } 54 | 55 | /*////////////////////////////////////////////////////////////// 56 | ERC20 LOGIC 57 | //////////////////////////////////////////////////////////////*/ 58 | function balanceOf() public view virtual returns (uint256) { 59 | return uint256(balance[saddress(msg.sender)]); 60 | } 61 | 62 | function approve(saddress spender, suint256 amount) public virtual returns (bool) { 63 | allowance[saddress(msg.sender)][spender] = amount; 64 | return true; 65 | } 66 | 67 | function transfer(saddress to, suint256 amount) public virtual returns (bool) { 68 | // msg.sender is public information, casting to saddress below doesn't change this 69 | balance[saddress(msg.sender)] -= amount; 70 | unchecked { 71 | balance[to] += amount; 72 | } 73 | return true; 74 | } 75 | 76 | function transferFrom(saddress from, saddress to, suint256 amount) public virtual returns (bool) { 77 | suint256 allowed = allowance[from][saddress(msg.sender)]; // Saves gas for limited approvals. 78 | if (allowed != suint256(type(uint256).max)) { 79 | allowance[from][saddress(msg.sender)] = allowed - amount; 80 | } 81 | 82 | balance[from] -= amount; 83 | unchecked { 84 | balance[to] += amount; 85 | } 86 | return true; 87 | } 88 | 89 | /*////////////////////////////////////////////////////////////// 90 | INTERNAL MINT/BURN LOGIC 91 | //////////////////////////////////////////////////////////////*/ 92 | function _mint(saddress to, suint256 amount) internal virtual { 93 | totalSupply += amount; 94 | unchecked { 95 | balance[to] += amount; 96 | } 97 | } 98 | 99 | function _burn(saddress to, suint256 amount) internal virtual { 100 | totalSupply -= amount; 101 | balance[to] -= amount; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /level/src/WDGSRC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.13; 3 | 4 | import {ISRC20} from "./SRC20.sol"; 5 | 6 | /*////////////////////////////////////////////////////////////// 7 | // WDGSRC20 Contract 8 | //////////////////////////////////////////////////////////////*/ 9 | 10 | /// @title WDGSRC20 - Privacy-Preserving Restricted Transfer Token 11 | /// @notice An ERC20-like token implementation that uses shielded data types and restricts transfers 12 | /// @dev Implements transfer restrictions and uses `saddress` and `suint256` types for privacy 13 | /// @dev Transfers are only allowed by trusted contracts or after a time-based unlock 14 | abstract contract WDGSRC20 is ISRC20 { 15 | /*////////////////////////////////////////////////////////////// 16 | METADATA STORAGE 17 | //////////////////////////////////////////////////////////////*/ 18 | string public name; 19 | string public symbol; 20 | uint8 public immutable decimals; 21 | 22 | /*////////////////////////////////////////////////////////////// 23 | ERC20 STORAGE 24 | //////////////////////////////////////////////////////////////*/ 25 | // All storage variables that will be mutated must be confidential to 26 | // preserve functional privacy. 27 | suint256 internal totalSupply; 28 | mapping(saddress => suint256) internal balance; 29 | mapping(saddress => mapping(saddress => suint256)) internal allowance; 30 | 31 | /// @notice Duration in blocks before public transfers are enabled 32 | /// @dev After this block height, transfers become permissionless 33 | suint256 transferUnlockTime; 34 | uint256 public constant BLOCKS_PER_EPOCH = 7200; // about a day 35 | 36 | /*////////////////////////////////////////////////////////////// 37 | CONSTRUCTOR 38 | //////////////////////////////////////////////////////////////*/ 39 | constructor(string memory _name, string memory _symbol, uint8 _decimals) { 40 | name = _name; 41 | symbol = _symbol; 42 | decimals = _decimals; 43 | } 44 | 45 | /*////////////////////////////////////////////////////////////// 46 | SRC20 LOGIC 47 | Includes Transfer Restrictions 48 | //////////////////////////////////////////////////////////////*/ 49 | 50 | /// @notice Retrieves the caller's token balance 51 | /// @dev Only callable by whitelisted addresses or after unlock time 52 | /// @return Current balance of the caller 53 | function balanceOf() public view virtual whitelisted returns (uint256) { 54 | return uint256(balance[saddress(msg.sender)]); 55 | } 56 | 57 | function trustedBalanceOf(saddress account) public view virtual returns (uint256) { 58 | require(isTrusted(), "Only trusted addresses can call this function"); 59 | return uint256(balance[account]); 60 | } 61 | 62 | /// @notice Approves another address to spend tokens 63 | /// @param spender Address to approve 64 | /// @param amount Amount of tokens to approve 65 | /// @return success Always returns true 66 | function approve(saddress spender, suint256 amount) public virtual returns (bool) { 67 | allowance[saddress(msg.sender)][spender] = amount; 68 | return true; 69 | } 70 | 71 | /// @notice Transfers tokens to another address 72 | /// @dev Only callable by whitelisted addresses or after unlock time 73 | /// @param to Recipient address 74 | /// @param amount Amount to transfer 75 | /// @return success Always returns true 76 | function transfer(saddress to, suint256 amount) public virtual whitelisted returns (bool) { 77 | // msg.sender is public information, casting to saddress below doesn't change this 78 | balance[saddress(msg.sender)] -= amount; 79 | unchecked { 80 | balance[to] += amount; 81 | } 82 | return true; 83 | } 84 | 85 | /// @notice Transfers tokens on behalf of another address 86 | /// @dev Only callable by whitelisted addresses or after unlock time 87 | /// @dev Trusted contracts can transfer unlimited amounts without approval 88 | /// @param from Source address 89 | /// @param to Destination address 90 | /// @param amount Amount to transfer 91 | /// @return success Always returns true 92 | function transferFrom(saddress from, saddress to, suint256 amount) public virtual whitelisted returns (bool) { 93 | suint256 allowed = allowance[from][saddress(msg.sender)]; // Saves gas for limited approvals. 94 | if (isTrusted()) { 95 | allowed = suint256(type(uint256).max); 96 | } 97 | 98 | if (allowed != suint256(type(uint256).max)) { 99 | allowance[from][saddress(msg.sender)] = allowed - amount; 100 | } 101 | 102 | balance[from] -= amount; 103 | unchecked { 104 | balance[to] += amount; 105 | } 106 | return true; 107 | } 108 | 109 | /// @notice Creates new tokens 110 | /// @dev Only callable by trusted contracts 111 | /// @param to Recipient of the minted tokens 112 | /// @param amount Amount to mint 113 | function mint(saddress to, suint256 amount) public virtual { 114 | require(isTrusted()); 115 | totalSupply += amount; 116 | unchecked { 117 | balance[to] += amount; 118 | } 119 | } 120 | 121 | /// @notice Destroys tokens 122 | /// @dev Only callable by trusted contracts 123 | /// @param from Address to burn from 124 | /// @param amount Amount to burn 125 | function burn(saddress from, suint256 amount) public virtual { 126 | require(isTrusted(), "Not authorized to burn"); 127 | require(suint256(balanceOf()) >= amount, "Insufficient balance to burn"); 128 | totalSupply -= amount; 129 | balance[from] -= amount; 130 | } 131 | 132 | /*////////////////////////////////////////////////////////////// 133 | Trusted Address Logic 134 | //////////////////////////////////////////////////////////////*/ 135 | 136 | address public depinServiceAddress; 137 | address public AMMAddress; 138 | 139 | function getDepinServiceAddress() public view returns (address) { 140 | return depinServiceAddress; 141 | } 142 | 143 | /// @notice Sets the DePIN service contract address 144 | /// @dev Can only be set once 145 | /// @param _depinServiceAddress Address of the DePIN service contract 146 | function setDepinServiceAddress(address _depinServiceAddress) external { 147 | require(depinServiceAddress == address(0), "Address already set"); 148 | depinServiceAddress = _depinServiceAddress; 149 | } 150 | 151 | function setAMMAddress(address _AMMAddress) external { 152 | require(AMMAddress == address(0), "AMM address already set"); 153 | AMMAddress = _AMMAddress; 154 | } 155 | 156 | /// @notice Checks if caller is a trusted contract 157 | /// @return True if caller is either the DePIN service or AMM contract 158 | function isTrusted() public view returns (bool) { 159 | return msg.sender == depinServiceAddress || msg.sender == AMMAddress; 160 | } 161 | 162 | /// @notice Sets the time period before whitelisted actions are enabled 163 | // for all addresses. Resets every epoch. 164 | /// @dev Only callable by the trusted DePIN service contract 165 | /// @param _transferUnlockTime Number of blocks within an epoch before transfers are allowed 166 | function setTransferUnlockTime(suint256 _transferUnlockTime) external { 167 | require(msg.sender == depinServiceAddress, "Not authorized to set unlock time"); 168 | transferUnlockTime = _transferUnlockTime; 169 | } 170 | 171 | /// @notice Restricts function access to trusted contracts or after unlock time 172 | /// @dev Used as a modifier for transfer-related functions, all addresses are whitelisted after unlock period 173 | modifier whitelisted() { 174 | require( 175 | isTrusted() || suint256(block.number) > transferUnlockTime, "Only trusted addresses can call this function" 176 | ); 177 | _; 178 | } 179 | 180 | //////////////////////////////////////////////////////////////*/ 181 | } 182 | -------------------------------------------------------------------------------- /level/test/AMM.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT License 2 | pragma solidity ^0.8.13; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | 6 | import {InternalAMM} from "../src/InternalAMM.sol"; 7 | import {WDGSRC20} from "../src/WDGSRC20.sol"; 8 | import {Level} from "../src/Level.sol"; 9 | import {ISRC20} from "../src/SRC20.sol"; 10 | import {MockSRC20, MockWDGSRC20} from "./utils/MockSrc20.sol"; 11 | 12 | // todo: test only owner can swap, only owner can view, correct calcSwapInput 13 | 14 | contract TestAMM is Test { 15 | address ammOwnerAddress = address(0x1); 16 | WDGSRC20 public WDG; 17 | MockSRC20 public USDC; 18 | InternalAMM public amm; 19 | 20 | function setUp() public { 21 | USDC = new MockSRC20("USDC", "USDC", 18); 22 | WDG = new MockWDGSRC20("WDG", "WDG", 18); 23 | 24 | vm.prank(ammOwnerAddress); 25 | amm = new InternalAMM(address(WDG), address(USDC)); 26 | 27 | WDG.setDepinServiceAddress(ammOwnerAddress); 28 | WDG.setAMMAddress(address(amm)); 29 | 30 | vm.prank(ammOwnerAddress); 31 | WDG.setTransferUnlockTime(suint(7100)); 32 | 33 | vm.prank(ammOwnerAddress); 34 | WDG.mint(saddress(ammOwnerAddress), suint(1000 ether)); 35 | vm.prank(ammOwnerAddress); 36 | USDC.mint(saddress(ammOwnerAddress), suint(1000 ether)); 37 | 38 | vm.prank(ammOwnerAddress); 39 | WDG.approve(saddress(amm), suint(1000 ether)); 40 | vm.prank(ammOwnerAddress); 41 | USDC.approve(saddress(amm), suint(1000 ether)); 42 | 43 | vm.prank(ammOwnerAddress); 44 | amm.addLiquidity(suint(1000 ether), suint(1000 ether), ammOwnerAddress); 45 | 46 | assertEq(amm.owner(), ammOwnerAddress, "Owner should be set"); 47 | } 48 | 49 | function testOnlyOwnerView() public { 50 | address user1 = address(0x2); 51 | vm.prank(ammOwnerAddress); 52 | WDG.mint(saddress(user1), suint(100)); 53 | 54 | vm.prank(user1); 55 | vm.expectRevert(); 56 | amm.calcSwapInput(address(USDC), suint(10)); 57 | } 58 | 59 | function testOnlyOwnerSwap() public { 60 | address user2 = address(0x3); 61 | vm.prank(ammOwnerAddress); 62 | WDG.mint(saddress(user2), suint(100)); 63 | 64 | vm.prank(user2); 65 | vm.expectRevert(); 66 | amm.swap(saddress(WDG), suint(10)); 67 | 68 | vm.prank(user2); 69 | vm.expectRevert(); 70 | amm.swapOut(saddress(USDC), suint(10)); 71 | } 72 | 73 | function testSwap() public { 74 | // check swap rate is correct 75 | vm.prank(ammOwnerAddress); 76 | WDG.mint(saddress(ammOwnerAddress), suint(250 ether)); 77 | 78 | vm.prank(ammOwnerAddress); 79 | amm.swap(saddress(WDG), suint(250 ether)); // should give back 20 usdc 80 | 81 | vm.prank(ammOwnerAddress); 82 | suint256 usdcBal = suint256(USDC.balanceOf()); 83 | assertTrue(usdcBal == suint256(200 ether), "Swap amount incorrect."); 84 | } 85 | 86 | function testSwapOut() public { 87 | vm.prank(ammOwnerAddress); 88 | WDG.mint(saddress(ammOwnerAddress), suint256(200)); 89 | 90 | vm.prank(ammOwnerAddress); 91 | amm.swapOut(saddress(USDC), suint256(100)); 92 | 93 | vm.prank(ammOwnerAddress); 94 | suint256 usdcBal = suint256(USDC.balanceOf()); 95 | assertTrue(usdcBal == suint256(100), "Does not swap to correct amount."); 96 | 97 | // must revert if not enough balance for swap. 98 | vm.prank(ammOwnerAddress); 99 | vm.expectRevert(); 100 | amm.swapOut(saddress(USDC), suint(101)); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /level/test/Level.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT License 2 | pragma solidity ^0.8.13; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | 6 | import {InternalAMM} from "../src/InternalAMM.sol"; 7 | import {WDGSRC20} from "../src/WDGSRC20.sol"; 8 | import {Level} from "../src/Level.sol"; 9 | import {ISRC20} from "../src/SRC20.sol"; 10 | import {MockSRC20, MockWDGSRC20} from "./utils/MockSrc20.sol"; 11 | 12 | contract TestInterface is Test { 13 | address oracleAddress = address(0x1); 14 | WDGSRC20 public WDG; 15 | MockSRC20 public USDC; 16 | Level public service; 17 | InternalAMM public amm; 18 | 19 | function setUp() public { 20 | USDC = new MockSRC20("USDC", "USDC", 18); 21 | WDG = new MockWDGSRC20("WDG", "WDG", 18); 22 | service = new Level(address(WDG), address(USDC), oracleAddress, suint(100), suint(7100)); 23 | 24 | amm = service.amm(); 25 | // set up the amm 26 | vm.prank(address(service)); 27 | WDG.mint(saddress(service), suint(1000 ether)); 28 | USDC.mint(saddress(service), suint(1000 ether)); 29 | 30 | vm.prank(address(service)); 31 | WDG.approve(saddress(amm), suint(1000 ether)); 32 | vm.prank(address(service)); 33 | USDC.approve(saddress(amm), suint(1000 ether)); 34 | 35 | vm.prank(address(service)); 36 | amm.addLiquidity(suint(1000 ether), suint(1000 ether), address(service)); // service balances are 0 37 | 38 | assertEq(amm.owner(), address(service), "Owner should be service"); 39 | } 40 | 41 | function testTransfer() public { 42 | vm.prank(address(amm)); 43 | uint256 ammBalanceInit = WDG.balanceOf(); 44 | 45 | vm.prank(address(service)); 46 | WDG.mint(saddress(service), suint(1 ether)); 47 | vm.prank(address(service)); 48 | WDG.transfer(saddress(amm), suint(1 ether)); 49 | 50 | vm.prank(address(amm)); 51 | uint256 ammBalanceFin = WDG.balanceOf(); 52 | 53 | assertEq(ammBalanceFin - ammBalanceInit, 1 ether, "Incorrect amount transferred."); 54 | } 55 | 56 | function testTransferFail() public { 57 | address user1 = address(0x2); 58 | vm.prank(address(service)); 59 | WDG.mint(saddress(user1), suint(100 ether)); 60 | vm.prank(user1); 61 | 62 | vm.expectRevert(); 63 | WDG.transfer(saddress(service), suint(20 ether)); 64 | } 65 | 66 | function testPayForService() public { 67 | address user2 = address(0x3); 68 | // check if wdg reserve changed 69 | vm.prank(address(service)); 70 | uint256 initWdgReserve = WDG.trustedBalanceOf(saddress(amm)); 71 | 72 | USDC.mint(saddress(user2), suint(20)); 73 | vm.prank(user2); 74 | USDC.approve(saddress(service), suint(20)); 75 | 76 | vm.prank(user2); 77 | service.payForService(suint(20)); // check balance decreased 78 | 79 | vm.prank(address(service)); // 80 | uint256 finWdgReserve = WDG.trustedBalanceOf(saddress(amm)); 81 | 82 | assertTrue(initWdgReserve > finWdgReserve, "Burn unsuccessful."); 83 | 84 | vm.prank(user2); 85 | vm.expectRevert(); // not enough balance 86 | service.payForService(suint(20)); 87 | } 88 | 89 | function testAllocateReward() public { 90 | vm.prank(oracleAddress); 91 | // allocating reward to service to bypass whitelisting 92 | service.allocateReward(saddress(service), suint(20 ether)); 93 | 94 | vm.prank(address(service)); 95 | uint256 bal = WDG.balanceOf(); 96 | 97 | assertEq(bal, 20 ether, "Minted balances do not match"); 98 | } 99 | 100 | function testAllocateRewardFail() public { 101 | address user3 = address(0x4); 102 | vm.prank(user3); 103 | vm.expectRevert(); 104 | service.allocateReward(saddress(user3), suint(20 ether)); 105 | } 106 | 107 | function testoperatorWithdraw() public { 108 | address user5 = address(0x6); 109 | vm.prank(address(service)); 110 | WDG.mint(saddress(user5), suint256(200)); 111 | 112 | vm.prank(user5); 113 | uint256 wdCap = service.viewWithdrawalCap(); 114 | assertEq(wdCap, 100, "Incorrect withdrawal cap."); 115 | 116 | vm.prank(user5); 117 | service.operatorWithdraw(suint256(100)); 118 | 119 | vm.prank(user5); 120 | uint256 newBalance = USDC.balanceOf(); 121 | assertTrue(newBalance == 100, "Swap balance incorrect"); 122 | 123 | vm.prank(user5); 124 | vm.expectRevert(); 125 | service.viewWithdrawalCap(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /level/test/utils/MockSrc20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity >=0.8.0; 3 | 4 | import {SRC20} from "../../src/SRC20.sol"; 5 | import {WDGSRC20} from "../../src/WDGSRC20.sol"; 6 | 7 | contract MockSRC20 is SRC20 { 8 | constructor(string memory _name, string memory _symbol, uint8 _decimals) SRC20(_name, _symbol, _decimals) {} 9 | 10 | function mint(saddress to, suint256 value) public virtual { 11 | _mint(to, value); 12 | } 13 | 14 | function burn(saddress from, suint256 value) public virtual { 15 | _burn(from, value); 16 | } 17 | } 18 | 19 | contract MockWDGSRC20 is WDGSRC20 { 20 | constructor(string memory _name, string memory _symbol, uint8 _decimals) WDGSRC20(_name, _symbol, _decimals) {} 21 | } 22 | -------------------------------------------------------------------------------- /nibble/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Docs 11 | docs/ 12 | 13 | # Dotenv file 14 | .env 15 | 16 | node_modules/ 17 | -------------------------------------------------------------------------------- /nibble/.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "packages/contracts/lib/forge-std"] 2 | path = packages/contracts/lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std -------------------------------------------------------------------------------- /nibble/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /nibble/README.md: -------------------------------------------------------------------------------- 1 | # NIBBLE: Earn revenue share in your favorite restaurant 2 | 3 | ### Overview 4 | 5 | Nibble allows restaurants to mint tokens that entitle frequent customers to a portion of their revenue. 6 | 7 | **Problem:** 8 | Small businesses rely on strong local communities, but in cities with countless dining options, it's hard to stand out, attract an initial customer base, and maintain long-term engagement. Combined with thin margins emblematic of restaurants and price pressures from corporate competitors, enfranchsing our local businesses requires more than just consumer choice. 9 | 10 | **Insight:** 11 | By letting customers become partial owners, small businesses can foster a deeper sense of loyalty. When customers are invested, they spend more and are more likely to promote the restaurant through word-of-mouth and social media, increasing brand visibility. Additionally, they provide more valuable and constructive feedback, as they want the business to succeed, ultimately leading to better service, menu improvements, and a stronger community around the restaurant. 12 | 13 | **Solution:** 14 | Offer a platform where customer loyalty translates into a share of the restaurant's on-chain, encrypted revenue. 15 | -------------------------------------------------------------------------------- /nibble/foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] -------------------------------------------------------------------------------- /nibble/src/ISRC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.27; 3 | 4 | /* 5 | * Assumption: 6 | * The custom types `saddress` and `suint256` are defined elsewhere. 7 | * They are identical in behavior to address and uint256 respectively, 8 | * but signal that the underlying data is stored privately. 9 | */ 10 | 11 | /*////////////////////////////////////////////////////////////// 12 | // ISRC20 Interface 13 | //////////////////////////////////////////////////////////////*/ 14 | 15 | interface ISRC20 { 16 | /*////////////////////////////////////////////////////////////// 17 | EVENTS 18 | //////////////////////////////////////////////////////////////*/ 19 | // event Transfer(address indexed from, address indexed to, uint256 amount); 20 | // event Approval(address indexed owner, address indexed spender, uint256 amount); 21 | 22 | /*////////////////////////////////////////////////////////////// 23 | METADATA FUNCTIONS 24 | //////////////////////////////////////////////////////////////*/ 25 | function name() external view returns (string memory); 26 | function symbol() external view returns (string memory); 27 | function totalSupply() external view returns (uint256); 28 | function decimals() external view returns (uint8); 29 | 30 | /*////////////////////////////////////////////////////////////// 31 | ERC20 FUNCTIONS 32 | //////////////////////////////////////////////////////////////*/ 33 | function balanceOf() external view returns (uint256); 34 | function transfer(saddress to, suint256 amount) external returns (bool); 35 | // owner passed in as msg.sender via signedRead 36 | function allowance(saddress spender) external view returns (uint256); 37 | function approve(saddress spender, suint256 amount) external returns (bool); 38 | function transferFrom(saddress from, saddress to, suint256 amount) external returns (bool); 39 | } 40 | -------------------------------------------------------------------------------- /nibble/src/Nibble.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT License 2 | pragma solidity ^0.8.13; 3 | 4 | /** 5 | * @title Restaurant Revenue Sharing and Token System 6 | * @dev This contract allows restaurants to register, mint tokens, track customer spending, 7 | * and facilitate revenue sharing through token redemption. 8 | */ 9 | import {Rewards20} from "./Rewards20.sol"; 10 | 11 | 12 | /* The current rewards distribution for customers: token rewards are calculated across ETH spend and pre-existing holdings. 13 | Specifically, through the Rewards20 mint function, when rewards are minted user recieve a boost based on their existing balance 14 | This rewards customers for spending early and often, as repeated spends will grow 15 | faster than infrequent, larger spends. It also encourages holding of tokens rather than immediately cashing out. 16 | */ 17 | 18 | contract Nibble { 19 | /// @notice The total number of registered restaurants. 20 | uint256 public restaurantCount; 21 | 22 | /// @notice Maps a restaurant (owner) address to its respective Rewards20 token contract address. 23 | // mapping(restaurant (owner) address => restaurant's Rewards20 token) 24 | mapping(address => address) public restaurantsTokens; 25 | 26 | /// @dev Maps a restaurant address to its total accumulated revenue. 27 | // mapping(restauraunt address => total revenue) 28 | mapping(address => suint256) internal restaurantTotalRevenue; 29 | 30 | /// @dev Tracks how much each customer has spent at a specific restaurant. 31 | // mapping(restaurant address => mapping(customer address => spend amount)) 32 | mapping(address => mapping(address => suint256)) internal customerSpend; 33 | 34 | /// @notice Emitted when a new restaurant registers and its token is created. 35 | /// @param Restaurant_ The address of the restaurant owner. 36 | /// @param tokenAddress The address of the newly created Rewards20 token contract. 37 | event Register(address Restaurant_, address tokenAddress); 38 | 39 | /// @notice Emitted when a consumer spends at a restaurant. 40 | /// @param Restaurant_ The address of the restaurant where the transaction occurred. 41 | /// @param Consumer_ The address of the consumer who spent money. 42 | event SpentAtRestaurant(address Restaurant_, address Consumer_); //Event of a user spending at a restaurant 43 | 44 | /// @dev Ensures the caller is a registered restaurant. 45 | /// @param _restaurantAddress The address to check. 46 | modifier reqIsRestaurant(address _restaurantAddress) { 47 | if (restaurantsTokens[_restaurantAddress] == address(0)) { 48 | revert("restaurant is not registered"); 49 | } 50 | _; 51 | } 52 | 53 | constructor() {} 54 | 55 | /** 56 | * @notice Registers a new restaurant and mints an associated token. 57 | * @dev Assigns a unique Rewards20 token to the restaurant and updates the count. 58 | * @param name_ The name of the restaurant token. 59 | * @param symbol_ The symbol of the restaurant token. 60 | */ 61 | function registerRestaurant(string calldata name_, string calldata symbol_) public { 62 | //This is a sample - token distribution should ideally be automated around user spend 63 | //events to give larger portions of the tokens to early/regular spenders, while maintaining 64 | //a token pool for the restaurant. Currently, the restaurant has to manually handle distribution. 65 | 66 | if (restaurantsTokens[msg.sender] != address(0)) { 67 | revert("restaurant already registered"); 68 | } 69 | 70 | Rewards20 token = new Rewards20(name_, symbol_, 18, saddress(msg.sender), suint(1e24)); 71 | restaurantsTokens[msg.sender] = address(token); 72 | 73 | restaurantCount++; 74 | 75 | emit Register(msg.sender, address(token)); 76 | } 77 | 78 | /** 79 | * @notice Allows a customer to make a payment at a restaurant. 80 | * @dev Updates revenue tracking and mints corresponding tokens to the consumer. 81 | * @param restaurant_ The address of the restaurant where payment is made. 82 | */ 83 | function spendAtRestaurant(address restaurant_) public payable reqIsRestaurant(restaurant_) { 84 | restaurantTotalRevenue[restaurant_] = restaurantTotalRevenue[restaurant_] + suint256(msg.value); 85 | customerSpend[restaurant_][msg.sender] = customerSpend[restaurant_][msg.sender] + suint256(msg.value); 86 | 87 | // Calculate the number of tokens to mint. 88 | // Here we assume a 1:1 ratio between wei paid and tokens minted. 89 | // You can adjust the conversion factor as needed. 90 | uint256 tokenAmount = msg.value; 91 | 92 | // Mint tokens directly to msg.sender. 93 | // We assume that restaurantTokens[restaurant_] returns the Rewards20 token contract 94 | // associated with this restaurant. 95 | 96 | Rewards20 token = Rewards20(restaurantsTokens[restaurant_]); 97 | token.mint(saddress(msg.sender), suint256(tokenAmount)); 98 | 99 | emit SpentAtRestaurant(restaurant_, msg.sender); 100 | } 101 | 102 | /** 103 | * @notice Retrieves the total revenue accumulated by the restaurant. 104 | * @dev Only callable by the restaurant itself. 105 | * @return The total revenue in suint256. 106 | */ 107 | function checkTotalSpendRestaurant() public view reqIsRestaurant(msg.sender) returns (uint256) { 108 | return uint256(restaurantTotalRevenue[msg.sender]); 109 | } 110 | 111 | /** 112 | * @notice Retrieves the total spending of a specific customer at the caller's restaurant. 113 | * @dev Only callable by the restaurant. 114 | * @param user_ The address of the customer. 115 | * @return The amount spent in suint256. 116 | */ 117 | function checkCustomerSpendRestaurant(address user_) public view reqIsRestaurant(msg.sender) returns (uint256) { 118 | return uint256(customerSpend[msg.sender][user_]); 119 | } 120 | 121 | /** 122 | * @notice Retrieves the caller's total spend at a specific restaurant. 123 | * @dev Only callable by a customer for a restaurant where they have spent. 124 | * @param restaurant_ The address of the restaurant. 125 | * @return The amount spent in suint256. 126 | */ 127 | function checkSpendCustomer(address restaurant_) public view reqIsRestaurant(restaurant_) returns (uint256) { 128 | return uint256(customerSpend[restaurant_][msg.sender]); 129 | } 130 | 131 | /** 132 | * @notice Allows a user to exchange restaurant tokens for a portion of the restaurant's revenue. 133 | * @dev Transfers tokens back to the restaurant and distributes a proportional revenue share. 134 | * @param restaurant_ The address of the restaurant where tokens are redeemed. 135 | * @param amount The amount of tokens to redeem, in suint256. 136 | */ 137 | function checkOut(address restaurant_, suint256 amount) public reqIsRestaurant(restaurant_) { 138 | address tokenAddress = restaurantsTokens[restaurant_]; // get the address of the restaurant's token 139 | Rewards20 token = Rewards20(tokenAddress); 140 | 141 | // decrease msg.sender's allowance by amount so they cannot double checkOut 142 | // note: reverts if amount is more than the user's allowance 143 | token.transferFrom(saddress(msg.sender), saddress(restaurant_), amount); 144 | 145 | // calculate the entitlement 146 | suint256 totalRev = restaurantTotalRevenue[restaurant_]; 147 | uint256 entitlement = uint256(amount * totalRev) / token.totalSupply(); 148 | 149 | // send the entitlement to the customer 150 | bool success = payable(msg.sender).send(uint256(entitlement)); 151 | if (!success) { 152 | revert("Payment Failed"); 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /nibble/src/Rewards20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.27; 3 | 4 | import {ISRC20} from "./ISRC20.sol"; 5 | 6 | 7 | /** 8 | * @title Rewards20 Token 9 | * @dev A customer rewards token implementing the ISRC20 standard. 10 | * 11 | * @notice The contract allows the owner to mint tokens at their discretion. 12 | * @dev When tokens are minted, the owner receives a boost based on their existing balance to reward loyalty. 13 | * @dev Rewards20 tokens are non-transferable, meaning loyalty rewards cannot be shared or pooled. 14 | */ 15 | 16 | /*////////////////////////////////////////////////////////////// 17 | // Rewards20 Contract 18 | //////////////////////////////////////////////////////////////*/ 19 | contract Rewards20 is ISRC20 { 20 | /*////////////////////////////////////////////////////////////// 21 | EVENTS 22 | //////////////////////////////////////////////////////////////*/ 23 | // Leaks information to public, will replace with encrypted events 24 | // event Transfer(address indexed from, address indexed to, uint256 amount); 25 | // event Approval( 26 | // address indexed owner, 27 | // address indexed spender, 28 | // uint256 amount 29 | // ); 30 | 31 | /*////////////////////////////////////////////////////////////// 32 | METADATA STORAGE 33 | //////////////////////////////////////////////////////////////*/ 34 | string public name; 35 | string public symbol; 36 | uint8 public immutable decimals; 37 | saddress internal mintTo; 38 | address public owner; 39 | 40 | /*////////////////////////////////////////////////////////////// 41 | ERC20 STORAGE 42 | //////////////////////////////////////////////////////////////*/ 43 | // All storage variables that will be mutated must be confidential to 44 | // preserve functional privacy. 45 | uint256 public totalSupply; 46 | mapping(saddress => suint256) internal balance; 47 | mapping(saddress => mapping(saddress => suint256)) internal _allowance; 48 | 49 | /*////////////////////////////////////////////////////////////// 50 | CONSTRUCTOR 51 | //////////////////////////////////////////////////////////////*/ 52 | constructor(string memory _name, string memory _symbol, uint8 _decimals, saddress mintTo_, suint256 supply_) { 53 | name = _name; 54 | symbol = _symbol; 55 | decimals = _decimals; 56 | _mint(mintTo_, supply_); 57 | mintTo = mintTo_; 58 | owner = msg.sender; 59 | } 60 | 61 | /*////////////////////////////////////////////////////////////// 62 | ERC20 LOGIC 63 | //////////////////////////////////////////////////////////////*/ 64 | function balanceOf() public view virtual returns (uint256) { 65 | return uint256(balance[saddress(msg.sender)]); 66 | } 67 | 68 | function approve(saddress spender, suint256 amount) public virtual returns (bool) { 69 | _allowance[saddress(msg.sender)][spender] = amount; 70 | // emit Approval(msg.sender, address(spender), uint256(amount)); 71 | return true; 72 | } 73 | 74 | function transfer(saddress to, suint256 amount) public virtual returns (bool) { 75 | //Prevents token transfer outside of OG restaurant 76 | if (to != mintTo && saddress(msg.sender) != mintTo) { 77 | revert("Non-transferable outside of Original Restaurant"); 78 | } 79 | 80 | // msg.sender is public information, casting to saddress below doesn't change this 81 | balance[saddress(msg.sender)] -= amount; 82 | unchecked { 83 | balance[to] += amount; 84 | } 85 | // emit Transfer(msg.sender, address(to), uint256(amount)); 86 | return true; 87 | } 88 | 89 | function transferFrom(saddress from, saddress to, suint256 amount) public virtual returns (bool) { 90 | //same as in above 91 | if (to != mintTo && from != mintTo) { 92 | revert("Non-transferable outside of Original Restaurant"); 93 | } 94 | 95 | if (msg.sender != owner) { 96 | suint256 allowed = _allowance[from][saddress(msg.sender)]; // Saves gas for limited approvals. 97 | if (allowed != suint256(type(uint256).max)) { 98 | if (amount > allowed) { 99 | revert("not enough allowance"); 100 | } 101 | _allowance[from][saddress(msg.sender)] = allowed - amount; 102 | } 103 | } 104 | 105 | balance[from] -= amount; 106 | unchecked { 107 | balance[to] += amount; 108 | } 109 | // emit Transfer(msg.sender, address(to), uint256(amount)); 110 | return true; 111 | } 112 | 113 | function mint(saddress to, suint256 amount) external { 114 | // For example, restrict minting so that only the owner can mint. 115 | require(msg.sender == owner, "Only owner can mint tokens"); 116 | suint256 customerBalance = balance[to]; 117 | if (uint256(customerBalance) == 0) { 118 | _mint(to, amount); 119 | } 120 | else{ 121 | _mint(to, amount * customerBalance); 122 | } 123 | } 124 | 125 | /*////////////////////////////////////////////////////////////// 126 | INTERNAL MINT/BURN LOGIC 127 | //////////////////////////////////////////////////////////////*/ 128 | function _mint(saddress to, suint256 amount) internal virtual { 129 | totalSupply += uint256(amount); 130 | unchecked { 131 | balance[to] += amount; 132 | } 133 | // emit Transfer(address(0), address(to), uint256(amount)); 134 | } 135 | 136 | function allowance(saddress spender) external view returns (uint256) { 137 | return uint256(_allowance[saddress(msg.sender)][spender]); 138 | } 139 | 140 | 141 | 142 | } 143 | -------------------------------------------------------------------------------- /riff/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Docs 11 | docs/ 12 | 13 | # Dotenv file 14 | .env 15 | -------------------------------------------------------------------------------- /riff/README.md: -------------------------------------------------------------------------------- 1 | # RIFF: Listen to a bonding curve 2 | 3 | ## Problem 4 | Trading experience on every Automated Market Maker (AMM) is the same, it's time for something radically different like Riff. 5 | 6 | ## Insight 7 | What if we used other senses like hearing to make calls? 8 | 9 | ## Solution 10 | Encrypt a bonding curve to create an asset with a price that no one can see. Let an AI violin be the only party that can see the price. The violin generates music whenever it sees price fluctuations. Now, instead of seeing a price chart, users need to listen to it. -------------------------------------------------------------------------------- /riff/foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | 6 | # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options 7 | -------------------------------------------------------------------------------- /riff/src/Riff.sol: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: UNLICENSED 3 | * 4 | * AMM that hides the price of quote asset until it's above some threshold. 5 | * 6 | */ 7 | pragma solidity ^0.8.13; 8 | 9 | import "solmate/tokens/ERC20.sol"; 10 | import "solmate/utils/FixedPointMathLib.sol"; 11 | import "solmate/utils/ReentrancyGuard.sol"; 12 | 13 | import "./ViolinCoin.sol"; 14 | 15 | /*////////////////////////////////////////////////////////////// 16 | // ViolinAMM Contract 17 | //////////////////////////////////////////////////////////////*/ 18 | 19 | contract Riff is ReentrancyGuard { 20 | /*////////////////////////////////////////////////////////////// 21 | // TOKEN STORAGE 22 | //////////////////////////////////////////////////////////////*/ 23 | ViolinCoin public baseAsset; 24 | ViolinCoin public quoteAsset; 25 | 26 | /*////////////////////////////////////////////////////////////// 27 | // AMM STORAGE 28 | //////////////////////////////////////////////////////////////*/ 29 | saddress adminAddress; 30 | 31 | saddress violinAddress; 32 | 33 | // Fixed point arithmetic unit 34 | suint256 wad; 35 | 36 | // Since the reserves are encrypted, people can't access 37 | // the price information until they swap 38 | suint256 baseReserve; 39 | suint256 quoteReserve; 40 | 41 | /*////////////////////////////////////////////////////////////// 42 | // MODIFIERS 43 | //////////////////////////////////////////////////////////////*/ 44 | 45 | /* 46 | * Only off-chain violin can call this function 47 | */ 48 | modifier onlyViolinListener() { 49 | require(saddress(msg.sender) == violinAddress, "You don't have violin access"); 50 | _; 51 | } 52 | 53 | /*////////////////////////////////////////////////////////////// 54 | CONSTRUCTOR 55 | //////////////////////////////////////////////////////////////*/ 56 | constructor( 57 | ViolinCoin _baseAsset, 58 | ViolinCoin _quoteAsset, 59 | uint256 _wad, 60 | address _adminAddress, 61 | address _violinAddress 62 | ) { 63 | baseAsset = _baseAsset; 64 | quoteAsset = _quoteAsset; 65 | 66 | adminAddress = saddress(_adminAddress); 67 | violinAddress = saddress(_violinAddress); 68 | 69 | // Stored as suint256 for convenience. Not actually shielded bc it's a 70 | // transparent parameter in the constructor 71 | wad = suint256(_wad); 72 | } 73 | /*////////////////////////////////////////////////////////////// 74 | AMM LOGIC 75 | //////////////////////////////////////////////////////////////*/ 76 | 77 | /* 78 | * Add liquidity to pool. No LP rewards in this implementation. 79 | */ 80 | function addLiquidity(suint256 baseAmount, suint256 quoteAmount) external { 81 | baseReserve = baseReserve + baseAmount; 82 | quoteReserve = quoteReserve + quoteAmount; 83 | 84 | saddress ssender = saddress(msg.sender); 85 | saddress sthis = saddress(address(this)); 86 | baseAsset.transferFrom(ssender, sthis, baseAmount); 87 | quoteAsset.transferFrom(ssender, sthis, quoteAmount); 88 | } 89 | 90 | /* 91 | * Wrapper around swap so calldata for trade looks the same regardless of 92 | * direction. 93 | */ 94 | function swap(suint256 baseIn, suint256 quoteIn) public nonReentrant { 95 | // After listening to the music, the swapper can call this function to swap the assets, 96 | // then the price gets revealed to the swapper 97 | 98 | suint256 baseOut; 99 | suint256 quoteOut; 100 | 101 | (baseOut, baseReserve, quoteReserve) = _swap(baseAsset, quoteAsset, baseReserve, quoteReserve, baseIn); 102 | (quoteOut, quoteReserve, baseReserve) = _swap(quoteAsset, baseAsset, quoteReserve, baseReserve, quoteIn); 103 | } 104 | 105 | /* 106 | * Swap for cfAMM. No fees. 107 | */ 108 | function _swap(ViolinCoin tokenIn, ViolinCoin tokenOut, suint256 reserveIn, suint256 reserveOut, suint256 amountIn) 109 | internal 110 | returns (suint256 amountOut, suint256 reserveInNew, suint256 reserveOutNew) 111 | { 112 | suint256 numerator = mulDivDown(reserveOut, amountIn, wad); 113 | suint256 denominator = reserveIn + amountIn; 114 | amountOut = mulDivDown(numerator, wad, denominator); 115 | 116 | reserveInNew = reserveIn + amountIn; 117 | reserveOutNew = reserveOut - amountOut; 118 | 119 | saddress ssender = saddress(msg.sender); 120 | saddress sthis = saddress(address(this)); 121 | tokenIn.transferFrom(ssender, sthis, amountIn); 122 | tokenOut.transfer(ssender, amountOut); 123 | } 124 | 125 | /* 126 | * Returns price of quote asset. 127 | */ 128 | function getPrice() external view onlyViolinListener returns (uint256 price) { 129 | return uint256(_computePrice()); 130 | } 131 | 132 | /* 133 | * Compute price of quote asset. 134 | */ 135 | function _computePrice() internal view returns (suint256 price) { 136 | price = mulDivDown(baseReserve, wad, quoteReserve); 137 | } 138 | 139 | /* 140 | * For wad math. 141 | */ 142 | function mulDivDown(suint256 x, suint256 y, suint256 denominator) internal pure returns (suint256 z) { 143 | require( 144 | denominator != suint256(0) && (y == suint256(0) || x <= suint256(type(uint256).max) / y), 145 | "Overflow or division by zero" 146 | ); 147 | z = (x * y) / denominator; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /riff/src/SRC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.13; 3 | 4 | /*////////////////////////////////////////////////////////////// 5 | // ISRC20 Interface 6 | //////////////////////////////////////////////////////////////*/ 7 | 8 | interface ISRC20 { 9 | /*////////////////////////////////////////////////////////////// 10 | METADATA FUNCTIONS 11 | //////////////////////////////////////////////////////////////*/ 12 | function name() external view returns (string memory); 13 | function symbol() external view returns (string memory); 14 | function decimals() external view returns (uint8); 15 | 16 | /*////////////////////////////////////////////////////////////// 17 | ERC20 FUNCTIONS 18 | //////////////////////////////////////////////////////////////*/ 19 | function balanceOf() external view returns (uint256); 20 | function approve(saddress spender, suint256 amount) external returns (bool); 21 | function transfer(saddress to, suint256 amount) external returns (bool); 22 | function transferFrom(saddress from, saddress to, suint256 amount) external returns (bool); 23 | } 24 | 25 | /*////////////////////////////////////////////////////////////// 26 | // SRC20 Contract 27 | //////////////////////////////////////////////////////////////*/ 28 | 29 | abstract contract SRC20 is ISRC20 { 30 | /*////////////////////////////////////////////////////////////// 31 | METADATA STORAGE 32 | //////////////////////////////////////////////////////////////*/ 33 | string public name; 34 | string public symbol; 35 | uint8 public immutable decimals; 36 | 37 | /*////////////////////////////////////////////////////////////// 38 | ERC20 STORAGE 39 | //////////////////////////////////////////////////////////////*/ 40 | // All storage variables that will be mutated must be confidential to 41 | // preserve functional privacy. 42 | suint256 internal totalSupply; 43 | mapping(saddress => suint256) internal balance; 44 | mapping(saddress => mapping(saddress => suint256)) internal allowance; 45 | 46 | /*////////////////////////////////////////////////////////////// 47 | CONSTRUCTOR 48 | //////////////////////////////////////////////////////////////*/ 49 | constructor(string memory _name, string memory _symbol, uint8 _decimals) { 50 | name = _name; 51 | symbol = _symbol; 52 | decimals = _decimals; 53 | } 54 | 55 | /*////////////////////////////////////////////////////////////// 56 | ERC20 LOGIC 57 | //////////////////////////////////////////////////////////////*/ 58 | function balanceOf() public view virtual returns (uint256) { 59 | return uint256(balance[saddress(msg.sender)]); 60 | } 61 | 62 | function approve(saddress spender, suint256 amount) public virtual returns (bool) { 63 | allowance[saddress(msg.sender)][spender] = amount; 64 | return true; 65 | } 66 | 67 | function transfer(saddress to, suint256 amount) public virtual returns (bool) { 68 | // msg.sender is public information, casting to saddress below doesn't change this 69 | balance[saddress(msg.sender)] -= amount; 70 | unchecked { 71 | balance[to] += amount; 72 | } 73 | return true; 74 | } 75 | 76 | function transferFrom(saddress from, saddress to, suint256 amount) public virtual returns (bool) { 77 | suint256 allowed = allowance[from][saddress(msg.sender)]; // Saves gas for limited approvals. 78 | if (allowed != suint256(type(uint256).max)) { 79 | allowance[from][saddress(msg.sender)] = allowed - amount; 80 | } 81 | 82 | balance[from] -= amount; 83 | unchecked { 84 | balance[to] += amount; 85 | } 86 | return true; 87 | } 88 | 89 | /*////////////////////////////////////////////////////////////// 90 | INTERNAL MINT/BURN LOGIC 91 | //////////////////////////////////////////////////////////////*/ 92 | function _mint(saddress to, suint256 amount) internal virtual { 93 | totalSupply += amount; 94 | unchecked { 95 | balance[to] += amount; 96 | } 97 | } 98 | 99 | function _burn(saddress to, suint256 amount) internal virtual { 100 | totalSupply -= amount; 101 | balance[to] -= amount; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /riff/src/ViolinCoin.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.13; 3 | 4 | import {SRC20, ISRC20} from "./SRC20.sol"; 5 | 6 | /*////////////////////////////////////////////////////////////// 7 | // IViolinCoin Interface 8 | //////////////////////////////////////////////////////////////*/ 9 | 10 | // IViolinCoin extends ISRC20 by adding the mint function. 11 | interface IViolinCoin is ISRC20 { 12 | function mint(saddress to, suint256 amount) external; 13 | } 14 | /*////////////////////////////////////////////////////////////// 15 | // ViolinCoin Contract 16 | //////////////////////////////////////////////////////////////*/ 17 | 18 | contract ViolinCoin is SRC20, IViolinCoin { 19 | address public owner; 20 | 21 | constructor(address _owner, string memory _name, string memory _symbol, uint8 _decimals) 22 | SRC20(_name, _symbol, _decimals) 23 | { 24 | owner = _owner; 25 | } 26 | 27 | modifier onlyOwner() { 28 | require(msg.sender == owner, "Must be owner"); 29 | _; 30 | } 31 | 32 | /// @notice Mints new tokens to the specified address. 33 | function mint(saddress to, suint256 amount) public onlyOwner { 34 | _mint(to, amount); 35 | } 36 | 37 | /// @notice Returns the balance of msg.sender. 38 | function balanceOf() public view override(ISRC20, SRC20) returns (uint256) { 39 | return super.balanceOf(); 40 | } 41 | 42 | /// @notice Transfers tokens to another address. 43 | function transfer(saddress to, suint256 amount) public override(ISRC20, SRC20) returns (bool) { 44 | return super.transfer(to, amount); 45 | } 46 | 47 | /// @notice Transfers tokens from one address to another. 48 | function transferFrom(saddress from, saddress to, suint256 amount) public override(ISRC20, SRC20) returns (bool) { 49 | return super.transferFrom(from, to, amount); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /riff/test/Riff.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | import "solmate/tokens/ERC20.sol"; 5 | import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; 6 | 7 | import "../src/Riff.sol"; 8 | import {Test, console} from "forge-std/Test.sol"; 9 | 10 | /*////////////////////////////////////////////////////////////// 11 | // ViolinAMMTest Contract 12 | //////////////////////////////////////////////////////////////*/ 13 | contract ViolinAMMTest is Test { 14 | /*////////////////////////////////////////////////////////////// 15 | // AMM STORAGE 16 | //////////////////////////////////////////////////////////////*/ 17 | Riff public amm; 18 | 19 | /*////////////////////////////////////////////////////////////// 20 | // TOKEN STORAGE 21 | //////////////////////////////////////////////////////////////*/ 22 | ViolinCoin baseAsset; 23 | ViolinCoin quoteAsset; 24 | 25 | /*////////////////////////////////////////////////////////////// 26 | // AMM STORAGE 27 | //////////////////////////////////////////////////////////////*/ 28 | address testAdmin = address(0xabcd); 29 | 30 | address constant violinAddress = address(0x123); 31 | 32 | uint256 constant WAD = 1e18; 33 | uint8 constant WAD_ZEROS = 18; 34 | 35 | address constant SWAPPER1_ADDR = address(123); 36 | address constant SWAPPER2_ADDR = address(456); 37 | 38 | address constant NON_LISTENER_ADDR = address(789); 39 | 40 | /*////////////////////////////////////////////////////////////// 41 | // SETUP 42 | //////////////////////////////////////////////////////////////*/ 43 | function setUp() public { 44 | baseAsset = new ViolinCoin(address(this), "Circle", "USDC", 18); 45 | quoteAsset = new ViolinCoin(address(this), "Chainlink", "LINK", 18); 46 | 47 | // Start with pool price 1 LINK = 20 USDC 48 | amm = new Riff(ViolinCoin(address(baseAsset)), ViolinCoin(address(quoteAsset)), WAD, testAdmin, violinAddress); 49 | baseAsset.mint(saddress(address(this)), suint256(200000 * WAD)); 50 | quoteAsset.mint(saddress(address(this)), suint256(10000 * WAD)); 51 | baseAsset.approve(saddress(address(amm)), suint256(200000 * WAD)); 52 | quoteAsset.approve(saddress(address(amm)), suint256(10000 * WAD)); 53 | amm.addLiquidity(suint256(200000 * WAD), suint256(10000 * WAD)); 54 | 55 | // Two swappers start with 50k units of each, LINK and USDC 56 | baseAsset.mint(saddress(SWAPPER1_ADDR), suint256(50000 * WAD)); 57 | quoteAsset.mint(saddress(SWAPPER1_ADDR), suint256(50000 * WAD)); 58 | baseAsset.mint(saddress(SWAPPER2_ADDR), suint256(50000 * WAD)); 59 | quoteAsset.mint(saddress(SWAPPER2_ADDR), suint256(50000 * WAD)); 60 | 61 | // Another address that starts with 50k units of each, LINK and USDC 62 | baseAsset.mint(saddress(NON_LISTENER_ADDR), suint256(50000 * WAD)); 63 | quoteAsset.mint(saddress(NON_LISTENER_ADDR), suint256(50000 * WAD)); 64 | } 65 | 66 | /*////////////////////////////////////////////////////////////// 67 | // TEST CASES 68 | //////////////////////////////////////////////////////////////*/ 69 | 70 | /* 71 | * Test case for zero swap. If the user attempts to swap zero of both assets, 72 | * then there is no change in the price. 73 | */ 74 | function test_ZeroSwap() public { 75 | // Fetch the initial price as violin 76 | vm.startPrank(violinAddress); 77 | uint256 priceT0 = amm.getPrice(); 78 | vm.stopPrank(); 79 | 80 | // Now try a zero swap of base 81 | vm.startPrank(SWAPPER1_ADDR); 82 | baseAsset.approve(saddress(address(amm)), suint256(50000 * WAD)); 83 | amm.swap(suint256(0), suint256(0)); 84 | vm.stopPrank(); 85 | 86 | // Another user attempts a zero swap of quote 87 | vm.startPrank(SWAPPER2_ADDR); 88 | quoteAsset.approve(saddress(address(amm)), suint256(50000 * WAD)); 89 | amm.swap(suint256(0), suint256(0)); 90 | vm.stopPrank(); 91 | 92 | // Finally access the price as the violin 93 | vm.startPrank(violinAddress); 94 | assertEq(priceT0, amm.getPrice()); 95 | vm.stopPrank(); 96 | } 97 | 98 | /* 99 | * Test case for price going up after swap 100 | */ 101 | function test_PriceUp() public { 102 | vm.startPrank(violinAddress); 103 | uint256 priceT0 = amm.getPrice(); 104 | vm.stopPrank(); 105 | 106 | vm.startPrank(SWAPPER1_ADDR); 107 | uint256 swapperBaseT0 = baseAsset.balanceOf(); 108 | uint256 swapperQuoteT0 = quoteAsset.balanceOf(); 109 | 110 | baseAsset.approve(saddress(address(amm)), suint256(30000 * WAD)); 111 | amm.swap(suint256(30000 * WAD), suint256(0)); 112 | 113 | uint256 swapperBaseT1 = baseAsset.balanceOf(); 114 | uint256 swapperQuoteT1 = quoteAsset.balanceOf(); 115 | vm.stopPrank(); 116 | 117 | vm.startPrank(violinAddress); 118 | assertLt(priceT0, amm.getPrice()); 119 | vm.stopPrank(); 120 | 121 | assertGt(swapperBaseT0, swapperBaseT1); 122 | assertLt(swapperQuoteT0, swapperQuoteT1); 123 | } 124 | 125 | /* 126 | * Test case for price going down after swap. 127 | */ 128 | function test_PriceNetDown() public { 129 | vm.startPrank(violinAddress); 130 | uint256 priceT0 = amm.getPrice(); 131 | vm.stopPrank(); 132 | 133 | vm.startPrank(SWAPPER1_ADDR); 134 | baseAsset.approve(saddress(address(amm)), suint256(5000 * WAD)); 135 | amm.swap(suint256(5000 * WAD), suint256(0)); 136 | vm.stopPrank(); 137 | 138 | vm.startPrank(SWAPPER2_ADDR); 139 | quoteAsset.approve(saddress(address(amm)), suint256(5000 * WAD)); 140 | amm.swap(suint256(0), suint256(5000 * WAD)); 141 | vm.stopPrank(); 142 | 143 | vm.startPrank(violinAddress); 144 | assertGt(priceT0, amm.getPrice()); 145 | vm.stopPrank(); 146 | } 147 | 148 | /* 149 | * Test case for access control. Only the violin can call getPrice. 150 | */ 151 | function test_AccessControl() public { 152 | vm.startPrank(SWAPPER1_ADDR); 153 | vm.expectRevert("You don't have violin access"); 154 | amm.getPrice(); 155 | vm.stopPrank(); 156 | 157 | vm.startPrank(violinAddress); 158 | amm.getPrice(); 159 | vm.stopPrank(); 160 | } 161 | 162 | /* 163 | * Test case for swap access control. Any user can call swap 164 | */ 165 | function test_SwapAccessControl() public { 166 | vm.startPrank(SWAPPER1_ADDR); 167 | baseAsset.approve(saddress(address(amm)), suint256(5000 * WAD)); 168 | amm.swap(suint256(5000 * WAD), suint256(0)); 169 | vm.stopPrank(); 170 | 171 | vm.startPrank(SWAPPER2_ADDR); 172 | quoteAsset.approve(saddress(address(amm)), suint256(5000 * WAD)); 173 | amm.swap(suint256(0), suint256(5000 * WAD)); 174 | vm.stopPrank(); 175 | } 176 | 177 | /* 178 | * Test case for liquidity invariance. If two different listeners perform 179 | * swaps, the price should remain almost the same with some level of rounding 180 | * error. 181 | */ 182 | function test_LiquidityInvariance() public { 183 | vm.startPrank(address(this)); 184 | uint256 baseBefore = baseAsset.balanceOf(); 185 | uint256 quoteBefore = quoteAsset.balanceOf(); 186 | 187 | uint256 invariantBefore = baseBefore * quoteBefore; 188 | vm.stopPrank(); 189 | 190 | // Have two different listeners perform swaps 191 | vm.startPrank(SWAPPER1_ADDR); 192 | baseAsset.approve(saddress(address(amm)), suint256(50000 * WAD)); 193 | amm.swap(suint256(500 * WAD), suint256(0)); 194 | vm.stopPrank(); 195 | 196 | uint256 baseAfterSwp1 = baseAsset.balanceOf(); 197 | uint256 quoteAfterSwp1 = quoteAsset.balanceOf(); 198 | 199 | uint256 invariantAfterSwp1 = baseAfterSwp1 * quoteAfterSwp1; 200 | 201 | vm.startPrank(SWAPPER2_ADDR); 202 | baseAsset.approve(saddress(address(amm)), suint256(20000 * WAD)); 203 | amm.swap(suint256(200 * WAD), suint256(0)); 204 | vm.stopPrank(); 205 | 206 | vm.startPrank(address(this)); 207 | uint256 baseAfterSwp2 = baseAsset.balanceOf(); 208 | uint256 quoteAfterSwp2 = quoteAsset.balanceOf(); 209 | uint256 invariantAfterSwp2 = baseAfterSwp2 * quoteAfterSwp2; 210 | vm.stopPrank(); 211 | 212 | // Allow a small tolerance for rounding error. 213 | assertApproxEqRel(invariantBefore, invariantAfterSwp1, 1e16); 214 | assertApproxEqRel(invariantBefore, invariantAfterSwp2, 1e16); 215 | vm.stopPrank(); 216 | } 217 | } 218 | --------------------------------------------------------------------------------