diff --git a/.gitmodules b/.gitmodules index 5f6f19f..50eb38c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,10 @@ +[submodule "level/lib/forge-std"] + path = level/lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "level/lib/openzeppelin-contracts"] + path = level/lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts + [submodule "rent/lib/forge-std"] path = dwell/lib/forge-std url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md index fd58cf2..09127b6 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,12 @@ Each project in this repo lives in its own directory and includes a dedicated RE Below is a quick summary of each prototype currently available in this repository: -1. **`RIFF`** - Listen to a bonding curve. +1. **`LEVEL`** + Stabilize your DePIN service. 1. **`DWELL`** Pay your rent with a yield-bearing stablecoin. +1. **`RIFF`** + Listen to a bonding curve. ## Contributing diff --git a/level/.gitignore b/level/.gitignore new file mode 100644 index 0000000..85198aa --- /dev/null +++ b/level/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/level/README.md b/level/README.md new file mode 100644 index 0000000..2c18c63 --- /dev/null +++ b/level/README.md @@ -0,0 +1,17 @@ +# LEVEL Protocol + +## Overview + +LEVEL ensures stable network coverage for Decentralized Physical Infrastructure Networks (DePIN) independent of token price fluctuations during the initial bootstrapping phase. + +### Problem + +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. + +### Insight + +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. + +### Solution + +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. \ No newline at end of file diff --git a/level/foundry.toml b/level/foundry.toml new file mode 100644 index 0000000..25b918f --- /dev/null +++ b/level/foundry.toml @@ -0,0 +1,6 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/level/lib/forge-std b/level/lib/forge-std new file mode 160000 index 0000000..3b20d60 --- /dev/null +++ b/level/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 3b20d60d14b343ee4f908cb8079495c07f5e8981 diff --git a/level/lib/openzeppelin-contracts b/level/lib/openzeppelin-contracts new file mode 160000 index 0000000..acd4ff7 --- /dev/null +++ b/level/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit acd4ff74de833399287ed6b31b4debf6b2b35527 diff --git a/level/src/InternalAMM.sol b/level/src/InternalAMM.sol new file mode 100644 index 0000000..f8482ef --- /dev/null +++ b/level/src/InternalAMM.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: MIT License +pragma solidity ^0.8.13; + +import {ISRC20} from "./SRC20.sol"; +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; + +/// @title InternalAMM - Restricted Access Automated Market Maker +/// @notice A constant product AMM (x*y=k) that manages the liquidity pool for the DePIN price floor protocol +/// @dev Uses shielded data types (suint256) for privacy-preserving calculations +/// @dev All operations are restricted to the owner to so that even properties relevent to the caller, +// e.g. price, can't be observed until the owner wishes to reveal them +contract InternalAMM is Ownable(msg.sender) { + ISRC20 public token0; + ISRC20 public token1; + + suint256 reserve0; + suint256 reserve1; + + suint256 totalSupply; + mapping(address => suint256) balanceOf; + + /// @notice Initializes the AMM with token pair addresses + /// @param _token0 Address of the first token (typically WDG) + /// @param _token1 Address of the second token (typically USDC) + constructor(address _token0, address _token1) { + token0 = ISRC20(_token0); + token1 = ISRC20(_token1); + } + + function _mint(address _to, suint256 _amount) private { + balanceOf[_to] += _amount; + totalSupply += _amount; + } + + function _burn(address _from, suint256 _amount) private { + balanceOf[_from] -= _amount; + totalSupply -= _amount; + } + + function _update(suint256 _reserve0, suint256 _reserve1) internal { + reserve0 = _reserve0; + reserve1 = _reserve1; + } + + /// @notice Calculates amount of token0 received after a liquidation + /// @dev Uses constant product formula to determine price impact + /// @param _amount0 Amount of token0 to be liquidated + /// @return Average price of token0 denominated in token1 + function calcSwapOutput(suint256 _amount0) external view onlyOwner returns (uint256) { + /* XY = K + (X+dX)(Y-dY) = K + dY = Y - K / (X+dX) + dY = YdX/(X+dX) = Y(1-X/(X+dX)) + + Return dY = dX * Y / (X+dX) + */ + + return uint256(reserve1 / (reserve0 / _amount0 + suint(1))); + } + + /// @notice Calculates required input amount for desired output + /// @dev Reverse calculation of constant product formula + /// @param _tokenOut Address of token to receive + /// @param _amount Desired output amount + /// @return amountIn of input token required to achieve desired output + function calcSwapInput(address _tokenOut, suint256 _amount) public view onlyOwner returns (uint256) { + /* XY = K + (X+dX)(Y-dY) = K + dY = Y - K / (X+dX) + dY = YdX/(X+dX) = Y(1-X/(X+dX)) + + Return dX = X dY / (Y-dY) + */ + suint256 amountIn = _tokenOut == address(token1) + ? reserve0 / (reserve1 / _amount - suint(1)) + : reserve1 / (reserve0 / _amount - suint(1)); + + return uint256(amountIn); + } + + /// @notice Adds liquidity to the AMM pool + /// @dev Maintains price ratio for existing pools + /// @param _amount0 Amount of token0 to add + /// @param _amount1 Amount of token1 to add + /// @param originalSender Address to receive LP tokens + function addLiquidity(suint256 _amount0, suint256 _amount1, address originalSender) external onlyOwner { + token0.transferFrom(saddress(msg.sender), saddress(this), _amount0); + token1.transferFrom(saddress(msg.sender), saddress(this), _amount1); + + if (reserve0 > suint256(0) || reserve1 > suint256(0)) { + require( + reserve0 * _amount1 == reserve1 * _amount0, //preserving price + "x / y != dx / dy" + ); + } + // if i wanted to put usdc into the pool, first swap until the ratios + + suint256 shares = totalSupply == suint(0) + ? _sqrt(_amount0 * _amount1) + : _min((_amount0 * totalSupply) / reserve0, (_amount1 * totalSupply) / reserve1); + + require(shares > suint256(0), "No shares to mint"); + _mint(originalSender, shares); + + // recalculate k + _update(suint256(token0.balanceOf()), suint256(token1.balanceOf())); + } + + /// @notice Removes liquidity from the AMM pool + /// @dev Burns LP tokens and returns underlying assets + /// @param _shares Amount of LP tokens to burn + /// @param originalSender Address that owns the LP tokens + function removeLiquidity(suint256 _shares, address originalSender) external onlyOwner { + require(balanceOf[originalSender] > _shares, "Insufficient shares"); + suint256 amount0 = (_shares * reserve0) / totalSupply; + suint256 amount1 = (_shares * reserve1) / totalSupply; + require(amount0 > suint256(0) && amount1 > suint256(0), "amount0 or amount1 = 0"); + + _burn(originalSender, _shares); // burn LP shares + _update(reserve0 - amount0, reserve1 - amount1); + + token0.transfer(saddress(msg.sender), amount0); + token1.transfer(saddress(msg.sender), suint(amount1)); + } + + /// @notice Executes token swap using constant product formula + /// @dev Updates reserves after swap completion + /// @param _tokenIn Address of input token + /// @param _amountIn Amount of input token + function swap(saddress _tokenIn, suint256 _amountIn) external onlyOwner { + require(_amountIn > suint(0), "Invalid amount to swap"); + require(_tokenIn == saddress(token0) || _tokenIn == saddress(token1), "Invalid token"); + + bool isToken0 = _tokenIn == saddress(token0); + + (ISRC20 tokenIn, ISRC20 tokenOut, suint256 reserveIn, suint256 reserveOut) = + isToken0 ? (token0, token1, reserve0, reserve1) : (token1, token0, reserve1, reserve0); + + tokenIn.transferFrom(saddress(msg.sender), saddress(this), _amountIn); + + suint256 amountOut = reserveOut * _amountIn / (reserveIn + _amountIn); // still shielded + + tokenOut.approve(saddress(this), amountOut); + tokenOut.transferFrom(saddress(this), saddress(msg.sender), amountOut); + + _update(suint256(token0.balanceOf()), suint256(token1.balanceOf())); + } + + /// @notice Executes token swap by taking token out of the pool. + /// This is ONLY called within operatorWithdraw, where the owed balance + /// is first transferred to the AMM. + /// @dev Updates reserves after swap completion. + function swapOut(saddress _tokenOut, suint256 _amountOut) external onlyOwner { + require(_tokenOut == saddress(token0) || _tokenOut == saddress(token1), "Invalid token"); + + bool isToken0 = _tokenOut == saddress(token0); + + (ISRC20 tokenOwed, ISRC20 tokenRm, suint256 reserveRm) = + isToken0 ? (token1, token0, reserve0) : (token0, token1, reserve1); + + require(_amountOut <= reserveRm, "Invalid amount to extract."); + + suint256 amountOwed = suint256(calcSwapInput(address(tokenRm), _amountOut)); + + tokenOwed.transferFrom(saddress(msg.sender), saddress(this), amountOwed); + + tokenRm.approve(saddress(this), _amountOut); + tokenRm.transferFrom(saddress(this), saddress(msg.sender), _amountOut); + + _update(suint256(token0.balanceOf()), suint256(token1.balanceOf())); + } + + /// @notice Calculates square root using binary search + /// @dev Used for initial LP token minting + /// @param y Value to find square root of + /// @return z Square root of input value + function _sqrt(suint256 y) private pure returns (suint256 z) { + if (y < suint256(3)) { + z = y; + suint256 x = y / suint256(2) + suint256(1); + while (x < z) { + z = x; + x = (y / x + x) / suint256(2); + } + } else if (y != suint256(0)) { + z = suint256(1); + } + } + + /// @notice Returns minimum of two values + /// @param x First value + /// @param y Second value + /// @return Smaller of the two inputs + function _min(suint256 x, suint256 y) private pure returns (suint256) { + return x <= y ? x : y; + } +} diff --git a/level/src/Level.sol b/level/src/Level.sol new file mode 100644 index 0000000..032915c --- /dev/null +++ b/level/src/Level.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT License +pragma solidity ^0.8.13; + +import {ISRC20} from "./SRC20.sol"; +import {WDGSRC20} from "./WDGSRC20.sol"; +import {InternalAMM} from "./InternalAMM.sol"; + +/// @title Level - DePIN Operator Reward Price Floor Protocol +/// @notice This contract implements a minimum price guarantee mechanism for DePIN operator rewards, +/// ensuring operators can liquidate their rewards at a guaranteed minimum price. +/// @dev Uses an AMM to provide price guarantees and implements epoch-based withdrawal limits +contract Level { + address public rewardOracle; + uint256 public constant BLOCKS_PER_EPOCH = 7200; // about a day + suint256 private maxWithdrawalPerEpoch; // the max usdc that can be withdrawn per epoch + + InternalAMM public amm; + WDGSRC20 public WDG; + ISRC20 public USDC; + + mapping(saddress => suint256) epochWithdrawalAmt; + mapping(saddress => suint256) lastWithdrawalEpoch; + + modifier onlyOracle() { + require(msg.sender == rewardOracle, "Only the oracle can call this function"); + _; + } + + /// @notice Initializes the price floor protocol + /// @param _wdg Address of the operator reward token (WDG) + /// @param _usdc Address of the stablecoin used for payments/withdrawals + /// @param _rewardOracle Address authorized to distribute operator rewards + /// @param _maxWithdrawalPerEpoch Maximum USDC that can be withdrawn per epoch to manage protocol liquidity + constructor( + address _wdg, + address _usdc, + address _rewardOracle, + suint256 _maxWithdrawalPerEpoch, + suint256 _transferUnlockTime + ) { + rewardOracle = _rewardOracle; + WDG = WDGSRC20(_wdg); + USDC = ISRC20(_usdc); + maxWithdrawalPerEpoch = _maxWithdrawalPerEpoch; + amm = new InternalAMM(_wdg, _usdc); + + // set the wdg trusted addresses + WDG.setDepinServiceAddress(address(this)); + WDG.setAMMAddress(address(amm)); + WDG.setTransferUnlockTime(_transferUnlockTime); + } + + /// @notice Processes user payments for DePIN services + /// @dev Payments are used to support the price guarantee through token buybacks + /// @param usdcAmount Amount of USDC to pay for services + function payForService(suint256 usdcAmount) public { + // transfer USDC from user to this contract + // it is assumed the transfer is approved before calling this function + USDC.transferFrom(saddress(msg.sender), saddress(this), usdcAmount); + + // user payments are distributed to token holders / operators + // through token buybacks in the AMM + _serviceBuyback(usdcAmount); + + // + // PLACEHOLDER + // normally business logic would go here + // but this is a dummy function + // + } + + /// @notice Internal buyback mechanism to support price guarantees + /// @dev Converts service payments to WDG tokens and burns them, supporting token value + /// @param usdcAmount Amount of USDC to use for buyback + function _serviceBuyback(suint256 usdcAmount) internal { + // 1) swap USDC into WDG through the AMM + USDC.approve(saddress(amm), usdcAmount); + amm.swap(saddress(USDC), usdcAmount); + // 2) and burn the WDG token that is swapped out + WDG.burn(saddress(this), suint(WDG.balanceOf())); // assumed there is no reason for this contract to have a WDG balance + } + + /// @notice Distributes reward tokens to operators for their services + /// @dev Only callable by the oracle which determines reward distribution + /// @param operator Address of the DePIN operator + /// @param amount Amount of WDG tokens to mint as reward + function allocateReward(saddress operator, suint256 amount) external onlyOracle { + WDG.mint(operator, amount); // double check this is the correct token + } + + /// @notice Checks operator's remaining withdrawal capacity for the current epoch + /// @dev Enforces epoch-based withdrawal limits to manage protocol liquidity + /// @dev TODO: Future versions should decouple withdrawal caps from token sales to allow + /// operators to manage their token exposure without affecting their withdrawal limits + /// @return Maximum amount of USDC that can currently be withdrawn in current epoch + function calcWithdrawalCap() internal returns (suint256) { + // reset the withdrawal cap if the user has not withdrawn in the current epoch + suint256 currentEpoch = suint(block.number) / suint(BLOCKS_PER_EPOCH); + if (currentEpoch > lastWithdrawalEpoch[saddress(msg.sender)]) { + epochWithdrawalAmt[saddress(msg.sender)] = suint(0); + lastWithdrawalEpoch[saddress(msg.sender)] = currentEpoch; + } else { + require(epochWithdrawalAmt[saddress(msg.sender)] == suint(0), "Already withdrawn this period."); + } + + suint256 usdcBalance = suint256(amm.calcSwapOutput(suint256(WDG.trustedBalanceOf(saddress(msg.sender))))); + return _min(maxWithdrawalPerEpoch, usdcBalance); + } + + /// @notice Returns the maximum amount of USDC an operator can currently withdraw + /// @dev Provides a view into the operator's withdrawal capacity for the current epoch + /// without modifying state. Useful for UIs and off-chain calculations. + /// @return The maximum amount of USDC that can be withdrawn in the current epoch, + /// limited by both the epoch withdrawal cap and the operator's WDG balance + function viewWithdrawalCap() public returns (uint256) { + return uint256(calcWithdrawalCap()); + } + + /// @notice Allows operators to liquidate their reward tokens at the guaranteed price + /// @dev Converts WDG to USDC through AMM at the protocol-guaranteed price + /// @param _amount Amount of USDC to withdraw + function operatorWithdraw(suint256 _amount) public { + suint256 withdrawalCap = calcWithdrawalCap(); // max usdc that user can withdraw + require(_amount <= withdrawalCap, "Overdrafting daily withdrawal limit or insufficient balance."); + + // calculate and swap amount of wdg for usdc + suint256 amountWdgIn = suint256(amm.calcSwapInput(address(USDC), _amount)); + WDG.transferFrom(saddress(msg.sender), saddress(this), amountWdgIn); + + amm.swapOut(saddress(USDC), _amount); // + USDC.transfer(saddress(msg.sender), suint(USDC.balanceOf())); + // USDC balance for this contract should be zero except during operatorWithdraw calls + epochWithdrawalAmt[saddress(msg.sender)] += _amount; + } + + /// @notice Utility function to return the minimum of two values + /// @param x First value + /// @param y Second value + /// @return Minimum of x and y + function _min(suint256 x, suint256 y) private pure returns (suint256) { + return x <= y ? x : y; + } +} diff --git a/level/src/SRC20.sol b/level/src/SRC20.sol new file mode 100644 index 0000000..8d2c77f --- /dev/null +++ b/level/src/SRC20.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.13; + +/*////////////////////////////////////////////////////////////// +// ISRC20 Interface +//////////////////////////////////////////////////////////////*/ + +interface ISRC20 { + /*////////////////////////////////////////////////////////////// + METADATA FUNCTIONS + //////////////////////////////////////////////////////////////*/ + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function decimals() external view returns (uint8); + + /*////////////////////////////////////////////////////////////// + ERC20 FUNCTIONS + //////////////////////////////////////////////////////////////*/ + function balanceOf() external view returns (uint256); + function approve(saddress spender, suint256 amount) external returns (bool); + function transfer(saddress to, suint256 amount) external returns (bool); + function transferFrom(saddress from, saddress to, suint256 amount) external returns (bool); +} + +/*////////////////////////////////////////////////////////////// +// SRC20 Contract +//////////////////////////////////////////////////////////////*/ + +abstract contract SRC20 is ISRC20 { + /*////////////////////////////////////////////////////////////// + METADATA STORAGE + //////////////////////////////////////////////////////////////*/ + string public name; + string public symbol; + uint8 public immutable decimals; + + /*////////////////////////////////////////////////////////////// + ERC20 STORAGE + //////////////////////////////////////////////////////////////*/ + // All storage variables that will be mutated must be confidential to + // preserve functional privacy. + suint256 internal totalSupply; + mapping(saddress => suint256) internal balance; + mapping(saddress => mapping(saddress => suint256)) internal allowance; + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + constructor(string memory _name, string memory _symbol, uint8 _decimals) { + name = _name; + symbol = _symbol; + decimals = _decimals; + } + + /*////////////////////////////////////////////////////////////// + ERC20 LOGIC + //////////////////////////////////////////////////////////////*/ + function balanceOf() public view virtual returns (uint256) { + return uint256(balance[saddress(msg.sender)]); + } + + function approve(saddress spender, suint256 amount) public virtual returns (bool) { + allowance[saddress(msg.sender)][spender] = amount; + return true; + } + + function transfer(saddress to, suint256 amount) public virtual returns (bool) { + // msg.sender is public information, casting to saddress below doesn't change this + balance[saddress(msg.sender)] -= amount; + unchecked { + balance[to] += amount; + } + return true; + } + + function transferFrom(saddress from, saddress to, suint256 amount) public virtual returns (bool) { + suint256 allowed = allowance[from][saddress(msg.sender)]; // Saves gas for limited approvals. + if (allowed != suint256(type(uint256).max)) { + allowance[from][saddress(msg.sender)] = allowed - amount; + } + + balance[from] -= amount; + unchecked { + balance[to] += amount; + } + return true; + } + + /*////////////////////////////////////////////////////////////// + INTERNAL MINT/BURN LOGIC + //////////////////////////////////////////////////////////////*/ + function _mint(saddress to, suint256 amount) internal virtual { + totalSupply += amount; + unchecked { + balance[to] += amount; + } + } + + function _burn(saddress to, suint256 amount) internal virtual { + totalSupply -= amount; + balance[to] -= amount; + } +} diff --git a/level/src/WDGSRC20.sol b/level/src/WDGSRC20.sol new file mode 100644 index 0000000..0a592d5 --- /dev/null +++ b/level/src/WDGSRC20.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.13; + +import {ISRC20} from "./SRC20.sol"; + +/*////////////////////////////////////////////////////////////// +// WDGSRC20 Contract +//////////////////////////////////////////////////////////////*/ + +/// @title WDGSRC20 - Privacy-Preserving Restricted Transfer Token +/// @notice An ERC20-like token implementation that uses shielded data types and restricts transfers +/// @dev Implements transfer restrictions and uses `saddress` and `suint256` types for privacy +/// @dev Transfers are only allowed by trusted contracts or after a time-based unlock +abstract contract WDGSRC20 is ISRC20 { + /*////////////////////////////////////////////////////////////// + METADATA STORAGE + //////////////////////////////////////////////////////////////*/ + string public name; + string public symbol; + uint8 public immutable decimals; + + /*////////////////////////////////////////////////////////////// + ERC20 STORAGE + //////////////////////////////////////////////////////////////*/ + // All storage variables that will be mutated must be confidential to + // preserve functional privacy. + suint256 internal totalSupply; + mapping(saddress => suint256) internal balance; + mapping(saddress => mapping(saddress => suint256)) internal allowance; + + /// @notice Duration in blocks before public transfers are enabled + /// @dev After this block height, transfers become permissionless + suint256 transferUnlockTime; + uint256 public constant BLOCKS_PER_EPOCH = 7200; // about a day + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + constructor(string memory _name, string memory _symbol, uint8 _decimals) { + name = _name; + symbol = _symbol; + decimals = _decimals; + } + + /*////////////////////////////////////////////////////////////// + SRC20 LOGIC + Includes Transfer Restrictions + //////////////////////////////////////////////////////////////*/ + + /// @notice Retrieves the caller's token balance + /// @dev Only callable by whitelisted addresses or after unlock time + /// @return Current balance of the caller + function balanceOf() public view virtual whitelisted returns (uint256) { + return uint256(balance[saddress(msg.sender)]); + } + + function trustedBalanceOf(saddress account) public view virtual returns (uint256) { + require(isTrusted(), "Only trusted addresses can call this function"); + return uint256(balance[account]); + } + + /// @notice Approves another address to spend tokens + /// @param spender Address to approve + /// @param amount Amount of tokens to approve + /// @return success Always returns true + function approve(saddress spender, suint256 amount) public virtual returns (bool) { + allowance[saddress(msg.sender)][spender] = amount; + return true; + } + + /// @notice Transfers tokens to another address + /// @dev Only callable by whitelisted addresses or after unlock time + /// @param to Recipient address + /// @param amount Amount to transfer + /// @return success Always returns true + function transfer(saddress to, suint256 amount) public virtual whitelisted returns (bool) { + // msg.sender is public information, casting to saddress below doesn't change this + balance[saddress(msg.sender)] -= amount; + unchecked { + balance[to] += amount; + } + return true; + } + + /// @notice Transfers tokens on behalf of another address + /// @dev Only callable by whitelisted addresses or after unlock time + /// @dev Trusted contracts can transfer unlimited amounts without approval + /// @param from Source address + /// @param to Destination address + /// @param amount Amount to transfer + /// @return success Always returns true + function transferFrom(saddress from, saddress to, suint256 amount) public virtual whitelisted returns (bool) { + suint256 allowed = allowance[from][saddress(msg.sender)]; // Saves gas for limited approvals. + if (isTrusted()) { + allowed = suint256(type(uint256).max); + } + + if (allowed != suint256(type(uint256).max)) { + allowance[from][saddress(msg.sender)] = allowed - amount; + } + + balance[from] -= amount; + unchecked { + balance[to] += amount; + } + return true; + } + + /// @notice Creates new tokens + /// @dev Only callable by trusted contracts + /// @param to Recipient of the minted tokens + /// @param amount Amount to mint + function mint(saddress to, suint256 amount) public virtual { + require(isTrusted()); + totalSupply += amount; + unchecked { + balance[to] += amount; + } + } + + /// @notice Destroys tokens + /// @dev Only callable by trusted contracts + /// @param from Address to burn from + /// @param amount Amount to burn + function burn(saddress from, suint256 amount) public virtual { + require(isTrusted(), "Not authorized to burn"); + require(suint256(balanceOf()) >= amount, "Insufficient balance to burn"); + totalSupply -= amount; + balance[from] -= amount; + } + + /*////////////////////////////////////////////////////////////// + Trusted Address Logic + //////////////////////////////////////////////////////////////*/ + + address public depinServiceAddress; + address public AMMAddress; + + function getDepinServiceAddress() public view returns (address) { + return depinServiceAddress; + } + + /// @notice Sets the DePIN service contract address + /// @dev Can only be set once + /// @param _depinServiceAddress Address of the DePIN service contract + function setDepinServiceAddress(address _depinServiceAddress) external { + require(depinServiceAddress == address(0), "Address already set"); + depinServiceAddress = _depinServiceAddress; + } + + function setAMMAddress(address _AMMAddress) external { + require(AMMAddress == address(0), "AMM address already set"); + AMMAddress = _AMMAddress; + } + + /// @notice Checks if caller is a trusted contract + /// @return True if caller is either the DePIN service or AMM contract + function isTrusted() public view returns (bool) { + return msg.sender == depinServiceAddress || msg.sender == AMMAddress; + } + + /// @notice Sets the time period before whitelisted actions are enabled + // for all addresses. Resets every epoch. + /// @dev Only callable by the trusted DePIN service contract + /// @param _transferUnlockTime Number of blocks within an epoch before transfers are allowed + function setTransferUnlockTime(suint256 _transferUnlockTime) external { + require(msg.sender == depinServiceAddress, "Not authorized to set unlock time"); + transferUnlockTime = _transferUnlockTime; + } + + /// @notice Restricts function access to trusted contracts or after unlock time + /// @dev Used as a modifier for transfer-related functions, all addresses are whitelisted after unlock period + modifier whitelisted() { + require( + isTrusted() || suint256(block.number) > transferUnlockTime, "Only trusted addresses can call this function" + ); + _; + } + + //////////////////////////////////////////////////////////////*/ +} diff --git a/level/test/AMM.t.sol b/level/test/AMM.t.sol new file mode 100644 index 0000000..37a968f --- /dev/null +++ b/level/test/AMM.t.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT License +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; + +import {InternalAMM} from "../src/InternalAMM.sol"; +import {WDGSRC20} from "../src/WDGSRC20.sol"; +import {Level} from "../src/Level.sol"; +import {ISRC20} from "../src/SRC20.sol"; +import {MockSRC20, MockWDGSRC20} from "./utils/MockSrc20.sol"; + +// todo: test only owner can swap, only owner can view, correct calcSwapInput + +contract TestAMM is Test { + address ammOwnerAddress = address(0x1); + WDGSRC20 public WDG; + MockSRC20 public USDC; + InternalAMM public amm; + + function setUp() public { + USDC = new MockSRC20("USDC", "USDC", 18); + WDG = new MockWDGSRC20("WDG", "WDG", 18); + + vm.prank(ammOwnerAddress); + amm = new InternalAMM(address(WDG), address(USDC)); + + WDG.setDepinServiceAddress(ammOwnerAddress); + WDG.setAMMAddress(address(amm)); + + vm.prank(ammOwnerAddress); + WDG.setTransferUnlockTime(suint(7100)); + + vm.prank(ammOwnerAddress); + WDG.mint(saddress(ammOwnerAddress), suint(1000 ether)); + vm.prank(ammOwnerAddress); + USDC.mint(saddress(ammOwnerAddress), suint(1000 ether)); + + vm.prank(ammOwnerAddress); + WDG.approve(saddress(amm), suint(1000 ether)); + vm.prank(ammOwnerAddress); + USDC.approve(saddress(amm), suint(1000 ether)); + + vm.prank(ammOwnerAddress); + amm.addLiquidity(suint(1000 ether), suint(1000 ether), ammOwnerAddress); + + assertEq(amm.owner(), ammOwnerAddress, "Owner should be set"); + } + + function testOnlyOwnerView() public { + address user1 = address(0x2); + vm.prank(ammOwnerAddress); + WDG.mint(saddress(user1), suint(100)); + + vm.prank(user1); + vm.expectRevert(); + amm.calcSwapInput(address(USDC), suint(10)); + } + + function testOnlyOwnerSwap() public { + address user2 = address(0x3); + vm.prank(ammOwnerAddress); + WDG.mint(saddress(user2), suint(100)); + + vm.prank(user2); + vm.expectRevert(); + amm.swap(saddress(WDG), suint(10)); + + vm.prank(user2); + vm.expectRevert(); + amm.swapOut(saddress(USDC), suint(10)); + } + + function testSwap() public { + // check swap rate is correct + vm.prank(ammOwnerAddress); + WDG.mint(saddress(ammOwnerAddress), suint(250 ether)); + + vm.prank(ammOwnerAddress); + amm.swap(saddress(WDG), suint(250 ether)); // should give back 20 usdc + + vm.prank(ammOwnerAddress); + suint256 usdcBal = suint256(USDC.balanceOf()); + assertTrue(usdcBal == suint256(200 ether), "Swap amount incorrect."); + } + + function testSwapOut() public { + vm.prank(ammOwnerAddress); + WDG.mint(saddress(ammOwnerAddress), suint256(200)); + + vm.prank(ammOwnerAddress); + amm.swapOut(saddress(USDC), suint256(100)); + + vm.prank(ammOwnerAddress); + suint256 usdcBal = suint256(USDC.balanceOf()); + assertTrue(usdcBal == suint256(100), "Does not swap to correct amount."); + + // must revert if not enough balance for swap. + vm.prank(ammOwnerAddress); + vm.expectRevert(); + amm.swapOut(saddress(USDC), suint(101)); + } +} diff --git a/level/test/Level.t.sol b/level/test/Level.t.sol new file mode 100644 index 0000000..919996e --- /dev/null +++ b/level/test/Level.t.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT License +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; + +import {InternalAMM} from "../src/InternalAMM.sol"; +import {WDGSRC20} from "../src/WDGSRC20.sol"; +import {Level} from "../src/Level.sol"; +import {ISRC20} from "../src/SRC20.sol"; +import {MockSRC20, MockWDGSRC20} from "./utils/MockSrc20.sol"; + +contract TestInterface is Test { + address oracleAddress = address(0x1); + WDGSRC20 public WDG; + MockSRC20 public USDC; + Level public service; + InternalAMM public amm; + + function setUp() public { + USDC = new MockSRC20("USDC", "USDC", 18); + WDG = new MockWDGSRC20("WDG", "WDG", 18); + service = new Level(address(WDG), address(USDC), oracleAddress, suint(100), suint(7100)); + + amm = service.amm(); + // set up the amm + vm.prank(address(service)); + WDG.mint(saddress(service), suint(1000 ether)); + USDC.mint(saddress(service), suint(1000 ether)); + + vm.prank(address(service)); + WDG.approve(saddress(amm), suint(1000 ether)); + vm.prank(address(service)); + USDC.approve(saddress(amm), suint(1000 ether)); + + vm.prank(address(service)); + amm.addLiquidity(suint(1000 ether), suint(1000 ether), address(service)); // service balances are 0 + + assertEq(amm.owner(), address(service), "Owner should be service"); + } + + function testTransfer() public { + vm.prank(address(amm)); + uint256 ammBalanceInit = WDG.balanceOf(); + + vm.prank(address(service)); + WDG.mint(saddress(service), suint(1 ether)); + vm.prank(address(service)); + WDG.transfer(saddress(amm), suint(1 ether)); + + vm.prank(address(amm)); + uint256 ammBalanceFin = WDG.balanceOf(); + + assertEq(ammBalanceFin - ammBalanceInit, 1 ether, "Incorrect amount transferred."); + } + + function testTransferFail() public { + address user1 = address(0x2); + vm.prank(address(service)); + WDG.mint(saddress(user1), suint(100 ether)); + vm.prank(user1); + + vm.expectRevert(); + WDG.transfer(saddress(service), suint(20 ether)); + } + + function testPayForService() public { + address user2 = address(0x3); + // check if wdg reserve changed + vm.prank(address(service)); + uint256 initWdgReserve = WDG.trustedBalanceOf(saddress(amm)); + + USDC.mint(saddress(user2), suint(20)); + vm.prank(user2); + USDC.approve(saddress(service), suint(20)); + + vm.prank(user2); + service.payForService(suint(20)); // check balance decreased + + vm.prank(address(service)); // + uint256 finWdgReserve = WDG.trustedBalanceOf(saddress(amm)); + + assertTrue(initWdgReserve > finWdgReserve, "Burn unsuccessful."); + + vm.prank(user2); + vm.expectRevert(); // not enough balance + service.payForService(suint(20)); + } + + function testAllocateReward() public { + vm.prank(oracleAddress); + // allocating reward to service to bypass whitelisting + service.allocateReward(saddress(service), suint(20 ether)); + + vm.prank(address(service)); + uint256 bal = WDG.balanceOf(); + + assertEq(bal, 20 ether, "Minted balances do not match"); + } + + function testAllocateRewardFail() public { + address user3 = address(0x4); + vm.prank(user3); + vm.expectRevert(); + service.allocateReward(saddress(user3), suint(20 ether)); + } + + function testoperatorWithdraw() public { + address user5 = address(0x6); + vm.prank(address(service)); + WDG.mint(saddress(user5), suint256(200)); + + vm.prank(user5); + uint256 wdCap = service.viewWithdrawalCap(); + assertEq(wdCap, 100, "Incorrect withdrawal cap."); + + vm.prank(user5); + service.operatorWithdraw(suint256(100)); + + vm.prank(user5); + uint256 newBalance = USDC.balanceOf(); + assertTrue(newBalance == 100, "Swap balance incorrect"); + + vm.prank(user5); + vm.expectRevert(); + service.viewWithdrawalCap(); + } +} diff --git a/level/test/utils/MockSrc20.sol b/level/test/utils/MockSrc20.sol new file mode 100644 index 0000000..22d11a9 --- /dev/null +++ b/level/test/utils/MockSrc20.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.0; + +import {SRC20} from "../../src/SRC20.sol"; +import {WDGSRC20} from "../../src/WDGSRC20.sol"; + +contract MockSRC20 is SRC20 { + constructor(string memory _name, string memory _symbol, uint8 _decimals) SRC20(_name, _symbol, _decimals) {} + + function mint(saddress to, suint256 value) public virtual { + _mint(to, value); + } + + function burn(saddress from, suint256 value) public virtual { + _burn(from, value); + } +} + +contract MockWDGSRC20 is WDGSRC20 { + constructor(string memory _name, string memory _symbol, uint8 _decimals) WDGSRC20(_name, _symbol, _decimals) {} +}