diff --git a/.gas-report b/.gas-report index aecda42..673a871 100644 --- a/.gas-report +++ b/.gas-report @@ -15,21 +15,40 @@ | src/RewardsStreamerMP.sol:RewardsStreamerMP contract | | | | | | |------------------------------------------------------|-----------------|--------|--------|--------|---------| | Deployment Cost | Deployment Size | | | | | -| 1105804 | 4983 | | | | | +| 1114894 | 5025 | | | | | | Function Name | min | avg | median | max | # calls | | MAX_LOCKING_PERIOD | 228 | 228 | 228 | 228 | 18 | -| MAX_MULTIPLIER | 229 | 229 | 229 | 229 | 24 | +| MAX_MULTIPLIER | 274 | 274 | 274 | 274 | 34 | | MIN_LOCKING_PERIOD | 229 | 229 | 229 | 229 | 8 | -| SCALE_FACTOR | 251 | 251 | 251 | 251 | 28 | -| accountedRewards | 373 | 963 | 373 | 2373 | 44 | -| getUserInfo | 1553 | 1553 | 1553 | 1553 | 41 | -| potentialMP | 330 | 330 | 330 | 330 | 44 | -| rewardIndex | 373 | 418 | 373 | 2373 | 44 | -| stake | 167821 | 215459 | 228608 | 249401 | 35 | -| totalMP | 352 | 352 | 352 | 352 | 44 | -| totalStaked | 330 | 330 | 330 | 330 | 44 | +| MP_RATE_PER_YEAR | 231 | 231 | 231 | 231 | 2 | +| SCALE_FACTOR | 251 | 251 | 251 | 251 | 30 | +| accountedRewards | 373 | 921 | 373 | 2373 | 62 | +| getUserInfo | 1531 | 1531 | 1531 | 1531 | 61 | +| potentialMP | 330 | 330 | 330 | 330 | 62 | +| rewardIndex | 373 | 405 | 373 | 2373 | 62 | +| stake | 167787 | 215055 | 228586 | 249379 | 43 | +| totalMP | 352 | 352 | 352 | 352 | 62 | +| totalStaked | 373 | 373 | 373 | 373 | 62 | | unstake | 75511 | 107650 | 110519 | 134250 | 10 | -| updateGlobalState | 30008 | 69159 | 80335 | 80335 | 10 | +| updateGlobalState | 30008 | 57540 | 50309 | 80335 | 22 | +| updateUserMP | 29022 | 38080 | 40060 | 40060 | 16 | + + +| src/XPToken.sol:XPToken contract | | | | | | +|----------------------------------|-----------------|-------|--------|-------|---------| +| Deployment Cost | Deployment Size | | | | | +| 725645 | 3153 | | | | | +| Function Name | min | avg | median | max | # calls | +| addXPProvider | 23968 | 57184 | 51091 | 68191 | 18 | +| allowance | 488 | 488 | 488 | 488 | 1 | +| approve | 390 | 390 | 390 | 390 | 1 | +| balanceOf | 8808 | 14560 | 11066 | 23808 | 3 | +| getXPProviders | 1033 | 3286 | 3286 | 5539 | 2 | +| removeXPProvider | 23665 | 28074 | 25780 | 34777 | 3 | +| setTotalSupply | 23792 | 26266 | 26266 | 28740 | 2 | +| totalSupply | 363 | 1863 | 2363 | 2363 | 4 | +| transfer | 411 | 411 | 411 | 411 | 1 | +| transferFrom | 521 | 521 | 521 | 521 | 1 | | test/mocks/MockToken.sol:MockToken contract | | | | | | @@ -37,11 +56,22 @@ | Deployment Cost | Deployment Size | | | | | | 639406 | 3369 | | | | | | Function Name | min | avg | median | max | # calls | -| approve | 46346 | 46346 | 46346 | 46346 | 120 | -| balanceOf | 561 | 1327 | 561 | 2561 | 201 | -| mint | 51284 | 58124 | 51284 | 68384 | 120 | +| approve | 46346 | 46346 | 46346 | 46346 | 150 | +| balanceOf | 561 | 1321 | 561 | 2561 | 271 | +| mint | 51284 | 58124 | 51284 | 68384 | 150 | | transfer | 34390 | 48070 | 51490 | 51490 | 10 | +| test/mocks/XPProviderMock.sol:XPProviderMock contract | | | | | | +|-------------------------------------------------------|-----------------|-------|--------|-------|---------| +| Deployment Cost | Deployment Size | | | | | +| 152145 | 489 | | | | | +| Function Name | min | avg | median | max | # calls | +| getTotalXPShares | 302 | 968 | 302 | 2302 | 6 | +| getUserXPShare | 504 | 1837 | 2504 | 2504 | 6 | +| setTotalXPShares | 43632 | 43632 | 43632 | 43632 | 2 | +| setUserXPShare | 43900 | 43900 | 43900 | 43900 | 2 | + + diff --git a/.gas-snapshot b/.gas-snapshot index 11a2fd5..ca08778 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,24 +1,38 @@ -IntegrationTest:testStake() (gas: 1378213) +IntegrationTest:testStake() (gas: 1378182) RewardsStreamerTest:testStake() (gas: 869874) -StakeTest:test_StakeMultipleAccounts() (gas: 438756) -StakeTest:test_StakeMultipleAccountsAndRewards() (gas: 586002) -StakeTest:test_StakeMultipleAccountsWithMinLockUp() (gas: 449214) -StakeTest:test_StakeMultipleAccountsWithRandomLockUp() (gas: 470881) -StakeTest:test_StakeOneAccount() (gas: 267795) -StakeTest:test_StakeOneAccountAndRewards() (gas: 415039) -StakeTest:test_StakeOneAccountWithMaxLockUp() (gas: 284120) -StakeTest:test_StakeOneAccountWithMinLockUp() (gas: 284152) -StakeTest:test_StakeOneAccountWithRandomLockUp() (gas: 284196) -UnstakeTest:test_StakeMultipleAccounts() (gas: 438778) -UnstakeTest:test_StakeMultipleAccountsAndRewards() (gas: 586002) -UnstakeTest:test_StakeMultipleAccountsWithMinLockUp() (gas: 449214) -UnstakeTest:test_StakeMultipleAccountsWithRandomLockUp() (gas: 470903) -UnstakeTest:test_StakeOneAccount() (gas: 267795) -UnstakeTest:test_StakeOneAccountAndRewards() (gas: 415061) -UnstakeTest:test_StakeOneAccountWithMaxLockUp() (gas: 284120) -UnstakeTest:test_StakeOneAccountWithMinLockUp() (gas: 284152) -UnstakeTest:test_StakeOneAccountWithRandomLockUp() (gas: 284196) -UnstakeTest:test_UnstakeMultipleAccounts() (gas: 616327) -UnstakeTest:test_UnstakeMultipleAccountsAndRewards() (gas: 937593) -UnstakeTest:test_UnstakeOneAccount() (gas: 446306) -UnstakeTest:test_UnstakeOneAccountAndRewards() (gas: 557157) \ No newline at end of file +StakeTest:test_StakeMultipleAccounts() (gas: 438733) +StakeTest:test_StakeMultipleAccountsAndRewards() (gas: 586000) +StakeTest:test_StakeMultipleAccountsMPIncreasesPotentialMPDecreases() (gas: 761311) +StakeTest:test_StakeMultipleAccountsWithMinLockUp() (gas: 449348) +StakeTest:test_StakeMultipleAccountsWithRandomLockUp() (gas: 471037) +StakeTest:test_StakeOneAccount() (gas: 267794) +StakeTest:test_StakeOneAccountAndRewards() (gas: 415103) +StakeTest:test_StakeOneAccountMPIncreasesPotentialMPDecreases() (gas: 484709) +StakeTest:test_StakeOneAccountReachingMPLimit() (gas: 446674) +StakeTest:test_StakeOneAccountWithMaxLockUp() (gas: 284253) +StakeTest:test_StakeOneAccountWithMinLockUp() (gas: 284263) +StakeTest:test_StakeOneAccountWithRandomLockUp() (gas: 284307) +UnstakeTest:test_StakeMultipleAccounts() (gas: 438755) +UnstakeTest:test_StakeMultipleAccountsAndRewards() (gas: 586045) +UnstakeTest:test_StakeMultipleAccountsMPIncreasesPotentialMPDecreases() (gas: 761245) +UnstakeTest:test_StakeMultipleAccountsWithMinLockUp() (gas: 449348) +UnstakeTest:test_StakeMultipleAccountsWithRandomLockUp() (gas: 471059) +UnstakeTest:test_StakeOneAccount() (gas: 267794) +UnstakeTest:test_StakeOneAccountAndRewards() (gas: 415125) +UnstakeTest:test_StakeOneAccountMPIncreasesPotentialMPDecreases() (gas: 484709) +UnstakeTest:test_StakeOneAccountReachingMPLimit() (gas: 446696) +UnstakeTest:test_StakeOneAccountWithMaxLockUp() (gas: 284276) +UnstakeTest:test_StakeOneAccountWithMinLockUp() (gas: 284263) +UnstakeTest:test_StakeOneAccountWithRandomLockUp() (gas: 284352) +UnstakeTest:test_UnstakeMultipleAccounts() (gas: 616281) +UnstakeTest:test_UnstakeMultipleAccountsAndRewards() (gas: 937677) +UnstakeTest:test_UnstakeOneAccount() (gas: 446369) +UnstakeTest:test_UnstakeOneAccountAndRewards() (gas: 557220) +XPTokenTest:testAddXPProviderOnlyOwner() (gas: 285732) +XPTokenTest:testBalanceOf() (gas: 210530) +XPTokenTest:testBalanceOfWithNoSystemTotalXP() (gas: 45808) +XPTokenTest:testRemoveXPProviderIndexOutOfBounds() (gas: 36277) +XPTokenTest:testRemoveXPProviderOnlyOwner() (gas: 72074) +XPTokenTest:testSetTotalSupplyOnlyOwner() (gas: 70544) +XPTokenTest:testTotalSupply() (gas: 10507) +XPTokenTest:testTransfersNotAllowed() (gas: 20634) diff --git a/certora/certora.conf b/certora/confs/RewardsStreamerMP.conf similarity index 99% rename from certora/certora.conf rename to certora/confs/RewardsStreamerMP.conf index 80b0d47..4123e8d 100644 --- a/certora/certora.conf +++ b/certora/confs/RewardsStreamerMP.conf @@ -11,4 +11,3 @@ "@openzeppelin=lib/openzeppelin-contracts" ] } - diff --git a/certora/confs/XPToken.conf b/certora/confs/XPToken.conf new file mode 100644 index 0000000..9bcbb48 --- /dev/null +++ b/certora/confs/XPToken.conf @@ -0,0 +1,13 @@ +{ + "files": ["src/XPToken.sol"], + "msg": "Verifying XPToken.sol", + "rule_sanity": "basic", + "verify": "XPToken:certora/specs/XPToken.spec", + "wait_for_results": "all", + "optimistic_loop": true, + "loop_iter": "3", + "packages": [ + "forge-std=lib/forge-std/src", + "@openzeppelin=lib/openzeppelin-contracts" + ] +} diff --git a/certora/specs/RewardsStreamerMP.spec b/certora/specs/RewardsStreamerMP.spec index 6d54f28..4be8f4d 100644 --- a/certora/specs/RewardsStreamerMP.spec +++ b/certora/specs/RewardsStreamerMP.spec @@ -1,3 +1,3 @@ -rule checkIdOutputIsAlwaysEqualToInput { +rule test { assert true; } diff --git a/certora/specs/XPToken.spec b/certora/specs/XPToken.spec new file mode 100644 index 0000000..4be8f4d --- /dev/null +++ b/certora/specs/XPToken.spec @@ -0,0 +1,3 @@ +rule test { + assert true; +} diff --git a/lib/forge-std b/lib/forge-std index 1714bee..035de35 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 1714bee72e286e73f76e320d110e0eaf5c4e649d +Subproject commit 035de35f5e366c8d6ed142aec4ccb57fe2dd87d4 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index dbb6104..8b591ba 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit dbb6104ce834628e473d2173bbc9d47f81a9eec3 +Subproject commit 8b591baef460523e5ca1c53712c464bcc1a1c467 diff --git a/package.json b/package.json index a762524..ceb6d04 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "scripts": { "clean": "rm -rf cache out", "lint": "pnpm lint:sol && pnpm prettier:check", - "verify": "certoraRun certora/certora.conf", + "verify": "pnpm verify:rewards_streamer_mp && pnpm verify:xp_token", + "verify:rewards_streamer_mp": "certoraRun certora/confs/RewardsStreamerMP.conf", + "verify:xp_token": "certoraRun certora/confs/XPToken.conf", "lint:sol": "forge fmt --check && pnpm solhint {script,src,test,certora}/**/*.sol", "prettier:check": "prettier --check **/*.{json,md,yml} --ignore-path=.prettierignore", "prettier:write": "prettier --write **/*.{json,md,yml} --ignore-path=.prettierignore", diff --git a/src/XPToken.sol b/src/XPToken.sol new file mode 100644 index 0000000..853a975 --- /dev/null +++ b/src/XPToken.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { IXPProvider } from "./interfaces/IXPProvider.sol"; + +contract XPToken is Ownable { + string public constant name = "XP Token"; + string public constant symbol = "XP"; + uint256 public constant decimals = 18; + + uint256 public totalSupply; + + IXPProvider[] public xpProviders; + + error XPToken__TransfersNotAllowed(); + error XPProvider__IndexOutOfBounds(); + + constructor(uint256 _totalSupply) Ownable(msg.sender) { + totalSupply = _totalSupply; + } + + function setTotalSupply(uint256 _totalSupply) external onlyOwner { + totalSupply = _totalSupply; + } + + function addXPProvider(IXPProvider provider) external onlyOwner { + xpProviders.push(provider); + } + + function removeXPProvider(uint256 index) external onlyOwner { + if (index >= xpProviders.length) { + revert XPProvider__IndexOutOfBounds(); + } + + xpProviders[index] = xpProviders[xpProviders.length - 1]; + xpProviders.pop(); + } + + function getXPProviders() external view returns (IXPProvider[] memory) { + return xpProviders; + } + + function balanceOf(address account) public view returns (uint256) { + uint256 userTotalXPShare = 0; + uint256 totalXPShares = 0; + + for (uint256 i = 0; i < xpProviders.length; i++) { + IXPProvider provider = xpProviders[i]; + userTotalXPShare += provider.getUserXPShare(account); + totalXPShares += provider.getTotalXPShares(); + } + + if (totalXPShares == 0) { + return 0; + } + + return (totalSupply * userTotalXPShare) / totalXPShares; + } + + function transfer(address, uint256) external pure returns (bool) { + revert XPToken__TransfersNotAllowed(); + } + + function approve(address, uint256) external pure returns (bool) { + revert XPToken__TransfersNotAllowed(); + } + + function transferFrom(address, address, uint256) external pure returns (bool) { + revert XPToken__TransfersNotAllowed(); + } + + function allowance(address, address) external pure returns (uint256) { + return 0; + } +} diff --git a/src/interfaces/IXPProvider.sol b/src/interfaces/IXPProvider.sol new file mode 100644 index 0000000..cdad5f1 --- /dev/null +++ b/src/interfaces/IXPProvider.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +interface IXPProvider { + function getTotalXPShares() external view returns (uint256); + function getUserXPShare(address user) external view returns (uint256); +} diff --git a/test/XPToken.t.sol b/test/XPToken.t.sol new file mode 100644 index 0000000..a7c42e6 --- /dev/null +++ b/test/XPToken.t.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { Test } from "forge-std/Test.sol"; +import { XPToken } from "../src/XPToken.sol"; +import { XPProviderMock } from "./mocks/XPProviderMock.sol"; +import { IXPProvider } from "../src/interfaces/IXPProvider.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +contract XPTokenTest is Test { + XPToken xpToken; + address owner = address(0x1); + address alice = address(0x2); + address bob = address(0x3); + + XPProviderMock provider1; + XPProviderMock provider2; + + function setUp() public { + vm.prank(owner); + xpToken = new XPToken(1000e18); + + provider1 = new XPProviderMock(); + provider2 = new XPProviderMock(); + + vm.prank(owner); + xpToken.addXPProvider(provider1); + + vm.prank(owner); + xpToken.addXPProvider(provider2); + } + + function testAddXPProviderOnlyOwner() public { + XPProviderMock provider3 = new XPProviderMock(); + + vm.prank(alice); + vm.expectPartialRevert(Ownable.OwnableUnauthorizedAccount.selector); + xpToken.addXPProvider(provider3); + + vm.prank(owner); + xpToken.addXPProvider(provider3); + + IXPProvider[] memory providers = xpToken.getXPProviders(); + assertEq(providers.length, 3); + assertEq(address(providers[0]), address(provider1)); + assertEq(address(providers[1]), address(provider2)); + assertEq(address(providers[2]), address(provider3)); + } + + function testRemoveXPProviderOnlyOwner() public { + vm.prank(alice); + vm.expectPartialRevert(Ownable.OwnableUnauthorizedAccount.selector); + xpToken.removeXPProvider(0); + + vm.prank(owner); + xpToken.removeXPProvider(0); + + IXPProvider[] memory providers = xpToken.getXPProviders(); + assertEq(providers.length, 1); + assertEq(address(providers[0]), address(provider2)); + } + + function testRemoveXPProviderIndexOutOfBounds() public { + vm.prank(owner); + vm.expectRevert(XPToken.XPProvider__IndexOutOfBounds.selector); + xpToken.removeXPProvider(10); + } + + function testTotalSupply() public view { + uint256 totalSupply = xpToken.totalSupply(); + assertEq(totalSupply, 1000 ether); + } + + function testBalanceOfWithNoSystemTotalXP() public view { + uint256 aliceBalance = xpToken.balanceOf(alice); + assertEq(aliceBalance, 0); + + uint256 bobBalance = xpToken.balanceOf(bob); + assertEq(bobBalance, 0); + } + + function testBalanceOf() public { + provider1.setUserXPShare(alice, 100e18); + provider1.setTotalXPShares(1000e18); + + provider2.setUserXPShare(alice, 200e18); + provider2.setTotalXPShares(2000e18); + + // Expected balance calculation + uint256 userTotalXP = 100e18 + 200e18; + uint256 systemTotalXP = 1000e18 + 2000e18; + + uint256 expectedBalance = (xpToken.totalSupply() * userTotalXP) / systemTotalXP; + + uint256 balance = xpToken.balanceOf(alice); + assertEq(balance, expectedBalance); + } + + function testSetTotalSupplyOnlyOwner() public { + uint256 totalSupply = xpToken.totalSupply(); + assertEq(totalSupply, 1000e18); + + vm.prank(alice); + vm.expectPartialRevert(Ownable.OwnableUnauthorizedAccount.selector); + xpToken.setTotalSupply(2000e18); + + vm.prank(owner); + xpToken.setTotalSupply(2000e18); + totalSupply = xpToken.totalSupply(); + assertEq(totalSupply, 2000e18); + } + + function testTransfersNotAllowed() public { + vm.expectRevert(XPToken.XPToken__TransfersNotAllowed.selector); + xpToken.transfer(alice, 100e18); + + vm.expectRevert(XPToken.XPToken__TransfersNotAllowed.selector); + xpToken.approve(alice, 100e18); + + vm.expectRevert(XPToken.XPToken__TransfersNotAllowed.selector); + xpToken.transferFrom(alice, bob, 100e18); + + uint256 allowance = xpToken.allowance(alice, bob); + assertEq(allowance, 0); + } +} diff --git a/test/mocks/XPProviderMock.sol b/test/mocks/XPProviderMock.sol new file mode 100644 index 0000000..1776641 --- /dev/null +++ b/test/mocks/XPProviderMock.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { IXPProvider } from "../../src/interfaces/IXPProvider.sol"; + +contract XPProviderMock is IXPProvider { + mapping(address => uint256) public userXPShare; + + uint256 public totalXPShares; + + function setUserXPShare(address user, uint256 xp) external { + userXPShare[user] = xp; + } + + function setTotalXPShares(uint256 xp) external { + totalXPShares = xp; + } + + function getUserXPShare(address account) external view override returns (uint256) { + return userXPShare[account]; + } + + function getTotalXPShares() external view override returns (uint256) { + return totalXPShares; + } +}