diff --git a/deployed_contracts.txt b/deployed_contracts.txt index d9486ec..4c77497 100644 --- a/deployed_contracts.txt +++ b/deployed_contracts.txt @@ -19,4 +19,6 @@ WarRatiosV2 ratios : 0xE40004395384455326c7a27A85204801C7f85F94 WarStaker staker : 0xA86c53AF3aadF20bE5d7a8136ACfdbC4B074758A WarRatiosV2 ratios 2 : 0xCD7219cE5D6248c99693fA8239e680bd6C26cd48 WarRatiosV2 ratios 3 : 0x92fb36b6933756D93552623e1800D8C98F4831f9 -WarRatiosV2 ratios 4 : 0xcf377332Ca848274b95bb162807851D96b51a4d3 \ No newline at end of file +WarRatiosV2 ratios 4 : 0xcf377332Ca848274b95bb162807851D96b51a4d3 + +WarRatiosV3 ratios : 0x8C1D862c1FA91E0880dEEf621C7A17f9087Bf50C \ No newline at end of file diff --git a/script/DeployRatios3.s.sol b/script/DeployRatios3.s.sol new file mode 100644 index 0000000..3ef75df --- /dev/null +++ b/script/DeployRatios3.s.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.16; + +import "forge-std/Script.sol"; +import "test/MainnetTest.sol"; + +// Infrastructure +import {WarRatiosV3} from "src/RatiosV3.sol"; + +contract Deployment is Script, MainnetTest { + WarRatiosV3 ratios; + + function run() public { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + + deploy(); + + vm.stopBroadcast(); + } + + function deploy() public { + ratios = new WarRatiosV3(); + } +} diff --git a/src/RatiosV3.sol b/src/RatiosV3.sol new file mode 100644 index 0000000..1e758ee --- /dev/null +++ b/src/RatiosV3.sol @@ -0,0 +1,116 @@ +//██████╗ █████╗ ██╗ █████╗ ██████╗ ██╗███╗ ██╗ +//██╔══██╗██╔══██╗██║ ██╔══██╗██╔══██╗██║████╗ ██║ +//██████╔╝███████║██║ ███████║██║ ██║██║██╔██╗ ██║ +//██╔═══╝ ██╔══██║██║ ██╔══██║██║ ██║██║██║╚██╗██║ +//██║ ██║ ██║███████╗██║ ██║██████╔╝██║██║ ╚████║ +//╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚═╝╚═╝ ╚═══╝ + +pragma solidity 0.8.16; +//SPDX-License-Identifier: BUSL-1.1 + +import {IRatios} from "interfaces/IRatios.sol"; +import {Errors} from "utils/Errors.sol"; +import {Owner} from "utils/Owner.sol"; + +/** + * @title Warlord WAR minting ratios contract V3 + * @author Paladin + * @notice Calculate the amounts of WAR to mint or burn + */ +contract WarRatiosV3 is IRatios, Owner { + /** + * @notice 1e18 scale + */ + uint256 private constant UNIT = 1e18; + + bool public isFrozen; + + /** + * @notice Amount of WAR to mint per token for each listed token + */ + mapping(address => uint256) public warPerToken; + + event Frozen(); + event Unfrozen(); + event AddedTokens(address indexed token, uint256 warRatio); + event UpdatedTokens(address indexed token, uint256 warRatio); + + /** + * @notice Returns the ratio for a given token + * @param token Address of the token + */ + function getTokenRatio(address token) external view returns (uint256) { + return warPerToken[token]; + } + + /** + * @notice Adds a new token and sets the ratio of WAR to mint per token + * @param token Address of the token + * @param warRatio Amount of WAR minted per token + */ + function addToken(address token, uint256 warRatio) external onlyOwner { + if (token == address(0)) revert Errors.ZeroAddress(); + if (warRatio == 0) revert Errors.ZeroValue(); + if (warPerToken[token] != 0) revert Errors.RatioAlreadySet(); + + warPerToken[token] = warRatio; + + emit AddedTokens(token, warRatio); + } + + function freezeRatios() external onlyOwner { + isFrozen = true; + + emit Frozen(); + } + + function unfreezeRatios() external onlyOwner { + isFrozen = false; + + emit Unfrozen(); + } + + /** + * @notice Updates the ratio of WAR to mint per token + * @param token Address of the token + * @param warRatio Amount of WAR minted per token + */ + function updateToken(address token, uint256 warRatio) external onlyOwner { + if (token == address(0)) revert Errors.ZeroAddress(); + if (warRatio == 0) revert Errors.ZeroValue(); + if (warPerToken[token] == 0) revert Errors.RatioNotSet(); + if (!isFrozen) revert Errors.RatiosNotFrozen(); + + warPerToken[token] = warRatio; + + emit UpdatedTokens(token, warRatio); + } + + /** + * @notice Returns the amount of WAR to mint for a given amount of token + * @param token Address of the token + * @param amount Amount of token received + * @return mintAmount (uint256) : Amount to mint + */ + function getMintAmount(address token, uint256 amount) external view returns (uint256 mintAmount) { + if (token == address(0)) revert Errors.ZeroAddress(); + if (amount == 0) revert Errors.ZeroValue(); + if (isFrozen) revert Errors.RatiosFrozen(); + + mintAmount = amount * warPerToken[token] / UNIT; + } + + /** + * @notice Returns the amount of token to redeem for a given amount of WAR burned + * @param token Address of the token + * @param burnAmount Amount of WAR to burn + * @return redeemAmount (uint256) : Redeem amount + */ + function getBurnAmount(address token, uint256 burnAmount) external view returns (uint256 redeemAmount) { + if (token == address(0)) revert Errors.ZeroAddress(); + if (burnAmount == 0) revert Errors.ZeroValue(); + if (isFrozen) revert Errors.RatiosFrozen(); + + redeemAmount = burnAmount * UNIT / warPerToken[token]; + } +} diff --git a/src/utils/Errors.sol b/src/utils/Errors.sol index af692dd..69ee04d 100644 --- a/src/utils/Errors.sol +++ b/src/utils/Errors.sol @@ -49,6 +49,9 @@ library Errors { error ZeroMintAmount(); error SupplyAlreadySet(); error RatioAlreadySet(); + error RatioNotSet(); + error RatiosFrozen(); + error RatiosNotFrozen(); // Harvestable error NotRewardToken(); diff --git a/test/RatiosV3/AddToken.t.sol b/test/RatiosV3/AddToken.t.sol new file mode 100644 index 0000000..1b2f493 --- /dev/null +++ b/test/RatiosV3/AddToken.t.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.16; + +import "./RatiosV3Test.sol"; + +contract AddToken is RatiosV3Test { + function testDefaultBehavior(address token, uint256 tokenRatio) public { + vm.assume(token != zero && token != address(cvx) && token != address(aura)); + vm.assume(tokenRatio > 0); + + vm.prank(admin); + ratiosV3.addToken(token, tokenRatio); + assertEq(ratiosV3.warPerToken(token), tokenRatio); + assertEq(ratiosV3.getTokenRatio(token), tokenRatio); + } + + function testBaseTokens() public { + // Token already added in setup just need to check + assertGt(ratiosV3.warPerToken(address(aura)), 0); + assertGt(ratiosV3.warPerToken(address(cvx)), 0); + //assertEq(ratiosV3.warPerToken(address(cvx)), ratiosV3.warPerToken(address(aura))); + } + + function testCantAddZeroAddress() public { + vm.expectRevert(Errors.ZeroAddress.selector); + vm.prank(admin); + ratiosV3.addToken(zero, 500e18); + } + + function testCantAddZeroSupply() public { + vm.expectRevert(Errors.ZeroValue.selector); + vm.prank(admin); + ratiosV3.addToken(address(42), 0); + } + + function testCantAddAlreadyExistingToken() public { + vm.expectRevert(Errors.RatioAlreadySet.selector); + vm.prank(admin); + ratiosV3.addToken(address(cvx), 50e18); + } +} diff --git a/test/RatiosV3/FreezeRatios.sol b/test/RatiosV3/FreezeRatios.sol new file mode 100644 index 0000000..a289cfa --- /dev/null +++ b/test/RatiosV3/FreezeRatios.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.16; + +import "./RatiosV3Test.sol"; + +contract FreezeRatios is RatiosV3Test { + function testDefaultBehavior() public { + assertEq(ratiosV3.isFrozen(), false); + + vm.prank(admin); + ratiosV3.freezeRatios(); + + assertEq(ratiosV3.isFrozen(), true); + } + + function testOnlyAdmin() public { + vm.expectRevert(); + ratiosV3.freezeRatios(); + } +} \ No newline at end of file diff --git a/test/RatiosV3/GetMintAmount.t.sol b/test/RatiosV3/GetMintAmount.t.sol new file mode 100644 index 0000000..525b9fb --- /dev/null +++ b/test/RatiosV3/GetMintAmount.t.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.16; + +import "./RatiosV3Test.sol"; + +contract GetMintAmount is RatiosV3Test { + function _defaultBehavior(address token, uint256 maxSupply, uint256 amount) internal { + vm.assume(amount >= 1e4 && amount <= maxSupply); + uint256 mintAmount = ratiosV3.getMintAmount(address(token), amount); + assertGt(mintAmount, 0); + + uint256 expectedMintAmount = amount * setWarPerToken[token] / UNIT; + + assertEq(mintAmount, expectedMintAmount); + } + + function testDefaultBehaviorWithAura(uint256 amount) public { + _defaultBehavior(address(aura), auraMaxSupply, amount); + } + + function testDefaultBehaviorWithCvx(uint256 amount) public { + _defaultBehavior(address(cvx), cvxMaxSupply, amount); + } + + function testPrecisionLoss(uint256 amount) public { + vm.assume(amount > 0 && amount < 1e4); + + address token = makeAddr("otherToken"); + vm.prank(admin); + ratiosV3.addToken(token, CVX_MINT_RATIO / 1e4); + + assertEq(ratiosV3.getMintAmount(token, amount), 0); + } + + function testZeroAddress(uint256 amount) public { + vm.assume(amount != 0); + + vm.expectRevert(Errors.ZeroAddress.selector); + ratiosV3.getMintAmount(zero, amount); + } + + function testZeroAmount(address token) public { + vm.assume(token != zero); + + vm.expectRevert(Errors.ZeroValue.selector); + ratiosV3.getMintAmount(token, 0); + } + + function testFrozen(address token) public { + vm.assume(token != zero); + + vm.prank(admin); + ratiosV3.freezeRatios(); + + vm.expectRevert(Errors.RatiosFrozen.selector); + ratiosV3.getMintAmount(token, 10e18); + } +} diff --git a/test/RatiosV3/GetRedeemAmount.t.sol b/test/RatiosV3/GetRedeemAmount.t.sol new file mode 100644 index 0000000..62d0e94 --- /dev/null +++ b/test/RatiosV3/GetRedeemAmount.t.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.16; + +import "./RatiosV3Test.sol"; + +contract GetRedeemAmount is RatiosV3Test { + function testDefaultBehavior(uint256 initialMintAmount) public { + address token = randomVlToken(initialMintAmount); + vm.assume(initialMintAmount > MINT_PRECISION_LOSS); + vm.assume(initialMintAmount <= (token == address(aura) ? AURA_MAX_SUPPLY : CVX_MAX_SUPPLY)); + + uint256 amountToBurn = ratiosV3.getMintAmount(token, initialMintAmount); + uint256 burnedAmount = ratiosV3.getBurnAmount(token, amountToBurn); + + // Precision correction + initialMintAmount = initialMintAmount / MINT_PRECISION_LOSS * MINT_PRECISION_LOSS; + burnedAmount = burnedAmount / MINT_PRECISION_LOSS * MINT_PRECISION_LOSS; + + assertEqDecimal( + burnedAmount, initialMintAmount, 18, "The burned amount should correspond to the initial amount used to mint" + ); + } + + function testZeroAddress(uint256 amount) public { + vm.assume(amount != 0); + + vm.expectRevert(Errors.ZeroAddress.selector); + ratiosV3.getBurnAmount(zero, amount); + } + + function testZeroAmount(address token) public { + vm.assume(token != zero); + + vm.expectRevert(Errors.ZeroValue.selector); + ratiosV3.getBurnAmount(token, 0); + } + + function testFrozen(address token) public { + vm.assume(token != zero); + + vm.prank(admin); + ratiosV3.freezeRatios(); + + vm.expectRevert(Errors.RatiosFrozen.selector); + ratiosV3.getBurnAmount(token, 10e18); + } +} diff --git a/test/RatiosV3/RatiosV3Test.sol b/test/RatiosV3/RatiosV3Test.sol new file mode 100644 index 0000000..49ccc96 --- /dev/null +++ b/test/RatiosV3/RatiosV3Test.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.16; + +import "../WarlordTest.sol"; +import "src/RatiosV3.sol"; + +contract RatiosV3Test is WarlordTest { + uint256 constant UNIT = 1e18; + uint256 constant MINT_PRECISION_LOSS = 10; // Because AURA ratio is 0.5 (1 AURA mints 0.5 WAR) + + uint256 constant cvxMaxSupply = 100_000_000e18; + uint256 constant auraMaxSupply = 100_000_000e18; + + mapping(address => uint256) public setWarPerToken; + + WarRatiosV3 public ratiosV3; + + function setUp() public override { + MainnetTest.setUp(); + fork(); + + setWarPerToken[address(cvx)] = CVX_MINT_RATIO; + setWarPerToken[address(aura)] = AURA_MINT_RATIO; + + vm.startPrank(admin); + ratiosV3 = new WarRatiosV3(); + ratiosV3.addToken(address(cvx), CVX_MINT_RATIO); + ratiosV3.addToken(address(aura), AURA_MINT_RATIO); + vm.stopPrank(); + } +} diff --git a/test/RatiosV3/UnfreezeRatios.sol b/test/RatiosV3/UnfreezeRatios.sol new file mode 100644 index 0000000..0903736 --- /dev/null +++ b/test/RatiosV3/UnfreezeRatios.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.16; + +import "./RatiosV3Test.sol"; + +contract FreezeRatios is RatiosV3Test { + function testDefaultBehavior() public { + vm.prank(admin); + ratiosV3.freezeRatios(); + + assertEq(ratiosV3.isFrozen(), true); + + vm.prank(admin); + ratiosV3.unfreezeRatios(); + + assertEq(ratiosV3.isFrozen(), false); + } + + function testOnlyAdmin() public { + vm.expectRevert(); + ratiosV3.unfreezeRatios(); + } +} \ No newline at end of file diff --git a/test/RatiosV3/UpdatedToken.t.sol b/test/RatiosV3/UpdatedToken.t.sol new file mode 100644 index 0000000..8a05047 --- /dev/null +++ b/test/RatiosV3/UpdatedToken.t.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.16; + +import "./RatiosV3Test.sol"; + +contract UpdateToken is RatiosV3Test { + function testDefaultBehavior(uint256 tokenRatio) public { + vm.assume(tokenRatio > 0); + + vm.prank(admin); + ratiosV3.freezeRatios(); + + vm.prank(admin); + ratiosV3.updateToken(address(cvx), tokenRatio); + assertEq(ratiosV3.warPerToken(address(cvx)), tokenRatio); + assertEq(ratiosV3.getTokenRatio(address(cvx)), tokenRatio); + } + + function testCantUpdateZeroAddress() public { + vm.prank(admin); + ratiosV3.freezeRatios(); + + vm.expectRevert(Errors.ZeroAddress.selector); + vm.prank(admin); + ratiosV3.updateToken(zero, 500e18); + } + + function testCantUpdateZeroValue() public { + vm.prank(admin); + ratiosV3.freezeRatios(); + + vm.expectRevert(Errors.ZeroValue.selector); + vm.prank(admin); + ratiosV3.updateToken(address(42), 0); + } + + function testCantUpdateNotExistingToken() public { + vm.prank(admin); + ratiosV3.freezeRatios(); + + vm.expectRevert(Errors.RatioNotSet.selector); + vm.prank(admin); + ratiosV3.updateToken(address(5555), 50e18); + } + + function testUpdateIfNotFrozen() public { + vm.expectRevert(Errors.RatiosNotFrozen.selector); + vm.prank(admin); + ratiosV3.updateToken(address(cvx), 50e18); + } +}