From 169941dd31b78a80fe7ad43863bd852294360d7a Mon Sep 17 00:00:00 2001 From: Michael Heuer Date: Thu, 18 Jun 2026 18:05:23 +0200 Subject: [PATCH 1/6] feat: add unlock, vesting, and ERC20Votes --- bun.lock | 4 +- foundry.toml | 9 +- justfile | 94 ++++++ package.json | 2 +- script/DeployXanV1.s.sol | 24 ++ script/ScheduleCouncilUpgradeToXanV2.s.sol | 38 +++ script/Upgrade.s.sol | 36 --- script/UpgradeToXanV2.s.sol | 29 ++ src/XanV2.sol | 325 +++++++++++++++++++++ src/drafts/XanV2.sol | 103 ------- src/drafts/XanV2Forwarder.sol | 81 ----- src/drafts/interfaces/IXanV2.sol | 20 -- src/interfaces/IXanV2.sol | 49 ++++ src/libs/Parameters.sol | 16 + test/XanV1.upgrade.t.sol | 26 +- test/XanV2.constructor.t.sol | 59 ++++ test/XanV2.nonces.t.sol | 108 +++++++ test/XanV2.reinitialization.t.sol | 93 ++++++ test/XanV2.storage.t.sol | 11 +- test/XanV2.unit.t.sol | 175 ----------- test/XanV2.unlocking.t.sol | 146 +++++++++ test/XanV2.upgrade.integration.t.sol | 59 ++++ test/XanV2.upgrade.t.sol | 40 +++ test/XanV2.voting.t.sol | 192 ++++++++++++ test/XanV2Forwarder.t.sol | 62 ---- test/fixtures/XanV2Fixture.sol | 69 +++++ test/invariants/HandlerXanV1.t.sol | 2 +- test/invariants/HandlerXanV2.t.sol | 86 ++++++ test/invariants/InvariantXanV1.t.sol | 2 +- test/invariants/InvariantXanV2.t.sol | 81 +++++ test/mocks/MockXanV2.sol | 21 ++ test/mocks/Persons.m.sol | 52 ---- test/mocks/ProtocolAdapter.m.sol | 25 -- test/mocks/Target.m.sol | 24 -- 34 files changed, 1548 insertions(+), 615 deletions(-) create mode 100644 script/DeployXanV1.s.sol create mode 100644 script/ScheduleCouncilUpgradeToXanV2.s.sol delete mode 100644 script/Upgrade.s.sol create mode 100644 script/UpgradeToXanV2.s.sol create mode 100644 src/XanV2.sol delete mode 100644 src/drafts/XanV2.sol delete mode 100644 src/drafts/XanV2Forwarder.sol delete mode 100644 src/drafts/interfaces/IXanV2.sol create mode 100644 src/interfaces/IXanV2.sol create mode 100644 test/XanV2.constructor.t.sol create mode 100644 test/XanV2.nonces.t.sol create mode 100644 test/XanV2.reinitialization.t.sol delete mode 100644 test/XanV2.unit.t.sol create mode 100644 test/XanV2.unlocking.t.sol create mode 100644 test/XanV2.upgrade.integration.t.sol create mode 100644 test/XanV2.upgrade.t.sol create mode 100644 test/XanV2.voting.t.sol delete mode 100644 test/XanV2Forwarder.t.sol create mode 100644 test/fixtures/XanV2Fixture.sol create mode 100644 test/invariants/HandlerXanV2.t.sol create mode 100644 test/invariants/InvariantXanV2.t.sol create mode 100644 test/mocks/MockXanV2.sol delete mode 100644 test/mocks/Persons.m.sol delete mode 100644 test/mocks/ProtocolAdapter.m.sol delete mode 100644 test/mocks/Target.m.sol diff --git a/bun.lock b/bun.lock index 3aa6d6c..6ff3df0 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "dependencies": { "@openzeppelin/upgrades-core": "^1.46.0", - "solhint": "^6.2.2", + "solhint": "^6.2.3", }, }, }, @@ -340,7 +340,7 @@ "slice-ansi": ["slice-ansi@4.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ=="], - "solhint": ["solhint@6.2.2", "", { "dependencies": { "@solidity-parser/parser": "^0.20.2", "ajv": "^8.18.0", "ast-parents": "^0.0.1", "better-ajv-errors": "^2.0.2", "chalk": "^4.1.2", "commander": "^10.0.0", "cosmiconfig": "^8.0.0", "fast-diff": "^1.2.0", "glob": "^13.0.6", "ignore": "^5.2.4", "js-yaml": "^4.1.0", "latest-version": "^7.0.0", "lodash": "^4.17.21", "pluralize": "^8.0.0", "semver": "^7.5.2", "table": "^6.8.1", "text-table": "^0.2.0" }, "optionalDependencies": { "prettier": "^3.0.0" }, "bin": { "solhint": "solhint.js" } }, "sha512-s8NVDXjVFBYjG/hp+LEeduf+B/d7Aw4HRS225zYuOWOKlvNZSDyGT8oO6jQuBU5K9oEfGlRqAaBYGD8+um+UHQ=="], + "solhint": ["solhint@6.2.3", "", { "dependencies": { "@solidity-parser/parser": "^0.20.2", "ajv": "^8.18.0", "ast-parents": "^0.0.1", "better-ajv-errors": "^2.0.2", "chalk": "^4.1.2", "commander": "^10.0.0", "cosmiconfig": "^8.0.0", "fast-diff": "^1.2.0", "glob": "^13.0.6", "ignore": "^5.2.4", "js-yaml": "^4.1.0", "latest-version": "^7.0.0", "lodash": "^4.17.21", "pluralize": "^8.0.0", "semver": "^7.5.2", "table": "^6.8.1", "text-table": "^0.2.0" }, "optionalDependencies": { "prettier": "^3.0.0" }, "bin": { "solhint": "solhint.js" } }, "sha512-w8prJP3kaf3G9hkmH5RmkCanvTULHWpdn+t4MieMEMZDhC2gFoUbRjS0TmslgxzGIItoOtP/J5PvU0FrvC08wA=="], "solidity-ast": ["solidity-ast@0.4.62", "", {}, "sha512-jSC7msQCkJXIzM8LlDjRZ5cif5w40g6THlXHFk3zchbL5dm3YLoBETvqPGo5KndYkftjhcs5kz1fnTu4d34lVQ=="], diff --git a/foundry.toml b/foundry.toml index a4e8e32..e8a6daa 100644 --- a/foundry.toml +++ b/foundry.toml @@ -30,7 +30,7 @@ libs = ["lib"] test = "test" script = "script" -gas_reports = ["XanV1", "XanV2", "XanV2Forwarder"] +gas_reports = ["XanV1", "XanV2"] allow_internal_expect_revert = true @@ -43,13 +43,10 @@ fail_on_revert = false fuzz = { runs = 10_000 } verbosity = 4 -[etherscan] -mainnet = { key = "${API_KEY_ETHERSCAN}" } -sepolia = { key = "${API_KEY_ETHERSCAN}" } [rpc_endpoints] -mainnet = "https://mainnet.infura.io/v3/${API_KEY_INFURA}" -sepolia = "https://sepolia.infura.io/v3/${API_KEY_INFURA}" +mainnet = "https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}" +sepolia = "https://eth-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY}" localhost = "http://localhost:8545" # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config diff --git a/justfile b/justfile index 5b45e8b..8903c81 100644 --- a/justfile +++ b/justfile @@ -57,3 +57,97 @@ check: @just build @echo "==> Testing..." @just test + +# --- Deployment --- + +# Simulate the XanV1 deployment (dry-run) +deploy-simulate initial-mint-recipient council chain *args: + @echo "Cleaning contracts to ensure reproducible build..." + @just clean + forge script script/DeployXanV1.s.sol:DeployXanV1 \ + --sig "run(address,address)" {{ initial-mint-recipient }} {{ council }} \ + --rpc-url {{ chain }} {{ args }} + +# Deploy XanV1 behind a UUPS proxy +deploy deployer initial-mint-recipient council chain *args: + @echo "Cleaning contracts to ensure reproducible build..." + @just clean + forge script script/DeployXanV1.s.sol:DeployXanV1 \ + --sig "run(address,address)" {{ initial-mint-recipient }} {{ council }} \ + --broadcast --rpc-url {{ chain }} --account {{ deployer }} {{ args }} + +# Simulate scheduling the council upgrade to XanV2 (dry-run) +schedule-council-upgrade-simulate proxy sender chain *args: + @echo "Cleaning contracts to ensure reproducible build..." + @just clean + forge script script/ScheduleCouncilUpgradeToXanV2.s.sol:ScheduleCouncilUpgradeToXanV2 \ + --sig "run(address)" {{ proxy }} \ + --rpc-url {{ chain }} --sender {{ sender }} {{ args }} + +# Schedule the council upgrade to XanV2 +schedule-council-upgrade deployer proxy sender chain *args: + @echo "Cleaning contracts to ensure reproducible build..." + @just clean + forge script script/ScheduleCouncilUpgradeToXanV2.s.sol:ScheduleCouncilUpgradeToXanV2 \ + --sig "run(address)" {{ proxy }} \ + --broadcast --rpc-url {{ chain }} --account {{ deployer }} --sender {{ sender }} {{ args }} + +# Simulate the upgrade to XanV2 (dry-run) +upgrade-simulate proxy owner chain *args: + @echo "Cleaning contracts to ensure reproducible build..." + @just clean + forge script script/UpgradeToXanV2.s.sol:UpgradeToXanV2 \ + --sig "run(address,address)" {{ proxy }} {{ owner }} \ + --rpc-url {{ chain }} --sender {{ owner }} {{ args }} + +# Upgrade the proxy to XanV2 +upgrade deployer proxy owner chain *args: + @echo "Cleaning contracts to ensure reproducible build..." + @just clean + forge script script/UpgradeToXanV2.s.sol:UpgradeToXanV2 \ + --sig "run(address,address)" {{ proxy }} {{ owner }} \ + --broadcast --rpc-url {{ chain }} --account {{ deployer }} {{ args }} + +# --- Verification --- + +# Verify an implementation contract on sourcify (e.g. contract=src/XanV1.sol:XanV1) +verify-impl-sourcify address contract chain *args: + ETHERSCAN_API_KEY="" forge verify-contract {{ address }} {{ contract }} \ + --chain {{ chain }} --verifier sourcify --watch {{ args }} + +# Verify an implementation contract on etherscan (e.g. contract=src/XanV1.sol:XanV1) +verify-impl-etherscan address contract chain *args: + forge verify-contract {{ address }} {{ contract }} \ + --chain {{ chain }} --verifier etherscan --watch {{ args }} + +# Verify an implementation contract on a custom explorer +verify-impl-custom address contract chain verifier-url *args: + forge verify-contract {{ address }} {{ contract }} \ + --chain {{ chain }} --verifier-url {{ verifier-url }} --watch {{ args }} + +# Verify an implementation contract on both sourcify and etherscan +verify-impl address contract chain: (verify-impl-sourcify address contract chain) (verify-impl-etherscan address contract chain) + +# Verify the ERC1967 proxy on sourcify (encodes the constructor args from the deploy inputs) +verify-proxy-sourcify proxy implementation initial-mint-recipient council chain *args: + ETHERSCAN_API_KEY="" forge verify-contract {{ proxy }} \ + lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy \ + --chain {{ chain }} --verifier sourcify --watch \ + --constructor-args "$(cast abi-encode 'c(address,bytes)' {{ implementation }} "$(cast calldata 'initializeV1(address,address)' {{ initial-mint-recipient }} {{ council }})")" {{ args }} + +# Verify the ERC1967 proxy on etherscan (encodes the constructor args from the deploy inputs) +verify-proxy-etherscan proxy implementation initial-mint-recipient council chain *args: + forge verify-contract {{ proxy }} \ + lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy \ + --chain {{ chain }} --verifier etherscan --watch \ + --constructor-args "$(cast abi-encode 'c(address,bytes)' {{ implementation }} "$(cast calldata 'initializeV1(address,address)' {{ initial-mint-recipient }} {{ council }})")" {{ args }} + +# Verify the ERC1967 proxy on a custom explorer (encodes the constructor args from the deploy inputs) +verify-proxy-custom proxy implementation initial-mint-recipient council chain verifier-url *args: + forge verify-contract {{ proxy }} \ + lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy \ + --chain {{ chain }} --verifier-url {{ verifier-url }} --watch \ + --constructor-args "$(cast abi-encode 'c(address,bytes)' {{ implementation }} "$(cast calldata 'initializeV1(address,address)' {{ initial-mint-recipient }} {{ council }})")" {{ args }} + +# Verify the ERC1967 proxy on both sourcify and etherscan +verify-proxy proxy implementation initial-mint-recipient council chain: (verify-proxy-sourcify proxy implementation initial-mint-recipient council chain) (verify-proxy-etherscan proxy implementation initial-mint-recipient council chain) diff --git a/package.json b/package.json index f0fbb79..110687e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "dependencies": { "@openzeppelin/upgrades-core": "^1.46.0", - "solhint": "^6.2.2" + "solhint": "^6.2.3" } } diff --git a/script/DeployXanV1.s.sol b/script/DeployXanV1.s.sol new file mode 100644 index 0000000..46a9193 --- /dev/null +++ b/script/DeployXanV1.s.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.30; + +import {Upgrades} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; + +import {Script} from "forge-std/Script.sol"; + +import {XanV1} from "../src/XanV1.sol"; + +contract DeployXanV1 is Script { + function run(address initialMintRecipient, address council) public returns (address proxy, address implementation) { + vm.startBroadcast(); + + proxy = Upgrades.deployUUPSProxy({ + contractName: "XanV1.sol:XanV1", + initializerData: abi.encodeCall(XanV1.initializeV1, (initialMintRecipient, council)) + }); + + implementation = XanV1(proxy).implementation(); + + vm.stopBroadcast(); + } +} diff --git a/script/ScheduleCouncilUpgradeToXanV2.s.sol b/script/ScheduleCouncilUpgradeToXanV2.s.sol new file mode 100644 index 0000000..b4d2fc8 --- /dev/null +++ b/script/ScheduleCouncilUpgradeToXanV2.s.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.30; + +import {Upgrades, Options} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; +import {Script} from "forge-std/Script.sol"; + +import {Parameters} from "../src/libs/Parameters.sol"; +import {XanV1} from "../src/XanV1.sol"; + +contract ScheduleCouncilUpgradeToXanV2 is Script { + error InvalidOwnerAddress(); + error InvalidVestingStart(); + error InvalidVestingDuration(); + + function run(address proxy) public returns (address implV2) { + Options memory opts; + + require(Parameters.INITIAL_OWNER != address(0), InvalidOwnerAddress()); + require(Parameters.VESTING_START != 0, InvalidVestingStart()); + require(Parameters.VESTING_DURATION != 0, InvalidVestingDuration()); + + // Bind the owner and vesting schedule into the implementation bytecode at deployment (the trusted step). The + // scheduled implementation address is fixed, so whoever later executes the (permissionless) upgrade cannot + // change these via calldata. Always use the `Parameters` constants so the vesting schedule cannot be picked + // wrong. + opts.constructorData = + abi.encode(Parameters.INITIAL_OWNER, Parameters.VESTING_START, Parameters.VESTING_DURATION); + + vm.startBroadcast(); + + implV2 = Upgrades.prepareUpgrade({contractName: "XanV2.sol:XanV2", opts: opts}); + + XanV1(proxy).scheduleCouncilUpgrade({impl: implV2}); + + vm.stopBroadcast(); + } +} diff --git a/script/Upgrade.s.sol b/script/Upgrade.s.sol deleted file mode 100644 index 1420821..0000000 --- a/script/Upgrade.s.sol +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -pragma solidity ^0.8.30; - -import {Upgrades} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; - -import {Script} from "forge-std/Script.sol"; - -import {XanV2} from "../src/drafts/XanV2.sol"; -import {XanV2Forwarder} from "../src/drafts/XanV2Forwarder.sol"; - -contract Upgrade is Script { - address internal constant _XAN_PROXY = address(0); - address internal constant _PROTOCOL_ADAPTER = address(0); - bytes32 internal constant _CALLDATA_CARRIER_LOGIC_REF = bytes32(0); - - function run() public { - vm.startBroadcast(); - - address xanV2Forwarder = address( - new XanV2Forwarder({ - xanProxy: address(this), - protocolAdapter: _PROTOCOL_ADAPTER, - calldataCarrierLogicRef: _CALLDATA_CARRIER_LOGIC_REF - }) - ); - - Upgrades.upgradeProxy({ - proxy: _XAN_PROXY, - contractName: "XanV2.sol:XanV2", - data: abi.encodeCall(XanV2.reinitializeFromV1, (xanV2Forwarder)) - }); - - vm.stopBroadcast(); - } -} diff --git a/script/UpgradeToXanV2.s.sol b/script/UpgradeToXanV2.s.sol new file mode 100644 index 0000000..a59ee21 --- /dev/null +++ b/script/UpgradeToXanV2.s.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.30; + +import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; +import {UnsafeUpgrades} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; + +import {Script} from "forge-std/Script.sol"; + +import {XanV1} from "../src/XanV1.sol"; +import {XanV2} from "../src/XanV2.sol"; + +contract UpgradeToXanV2 is Script { + function run(address proxy) public returns (address newImplementation) { + (address implV2, uint48 endTime) = XanV1(proxy).scheduledCouncilUpgrade(); + + require(endTime <= Time.timestamp(), XanV1.DelayPeriodNotEnded({endTime: endTime})); + + vm.startBroadcast(); + + // The owner and vesting start are baked into `implV2` at deployment (see `ScheduleCouncilUpgradeToXanV2`), + // so `reinitializeFromV1` takes no arguments and executing this upgrade cannot influence them. + UnsafeUpgrades.upgradeProxy({proxy: proxy, newImpl: implV2, data: abi.encodeCall(XanV2.reinitializeFromV1, ())}); + + vm.stopBroadcast(); + + newImplementation = XanV2(proxy).implementation(); + } +} diff --git a/src/XanV2.sol b/src/XanV2.sol new file mode 100644 index 0000000..cde543a --- /dev/null +++ b/src/XanV2.sol @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import { + ERC20BurnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import { + ERC20PermitUpgradeable +} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; +import { + ERC20VotesUpgradeable +} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; +import {NoncesUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/NoncesUpgradeable.sol"; +import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; +import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; + +import {IXanV2} from "./interfaces/IXanV2.sol"; +import {Council} from "./libs/Council.sol"; +import {Locking} from "./libs/Locking.sol"; +import {Voting} from "./libs/Voting.sol"; + +/// @title XanV2 +/// @author Anoma Foundation, 2026 +/// @notice The Anoma (XAN) token contract implementation version 2. +/// * removes the V1 built-in governance and replaces it with simple ownership +/// * adds a linear vesting mechanism unlocking locked tokens +/// * adds ERC-20 vote delegation and checkpoints (`ERC20Votes`) on a timestamp-based clock (EIP-6372) +/// @custom:security-contact security@anoma.foundation +/// @custom:oz-upgrades-from XanV1 +contract XanV2 is + IXanV2, + Initializable, + ERC20Upgradeable, + ERC20BurnableUpgradeable, + ERC20PermitUpgradeable, + ERC20VotesUpgradeable, + OwnableUpgradeable, + UUPSUpgradeable +{ + /// @notice A struct containing data associated with the V1 implementation. + /// @param lockingData The state associated with the locking mechanism of the V1 implementation. + /// @param votingData The state associated with the (now-defunct) V1 voting mechanism. + /// @param councilData The state associated with the (now-defunct) V1 governance council. + /// @dev Kept identical to `XanV1` so that the inherited V1 storage layout is read correctly. Only `lockingData` + /// is read by V2; `votingData` and `councilData` are retained solely for layout compatibility. + struct ImplementationData { + Locking.Data lockingData; + Voting.Data votingData; + Council.Data councilData; + } + + /// @notice The [ERC-7201](https://eips.ethereum.org/EIPS/eip-7201) storage of the V1 contract. + /// @custom:storage-location erc7201:anoma.storage.Xan.v1 + struct XanV1Storage { + mapping(address currentProxyImplementation => ImplementationData) implementationSpecificData; + } + + /// @notice The [ERC-7201](https://eips.ethereum.org/EIPS/eip-7201) storage of the V2 contract. + /// @param unlocked The cumulative amount each account has already unlocked (moved from locked to spendable). + /// @custom:storage-location erc7201:anoma.storage.Xan.v2 + struct XanV2Storage { + mapping(address owner => uint256) unlocked; + } + + /// @notice The address of the single mainnet V1 implementation under which the locked balances + /// (the vesting principal) are stored in the inherited V1 storage. + address internal constant _XAN_V1_IMPLEMENTATION = 0x03997b568FE70E91A53c458DC19dc29e0bC2735E; + + /// @notice The ERC-7201 storage location of the Xan V1 contract (see https://eips.ethereum.org/EIPS/eip-7201). + /// @dev Obtained from + /// `keccak256(abi.encode(uint256(keccak256("anoma.storage.Xan.v1")) - 1)) & ~bytes32(uint256(0xff))`. + bytes32 internal constant _XAN_V1_STORAGE_LOCATION = + 0x52f7d5fb153315ca313a5634db151fa7e0b41cd83fe6719e93ed3cd02b69d200; + + /// @notice The ERC-7201 storage location of the Xan V2 contract (see https://eips.ethereum.org/EIPS/eip-7201). + /// @dev Obtained from + /// `keccak256(abi.encode(uint256(keccak256("anoma.storage.Xan.v2")) - 1)) & ~bytes32(uint256(0xff))`. + bytes32 internal constant _XAN_V2_STORAGE_LOCATION = + 0x52ac9b9514a24171c0416c0576d612fe5fab9f5a41dcf77ddbf6be60ca9da600; + + /// @notice The initial owner of the proxy. + /// @dev Read only once, by `__Ownable_init` in `reinitializeFromV1`, to set the initial owner. + /// Afterwards the live owner lives in `OwnableUpgradeable` storage and can change via `transferOwnership`, so this + /// immutable may become stale and must never be read as the current owner. This is intentional and binds the + /// initial owner to the V2 implementation bytecode that V1 governance votes on before the upgrade, instead of + /// providing it via a `reinitializeFromV1` argument. This is critical because `reinitializeFromV1` can be called + /// permissionlessly by anyone after the upgrade delay has passed. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address private immutable _INITIAL_OWNER; + + /// @notice The timestamp at which the linear vesting of the formerly locked balances starts. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint48 private immutable _VESTING_START; + + /// @notice The duration over which the formerly locked balances vest linearly. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint48 private immutable _VESTING_DURATION; + + /// @notice Thrown when an account tries to move more than its unlocked (spendable) balance. + error UnlockedBalanceInsufficient(address sender, uint256 unlockedBalance, uint256 valueToLock); + + /// @notice Thrown when `unlock` is called but no tokens have vested since the last unlock. + error NothingToUnlock(address account); + + /// @notice Thrown if the zero address is provided as owner in the constructor. + error ZeroOwnerNotAllowed(); + + /// @notice Disables the initializers on the implementation contract to prevent it from being left uninitialized, + /// and binds the owner and vesting schedule into the implementation bytecode. + /// @param initialOwner The owner of the proxy after the upgrade (e.g. a multisig or DAO). + /// @param vestingStartTimestamp The timestamp at which the linear vesting of the formerly locked balances starts. + /// @param vestingDuration The duration over which the formerly locked balances vest linearly. + /// @custom:oz-upgrades-unsafe-allow constructor state-variable-immutable + constructor(address initialOwner, uint48 vestingStartTimestamp, uint48 vestingDuration) { + require(initialOwner != address(0), ZeroOwnerNotAllowed()); + _INITIAL_OWNER = initialOwner; + _VESTING_START = vestingStartTimestamp; + _VESTING_DURATION = vestingDuration; + _disableInitializers(); + } + + /// @notice Reinitializes the contract after the upgrade from V1, installing the owner and scheduling vesting. + /// @custom:oz-upgrades-validate-as-initializer + /// @custom:oz-upgrades-unsafe-allow incorrect-initializer-order + /// @custom:oz-upgrades-unsafe-allow missing-initializer-call + function reinitializeFromV1() external reinitializer(2) /* solhint-disable-line comprehensive-interface */ { + __ERC20Votes_init(); + __Ownable_init({initialOwner: _INITIAL_OWNER}); + + // The V1 supply was minted before `ERC20Votes` existed, so the voting total-supply checkpoint is empty and + // `getPastTotalSupply` would read 0. Seed it once with the existing supply: + // `from == address(0)` adds to the total-supply checkpoint, and because nothing has been delegated yet the `to` + // argument moves no delegate votes. + _transferVotingUnits({from: address(0), to: address(this), amount: totalSupply()}); + + emit VestingScheduled({start: _VESTING_START, duration: _VESTING_DURATION}); + } + + /// @inheritdoc IXanV2 + function unlock() external override returns (uint256 value) { + XanV2Storage storage xanV2Storage = _getXanV2Storage(); + + uint256 principal = _principalOf(msg.sender); + uint256 vested = _vestedAmount(principal); + uint256 alreadyUnlocked = xanV2Storage.unlocked[msg.sender]; + + // `vested` is monotonically non-decreasing in time and capped at `principal`, so it can never drop below + // `alreadyUnlocked`. Revert instead of emitting a no-op unlock. + require(vested > alreadyUnlocked, NothingToUnlock({account: msg.sender})); + + unchecked { + // Safe: checked `vested > alreadyUnlocked` above. + value = vested - alreadyUnlocked; + } + + xanV2Storage.unlocked[msg.sender] = vested; + + emit Unlocked({account: msg.sender, value: value}); + } + + /// @inheritdoc IXanV2 + function implementation() external view override returns (address thisImplementation) { + thisImplementation = ERC1967Utils.getImplementation(); + } + + /// @inheritdoc IXanV2 + function unlockedBalanceOf(address from) public view override returns (uint256 unlockedBalance) { + unlockedBalance = balanceOf(from) - lockedBalanceOf(from); + } + + /// @inheritdoc IXanV2 + function lockedBalanceOf(address from) public view override returns (uint256 lockedBalance) { + // The still-locked balance is the V1 principal minus what the account has already unlocked. + // `unlocked[from] <= principal` is maintained by `unlock` (capped at `_vestedAmount(principal) <= principal`). + lockedBalance = _principalOf(from) - _getXanV2Storage().unlocked[from]; + } + + /// @notice Returns the next unused nonce for an address. + /// @param owner The address to query the nonce for. + /// @return nonce The next unused nonce. + /// @dev Nonces will be used for both, `permit` (`ERC20PermitUpgradeable`) and `delegateBySig` + /// (`ERC20VotesUpgradeable`) signatures, both of which extend `NoncesUpgradeable`. + function nonces(address owner) + public + view + override(ERC20PermitUpgradeable, NoncesUpgradeable) + returns (uint256 nonce) + { + nonce = super.nonces(owner); + } + + /// @inheritdoc IXanV2 + function claimableBalanceOf(address account) public view override returns (uint256 value) { + uint256 principal = _principalOf(account); + uint256 vested = _vestedAmount(principal); + uint256 alreadyUnlocked = _getXanV2Storage().unlocked[account]; + + value = vested > alreadyUnlocked ? vested - alreadyUnlocked : 0; + } + + /// @inheritdoc IXanV2 + function vestingStart() public view override returns (uint48 start) { + start = _VESTING_START; + } + + /// @inheritdoc IXanV2 + function vestingEnd() public view override returns (uint48 end) { + end = _VESTING_START + _VESTING_DURATION; + } + + /// @notice Returns the current timepoint used for voting checkpoints (EIP-6372). + /// @dev Overrides the default block-number clock with the block timestamp, so the voting clock matches the + /// timestamp-based vesting schedule and any consumer (e.g. a governor) denominates its windows in seconds. + /// @return timepoint The current block timestamp. + function clock() public view override returns (uint48 timepoint) { + timepoint = Time.timestamp(); + } + + // The `CLOCK_MODE` name is mandated by EIP-6372 and cannot follow the mixed-case convention. + // solhint-disable func-name-mixedcase + + /// @notice Returns the machine-readable description of the clock (EIP-6372). + /// @return mode The clock mode, `mode=timestamp`. + function CLOCK_MODE() public pure override returns (string memory mode) { + mode = "mode=timestamp"; + } + + // solhint-enable func-name-mixedcase + + /// @notice Updates the balances. Only the unlocked token balances can be moved, except for the minting case, + /// where `from == address(0)`. + /// @param from The address to take the tokens from. + /// @param to The address to give the tokens to. + /// @param value The amount of tokens to update that must be unlocked. + function _update(address from, address to, uint256 value) + internal + override(ERC20Upgradeable, ERC20VotesUpgradeable) + { + // Require the unlocked balance to be at least the updated value, except for the minting case, + // where `from == address(0)`. + // In this case, tokens are created ex-nihilo and formally sent from `address(0)` to the `to` address + // without balance checks. + if (from != address(0)) { + uint256 unlockedBalance = unlockedBalanceOf(from); + + require( + value < unlockedBalance + 1, + UnlockedBalanceInsufficient({sender: from, unlockedBalance: unlockedBalance, valueToLock: value}) + ); + } + + super._update({from: from, to: to, value: value}); + } + + /// @notice Authorizes an upgrade. Restricted to the owner (e.g. a multisig or DAO). + /// @param newImpl The new implementation to authorize the upgrade to. + function _authorizeUpgrade(address newImpl) internal view override onlyOwner { + (newImpl); + } + + /// @notice Returns the amount of an account's V1 principal that has vested by the current timestamp. + /// @param principal The account's formerly locked V1 balance. + /// @return vested The vested amount, linearly interpolated and capped at `principal`. + function _vestedAmount(uint256 principal) internal view returns (uint256 vested) { + uint48 start = _VESTING_START; + uint48 nowTs = Time.timestamp(); + + if (nowTs < start + 1) { + return vested = 0; + } + + uint48 elapsed = nowTs - start; + if (elapsed > _VESTING_DURATION - 1) { + return vested = principal; + } + + vested = (principal * elapsed) / _VESTING_DURATION; + } + + /// @notice Returns the formerly locked V1 balance of an account that is the principal subject to vesting. + /// @param account The account to query. + /// @return principal The V1 locked balance of the account. + function _principalOf(address account) internal view returns (uint256 principal) { + principal = + _getXanV1Storage().implementationSpecificData[_implementationV1()].lockingData.lockedBalances[account]; + } + + /// @notice Returns the V1 implementation address under which the vesting principal is stored. + /// @dev Declared `virtual` so tests can point at a locally deployed V1 implementation; in production it is the + /// single mainnet V1 implementation. + /// @return implementationV1 The V1 implementation address. + function _implementationV1() internal view virtual returns (address implementationV1) { + implementationV1 = _XAN_V1_IMPLEMENTATION; + } + + /// @notice Returns the storage from the Xan V1 storage location. + /// @return xanV1Storage The data associated with the Xan V1 token storage. + function _getXanV1Storage() internal pure returns (XanV1Storage storage xanV1Storage) { + // solhint-disable no-inline-assembly + + // slither-disable-next-line assembly + assembly { + xanV1Storage.slot := _XAN_V1_STORAGE_LOCATION + } + + // solhint-enable no-inline-assembly + } + + /// @notice Returns the storage from the Xan V2 storage location. + /// @return xanV2Storage The data associated with the Xan V2 token storage. + function _getXanV2Storage() internal pure returns (XanV2Storage storage xanV2Storage) { + // solhint-disable no-inline-assembly + + // slither-disable-next-line assembly + assembly { + xanV2Storage.slot := _XAN_V2_STORAGE_LOCATION + } + + // solhint-enable no-inline-assembly + } +} diff --git a/src/drafts/XanV2.sol b/src/drafts/XanV2.sol deleted file mode 100644 index 8bacffb..0000000 --- a/src/drafts/XanV2.sol +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -pragma solidity ^0.8.30; - -import {Parameters} from "../libs/Parameters.sol"; -import {XanV1} from "../XanV1.sol"; -import {IXanV2} from "./interfaces/IXanV2.sol"; - -/// @title XanV2 -/// @author Anoma Foundation, 2025 -/// @notice A draft of the Anoma (XAN) token contract implementation version 2. -/// This is used to test that `XanV1` can be upgraded to subsequent version. -/// @custom:security-contact security@anoma.foundation -/// @custom:oz-upgrades-from XanV1 -contract XanV2 is IXanV2, XanV1 { - /// @notice The [ERC-7201](https://eips.ethereum.org/EIPS/eip-7201) storage of the contract. - /// @custom:storage-location erc7201:anoma.storage.Xan.v2 - /// @param forwarder The forwarder being allowed to mint more tokens. - struct XanV2Storage { - address forwarder; - } - - /// @notice The ERC-7201 storage location of the Xan V2 contract (see https://eips.ethereum.org/EIPS/eip-7201). - /// @dev Obtained from - /// `keccak256(abi.encode(uint256(keccak256("anoma.storage.Xan.v2")) - 1)) & ~bytes32(uint256(0xff))`. - bytes32 internal constant _XAN_V2_STORAGE_LOCATION = - 0x52ac9b9514a24171c0416c0576d612fe5fab9f5a41dcf77ddbf6be60ca9da600; - - /// @notice Limits functions to be callable only by the forwarder address. - modifier onlyForwarder() { - _checkForwarder(); - _; - } - - /// @notice Initializes the XanV2 contract. - /// @param initialMintRecipient The distributor address being the initial recipient of the minted tokens and - /// authorized caller of the `transferAndLock` function. - /// @param council The address of the governance council contract. - /// @param xanV2Forwarder The XanV2 forwarder contract. - /// @custom:oz-upgrades-validate-as-initializer - function initializeV2( /* solhint-disable-line comprehensive-interface*/ - address initialMintRecipient, - address council, - address xanV2Forwarder - ) - external - reinitializer(2) - { - // Initialize inherited contracts - __ERC20_init({name_: Parameters.NAME, symbol_: Parameters.SYMBOL}); - __ERC20Permit_init({name: Parameters.NAME}); - __ERC20Burnable_init(); - - // Initialize the XanV1 contract - _mint(initialMintRecipient, Parameters.SUPPLY); - _getCouncilData().council = council; - - // Initialize the XanV2 contract - _getXanV2Storage().forwarder = xanV2Forwarder; - } - - /* solhint-disable comprehensive-interface */ - - /// @notice Reinitializes the XanV2 contract after an upgrade from XanV1. - /// @param xanV2Forwarder The XanV2 forwarder contract. - /// @custom:oz-upgrades-unsafe-allow missing-initializer-call - /// @custom:oz-upgrades-validate-as-initializer - function reinitializeFromV1(address xanV2Forwarder) external reinitializer(2) { - // Initialize the XanV2 contract - _getXanV2Storage().forwarder = xanV2Forwarder; - } - - /* solhint-enable comprehensive-interface */ - - /// @inheritdoc IXanV2 - function mint(address account, uint256 value) external override onlyForwarder { - _mint(account, value); - } - - /// @inheritdoc IXanV2 - function forwarder() public view override returns (address addr) { - addr = _getXanV2Storage().forwarder; - } - - /// @notice Throws if the sender is not the forwarder. - function _checkForwarder() internal view { - if (forwarder() != _msgSender()) { - revert UnauthorizedCaller({caller: _msgSender()}); - } - } - - /// @notice Returns the storage from the Xan V2 storage location. - /// @return xanV2Storage The data associated with the Xan V2 token storage. - function _getXanV2Storage() internal pure returns (XanV2Storage storage xanV2Storage) { - // solhint-disable no-inline-assembly - { - // slither-disable-next-line assembly - assembly { - xanV2Storage.slot := _XAN_V2_STORAGE_LOCATION - } - } - // solhint-enable no-inline-assembly - } -} diff --git a/src/drafts/XanV2Forwarder.sol b/src/drafts/XanV2Forwarder.sol deleted file mode 100644 index 1d8190a..0000000 --- a/src/drafts/XanV2Forwarder.sol +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -pragma solidity ^0.8.30; - -import {XanV2} from "./XanV2.sol"; - -/// @title XanV2Forwarder -/// @author Anoma Foundation, 2025 -/// @notice A draft of a XanV2 forwarder contract minting new XAN tokens for a recipient. -/// Note, that the forwarder contract is the recipient of newly minted XAN tokens. -/// @custom:security-contact security@anoma.foundation -contract XanV2Forwarder { - XanV2 internal immutable _XAN_PROXY; - address internal immutable _PROTOCOL_ADAPTER; - bytes32 internal immutable _CALLDATA_CARRIER_RESOURCE_KIND; - - error AddressZero(); - error Bytes32Zero(); - error UnauthorizedCaller(address caller); - error InvalidFunctionSelector(bytes4 expected, bytes4 actual); - error InvalidMintRecipient(address recipient); - - /// @notice Constructs a forwarder. - /// @param xanProxy The of the XAN proxy contract. - /// @param protocolAdapter The address of the protocol adapter. - /// @param calldataCarrierLogicRef The logic reference of the associated calldata carrier resource. - constructor(address xanProxy, address protocolAdapter, bytes32 calldataCarrierLogicRef) { - // Zero checks - if (xanProxy == address(0) || protocolAdapter == address(0)) { - revert AddressZero(); - } - if (calldataCarrierLogicRef == bytes32(0)) { - revert Bytes32Zero(); - } - - _XAN_PROXY = XanV2(xanProxy); - _PROTOCOL_ADAPTER = protocolAdapter; - _CALLDATA_CARRIER_RESOURCE_KIND = - _kind({logicRef: calldataCarrierLogicRef, labelRef: sha256(abi.encode(address(this)))}); - } - - /* solhint-disable comprehensive-interface */ - - /// @notice Forwards mint calls to the XAN proxy contract pointing to the `XanV2` implementation. - /// @param input The `bytes` encoded mint calldata (including the `bytes4` function selector). - /// @return output The empty output of the call. - function forwardCall(bytes calldata input) external returns (bytes memory output) { - if (msg.sender != _PROTOCOL_ADAPTER) { - revert UnauthorizedCaller(msg.sender); - } - - bytes4 selector = bytes4(input[:4]); - - bytes memory args = input[4:]; - - // Check that that the mint function is the call target. - if (selector != XanV2.mint.selector) { - revert InvalidFunctionSelector({expected: XanV2.mint.selector, actual: selector}); - } - // NOTE: The recipient address is not needed on the EVM side, because the forwarder receives the tokens. - (address recipient, uint256 value) = abi.decode(args, (address, uint256)); - if (recipient == address(this)) { - revert InvalidMintRecipient({recipient: address(this)}); - } - - output = bytes(""); - - // Mint tokens for the forwarder contract. - // NOTE: The calldata carrier resource must ensure that the recipient receives corresponding XAN resources. - _XAN_PROXY.mint({account: address(this), value: value}); - } - - /* solhint-enable comprehensive-interface */ - - /// @notice Computes the resource kind. - /// @param logicRef The resource logic reference. - /// @param labelRef The resource label reference. - /// @return k The computed kind. - function _kind(bytes32 logicRef, bytes32 labelRef) internal pure returns (bytes32 k) { - k = sha256(abi.encode(logicRef, labelRef)); - } -} diff --git a/src/drafts/interfaces/IXanV2.sol b/src/drafts/interfaces/IXanV2.sol deleted file mode 100644 index 94bb7da..0000000 --- a/src/drafts/interfaces/IXanV2.sol +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -pragma solidity ^0.8.30; - -import {IXanV1} from "../../interfaces/IXanV1.sol"; - -/// @title IXanV2 -/// @author Anoma Foundation, 2025 -/// @notice A draft of the interface of the Anoma (XAN) token contract version 2. -/// @custom:security-contact security@anoma.foundation -interface IXanV2 is IXanV1 { - /// @notice Mints tokens for an account. - /// @param account The receiving account. - /// @param value The value to be minted. - /// @dev Can only be called by the `XanV2Forwarder` contract that has been created during initialization of v2. - function mint(address account, uint256 value) external; - - /// @notice Returns the address of the forwarder contract being permitted to call the `mint` function. - /// @return addr The forwarder address. - function forwarder() external view returns (address addr); -} diff --git a/src/interfaces/IXanV2.sol b/src/interfaces/IXanV2.sol new file mode 100644 index 0000000..5175b82 --- /dev/null +++ b/src/interfaces/IXanV2.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +/// @title IXanV2 +/// @author Anoma Foundation, 2026 +/// @notice The interface of the Anoma (XAN) token contract version 2. +/// @custom:security-contact security@anoma.foundation +interface IXanV2 { + /// @notice Emitted when the vesting schedule for the formerly locked balances is set, at upgrade time. + /// @param start The timestamp at which vesting starts (may be in the future). + /// @param duration The duration over which the locked balances vest linearly. + event VestingScheduled(uint48 start, uint48 duration); + + /// @notice Emitted when an account unlocks vested tokens. + /// @param account The account that unlocked tokens. + /// @param value The amount of tokens that became spendable. + event Unlocked(address indexed account, uint256 value); + + /// @notice Unlocks the tokens of the caller that have vested since the last unlock, making them spendable. + /// @return value The amount of tokens that became spendable. + function unlock() external returns (uint256 value); + + /// @notice Returns the amount of tokens the account can unlock right now (vested but not yet unlocked). + /// @param account The account to query. + /// @return value The currently claimable amount. + function claimableBalanceOf(address account) external view returns (uint256 value); + + /// @notice Returns the unlocked (spendable) token balance of an account. + /// @param from The account to query. + /// @return unlockedBalance The unlocked balance. + function unlockedBalanceOf(address from) external view returns (uint256 unlockedBalance); + + /// @notice Returns the still-locked token balance of an account that has not vested or not been unlocked yet. + /// @param from The account to query. + /// @return lockedBalance The locked balance. + function lockedBalanceOf(address from) external view returns (uint256 lockedBalance); + + /// @notice Returns the timestamp at which vesting started. + /// @return start The vesting start timestamp. + function vestingStart() external view returns (uint48 start); + + /// @notice Returns the timestamp at which vesting ends and all locked balances are fully vested. + /// @return end The vesting end timestamp. + function vestingEnd() external view returns (uint48 end); + + /// @notice Returns the implementation + /// @return impl The implementation. + function implementation() external view returns (address impl); +} diff --git a/src/libs/Parameters.sol b/src/libs/Parameters.sol index 4305c78..856a6c7 100644 --- a/src/libs/Parameters.sol +++ b/src/libs/Parameters.sol @@ -6,6 +6,8 @@ pragma solidity ^0.8.30; /// @notice A library containing the token parameters. /// @custom:security-contact security@anoma.foundation library Parameters { + /* ========== Xan V1 ========== */ + /// @notice The name of the token. string internal constant NAME = "Anoma"; @@ -26,4 +28,18 @@ library Parameters { /// @notice The delay duration that must pass to upgrade to a new implementation. uint32 internal constant DELAY_DURATION = 2 weeks; + + /* ========== Xan V2 ========== */ + + /// @notice The timestamp at which the linear vesting of the formerly locked balances starts in `XanV2`. + /// @dev Thu Oct 01 2026 12:00:00 UTC. + uint48 internal constant VESTING_START = 1_790_856_000; + + /// @notice The duration over which formerly locked balances vest linearly in `XanV2`. + /// @dev Three years. Vesting is continuous (every block). + uint48 internal constant VESTING_DURATION = 3 * 365 days; + + /// @notice The initial owner who can upgrade the XAN proxy V2. + //! IMPORTANT: This address is currently a placeholder and must be changed before scheduling the upgrade to V2. + address internal constant INITIAL_OWNER = 0x0000000000000000000000000000000000000000; } diff --git a/test/XanV1.upgrade.t.sol b/test/XanV1.upgrade.t.sol index 3d12375..a5b2d5f 100644 --- a/test/XanV1.upgrade.t.sol +++ b/test/XanV1.upgrade.t.sol @@ -8,9 +8,9 @@ import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; import {Upgrades, UnsafeUpgrades, Options} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; import {Test} from "forge-std/Test.sol"; -import {XanV2} from "../src/drafts/XanV2.sol"; import {Parameters} from "../src/libs/Parameters.sol"; import {XanV1} from "../src/XanV1.sol"; +import {XanV2} from "../src/XanV2.sol"; contract XanV1UpgradeTest is Test { using UnsafeUpgrades for address; @@ -37,6 +37,9 @@ contract XanV1UpgradeTest is Test { ); Options memory opts; + // `XanV2` binds the owner and vesting schedule as constructor immutables, so the prepared implementations + // need encoded constructor arguments. + opts.constructorData = abi.encode(_defaultSender, Parameters.VESTING_START, Parameters.VESTING_DURATION); _voterProposedImpl = Upgrades.prepareUpgrade({contractName: "XanV2.sol:XanV2", opts: opts}); _voterProposedImpl2 = Upgrades.prepareUpgrade({contractName: "XanV2.sol:XanV2", opts: opts}); _councilProposedImpl = Upgrades.prepareUpgrade({contractName: "XanV2.sol:XanV2", opts: opts}); @@ -292,26 +295,7 @@ contract XanV1UpgradeTest is Test { emit IERC1967.Upgraded(_councilProposedImpl); address(_xanProxy) - .upgradeProxy({ - newImpl: _councilProposedImpl, data: abi.encodeCall(XanV2.reinitializeFromV1, (address(uint160(1)))) - }); - } - - function test_upgradeToAndCall_resets_the_governance_council_address() public { - vm.prank(_COUNCIL); - _xanProxy.scheduleCouncilUpgrade(_councilProposedImpl); - - skip(Parameters.DELAY_DURATION); - - vm.expectEmit(address(_xanProxy)); - emit IERC1967.Upgraded(_councilProposedImpl); - - address(_xanProxy) - .upgradeProxy({ - newImpl: _councilProposedImpl, data: abi.encodeCall(XanV2.reinitializeFromV1, (address(uint160(1)))) - }); - - assertEq(_xanProxy.governanceCouncil(), address(0)); + .upgradeProxy({newImpl: _councilProposedImpl, data: abi.encodeCall(XanV2.reinitializeFromV1, ())}); } function test_upgradeToAndCall_allows_upgrade_to_the_current_implementation() public { diff --git a/test/XanV2.constructor.t.sol b/test/XanV2.constructor.t.sol new file mode 100644 index 0000000..48b2131 --- /dev/null +++ b/test/XanV2.constructor.t.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {SlotDerivation} from "@openzeppelin/contracts/utils/SlotDerivation.sol"; +import {UnsafeUpgrades} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; +import {Test} from "forge-std/Test.sol"; + +import {XanV2} from "../src/XanV2.sol"; + +contract XanV2ConstructorTest is Test { + // Values distinct from the `Parameters` constants, so the assertions prove the getters read the constructor + // arguments rather than hard-coded parameters. + uint48 internal constant _VESTING_START = 1_000_000; + uint48 internal constant _VESTING_DURATION = 3_000_000; + + address internal immutable _INITIAL_OWNER = makeAddr("owner"); + + XanV2 internal _impl; + + function setUp() public { + _impl = new XanV2({ + initialOwner: _INITIAL_OWNER, vestingStartTimestamp: _VESTING_START, vestingDuration: _VESTING_DURATION + }); + } + + function test_constructor_disables_initializers_on_the_implementation() public { + vm.expectRevert(Initializable.InvalidInitialization.selector, address(_impl)); + _impl.reinitializeFromV1(); + } + + function test_constructor_reverts_if_the_owner_is_the_zero_address() public { + // The revert happens inside the contract being created, whose address we can predict from this contract's nonce. + address predictedImpl = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); + vm.expectRevert(XanV2.ZeroOwnerNotAllowed.selector, predictedImpl); + new XanV2({initialOwner: address(0), vestingStartTimestamp: _VESTING_START, vestingDuration: _VESTING_DURATION}); + } + + function test_constructor_binds_the_initial_owner() public { + address proxy = UnsafeUpgrades.deployUUPSProxy(address(_impl), abi.encodeCall(XanV2.reinitializeFromV1, ())); + + assertEq(XanV2(proxy).owner(), _INITIAL_OWNER); + } + + function test_constructor_sets_initialized_to_the_maximal_value() public view { + bytes32 slot = SlotDerivation.erc7201Slot("openzeppelin.storage.Initializable"); + uint64 initialized = uint64(uint256(vm.load(address(_impl), slot))); + + assertEq(initialized, type(uint64).max); + } + + function test_constructor_sets_the_vesting_start() public view { + assertEq(_impl.vestingStart(), _VESTING_START); + } + + function test_constructor_sets_the_vesting_end() public view { + assertEq(_impl.vestingEnd(), _VESTING_START + _VESTING_DURATION); + } +} diff --git a/test/XanV2.nonces.t.sol b/test/XanV2.nonces.t.sol new file mode 100644 index 0000000..86df049 --- /dev/null +++ b/test/XanV2.nonces.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +import {NoncesUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/NoncesUpgradeable.sol"; + +import {XanV2Fixture} from "./fixtures/XanV2Fixture.sol"; + +/// @notice Verifies that `permit` (from `ERC20PermitUpgradeable`) and `delegateBySig` (from `ERC20VotesUpgradeable`) +/// draw from a single, shared `NoncesUpgradeable` counter per account, as wired by the `nonces` override. +contract XanV2NoncesTest is XanV2Fixture { + uint256 internal constant _ALICE_PRIVATE_KEY = 0xA11CE; + address internal immutable _ALICE = vm.addr(_ALICE_PRIVATE_KEY); + address internal immutable _SPENDER = makeAddr("spender"); + address internal immutable _DELEGATEE = makeAddr("delegatee"); + + /// @notice A `permit` and a subsequent `delegateBySig` for the same account advance and consume the same nonce + /// counter: the delegation must use the nonce left behind by the permit. + function test_nonces_are_shared_between_permit_then_delegateBySig() public { + uint256 deadline = block.timestamp + 1 hours; + + assertEq(_xanV2Proxy.nonces(_ALICE), 0); + + // `permit` consumes nonce 0 (its nonce is taken from the shared counter internally). + (uint8 pv, bytes32 pr, bytes32 ps) = + _signPermit({owner: _ALICE, spender: _SPENDER, value: 500, nonce: 0, deadline: deadline}); + _xanV2Proxy.permit({owner: _ALICE, spender: _SPENDER, value: 500, deadline: deadline, v: pv, r: pr, s: ps}); + + assertEq(_xanV2Proxy.allowance(_ALICE, _SPENDER), 500); + assertEq(_xanV2Proxy.nonces(_ALICE), 1); + + // `delegateBySig` must now use nonce 1 — the value the permit left in the shared counter. + (uint8 dv, bytes32 dr, bytes32 ds) = _signDelegation(_DELEGATEE, 1, deadline); + _xanV2Proxy.delegateBySig({delegatee: _DELEGATEE, nonce: 1, expiry: deadline, v: dv, r: dr, s: ds}); + + assertEq(_xanV2Proxy.delegates(_ALICE), _DELEGATEE); + assertEq(_xanV2Proxy.nonces(_ALICE), 2); + } + + /// @notice The reverse direction: a `delegateBySig` consumes nonce 0, so a subsequent `permit` must use nonce 1. + function test_nonces_are_shared_between_delegateBySig_then_permit() public { + uint256 deadline = block.timestamp + 1 hours; + + // `delegateBySig` consumes nonce 0. + (uint8 dv, bytes32 dr, bytes32 ds) = _signDelegation(_DELEGATEE, 0, deadline); + _xanV2Proxy.delegateBySig({delegatee: _DELEGATEE, nonce: 0, expiry: deadline, v: dv, r: dr, s: ds}); + + assertEq(_xanV2Proxy.delegates(_ALICE), _DELEGATEE); + assertEq(_xanV2Proxy.nonces(_ALICE), 1); + + // `permit` must now sign over nonce 1 — the value the delegation left in the shared counter. + (uint8 pv, bytes32 pr, bytes32 ps) = + _signPermit({owner: _ALICE, spender: _SPENDER, value: 500, nonce: 1, deadline: deadline}); + _xanV2Proxy.permit({owner: _ALICE, spender: _SPENDER, value: 500, deadline: deadline, v: pv, r: pr, s: ps}); + + assertEq(_xanV2Proxy.allowance(_ALICE, _SPENDER), 500); + assertEq(_xanV2Proxy.nonces(_ALICE), 2); + } + + /// @notice A nonce already consumed by `permit` cannot be reused by `delegateBySig`, proving the counter is shared + /// rather than per-extension. + function test_nonces_cannot_be_reused_across_signature_types() public { + uint256 deadline = block.timestamp + 1 hours; + + (uint8 pv, bytes32 pr, bytes32 ps) = + _signPermit({owner: _ALICE, spender: _SPENDER, value: 500, nonce: 0, deadline: deadline}); + _xanV2Proxy.permit({owner: _ALICE, spender: _SPENDER, value: 500, deadline: deadline, v: pv, r: pr, s: ps}); + + // Sign a delegation over the already-consumed nonce 0; the shared counter now stands at 1. + (uint8 dv, bytes32 dr, bytes32 ds) = _signDelegation(_DELEGATEE, 0, deadline); + vm.expectRevert( + abi.encodeWithSelector(NoncesUpgradeable.InvalidAccountNonce.selector, _ALICE, 1), address(_xanV2Proxy) + ); + _xanV2Proxy.delegateBySig({delegatee: _DELEGATEE, nonce: 0, expiry: deadline, v: dv, r: dr, s: ds}); + } + + function _signPermit(address owner, address spender, uint256 value, uint256 nonce, uint256 deadline) + internal + view + returns (uint8 v, bytes32 r, bytes32 s) + { + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + spender, + value, + nonce, + deadline + ) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _xanV2Proxy.DOMAIN_SEPARATOR(), structHash)); + (v, r, s) = vm.sign(_ALICE_PRIVATE_KEY, digest); + } + + function _signDelegation(address delegatee, uint256 nonce, uint256 expiry) + internal + view + returns (uint8 v, bytes32 r, bytes32 s) + { + bytes32 structHash = keccak256( + abi.encode( + keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"), delegatee, nonce, expiry + ) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _xanV2Proxy.DOMAIN_SEPARATOR(), structHash)); + (v, r, s) = vm.sign(_ALICE_PRIVATE_KEY, digest); + } +} diff --git a/test/XanV2.reinitialization.t.sol b/test/XanV2.reinitialization.t.sol new file mode 100644 index 0000000..9870cb2 --- /dev/null +++ b/test/XanV2.reinitialization.t.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {Upgrades, UnsafeUpgrades} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; +import {Test} from "forge-std/Test.sol"; + +import {IXanV2} from "../src/interfaces/IXanV2.sol"; +import {Parameters} from "../src/libs/Parameters.sol"; +import {XanV1} from "../src/XanV1.sol"; +import {XanV2} from "../src/XanV2.sol"; +import {MockXanV2} from "./mocks/MockXanV2.sol"; + +contract XanV2ReinitializationTest is Test { + address internal immutable _COUNCIL = makeAddr("council"); + address internal immutable _INITIAL_OWNER = makeAddr("owner"); + + address internal _defaultSender; + + XanV1 internal _xanV1Proxy; + XanV2 internal _xanV2Proxy; + address internal _xanV2Impl; + + function setUp() public { + (, _defaultSender,) = vm.readCallers(); + + (_xanV1Proxy, _xanV2Impl) = _deployV1AndPrepareUpgrade(); + + UnsafeUpgrades.upgradeProxy({ + proxy: address(_xanV1Proxy), newImpl: _xanV2Impl, data: abi.encodeCall(XanV2.reinitializeFromV1, ()) + }); + + _xanV2Proxy = XanV2(address(_xanV1Proxy)); + } + + function test_reinitializeFromV1_emits_the_VestingScheduled_event() public { + (XanV1 v1Proxy, address v2Impl) = _deployV1AndPrepareUpgrade(); + + vm.expectEmit(address(v1Proxy)); + emit IXanV2.VestingScheduled({start: Parameters.VESTING_START, duration: Parameters.VESTING_DURATION}); + + UnsafeUpgrades.upgradeProxy({ + proxy: address(v1Proxy), newImpl: v2Impl, data: abi.encodeCall(XanV2.reinitializeFromV1, ()) + }); + } + + function test_reinitializeFromV1_reverts_when_called_again() public { + vm.expectRevert(abi.encodeWithSelector(Initializable.InvalidInitialization.selector), address(_xanV2Proxy)); + _xanV2Proxy.reinitializeFromV1(); + } + + function test_reinitializeFromV1_sets_the_owner() public view { + assertEq(_xanV2Proxy.owner(), _INITIAL_OWNER); + } + + /// @notice `reinitializeFromV1` must take no arguments. The upgrade can be executed by anyone once the V1 delay + /// elapses, so any argument would be attacker-controlled; the owner and vesting schedule are bound into the + /// implementation bytecode instead. This variant pins the selector to the no-argument signature, so adding a + /// parameter changes `XanV2.reinitializeFromV1.selector` and fails the assertion. + function test_reinitializeFromV1_takes_no_arguments() public pure { + assertEq( + bytes32(XanV2.reinitializeFromV1.selector), + bytes32(bytes4(keccak256("reinitializeFromV1()"))), + "reinitializeFromV1 must take no arguments" + ); + } + + /// @notice Deploys a V1 proxy, mints to `_defaultSender`, and wins a voter-body upgrade vote for a freshly + /// deployed V2 implementation so the proxy is ready to be upgraded. + function _deployV1AndPrepareUpgrade() internal returns (XanV1 v1Proxy, address v2Impl) { + v1Proxy = XanV1( + Upgrades.deployUUPSProxy({ + contractName: "XanV1.sol:XanV1", + initializerData: abi.encodeCall(XanV1.initializeV1, (_defaultSender, _COUNCIL)) + }) + ); + + // Point the V2 mock at the locally deployed V1 implementation (the vesting principal is stored under it). + v2Impl = address( + new MockXanV2( + v1Proxy.implementation(), _INITIAL_OWNER, Parameters.VESTING_START, Parameters.VESTING_DURATION + ) + ); + + vm.startPrank(_defaultSender); + v1Proxy.lock(v1Proxy.unlockedBalanceOf(_defaultSender)); + v1Proxy.castVote(v2Impl); + v1Proxy.scheduleVoterBodyUpgrade(); + vm.stopPrank(); + + skip(Parameters.DELAY_DURATION); + } +} diff --git a/test/XanV2.storage.t.sol b/test/XanV2.storage.t.sol index 5661747..b25c069 100644 --- a/test/XanV2.storage.t.sol +++ b/test/XanV2.storage.t.sol @@ -1,15 +1,16 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.30; +import {SlotDerivation} from "@openzeppelin/contracts/utils/SlotDerivation.sol"; import {Test} from "forge-std/Test.sol"; -import {XanV2} from "../src/drafts/XanV2.sol"; +import {XanV2} from "../src/XanV2.sol"; contract XanV2StorageTest is Test, XanV2 { - function test_storageLocation() external pure { - bytes32 expected = - keccak256(abi.encode(uint256(keccak256("anoma.storage.Xan.v2")) - 1)) & ~bytes32(uint256(0xff)); + // The values are irrelevant: this harness only reads the compile-time storage-location constant. + constructor() XanV2(address(1), 2, 3) {} - assertEq(_XAN_V2_STORAGE_LOCATION, expected); + function test_storage_slot() public pure { + assertEq(_XAN_V2_STORAGE_LOCATION, SlotDerivation.erc7201Slot("anoma.storage.Xan.v2")); } } diff --git a/test/XanV2.unit.t.sol b/test/XanV2.unit.t.sol deleted file mode 100644 index 5366200..0000000 --- a/test/XanV2.unit.t.sol +++ /dev/null @@ -1,175 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -pragma solidity ^0.8.30; - -import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; -import {Upgrades, UnsafeUpgrades} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; -import {Test} from "forge-std/Test.sol"; - -import {XanV2} from "../src/drafts/XanV2.sol"; -import {XanV2Forwarder} from "../src/drafts/XanV2Forwarder.sol"; -import {Parameters} from "../src/libs/Parameters.sol"; -import {XanV1} from "../src/XanV1.sol"; -import {MockProtocolAdapter} from "../test/mocks/ProtocolAdapter.m.sol"; - -contract XanV2UnitTest is Test { - XanV1 internal _xanV1Proxy; - XanV2 internal _xanV2Proxy; - address internal _xanV2Impl; - address internal _xanV2Forwarder; - address internal _defaultSender; - address internal _other; - address internal _governanceCouncil; - - MockProtocolAdapter internal _mockProtocolAdapter = new MockProtocolAdapter(); - - function setUp() public { - (, _defaultSender,) = vm.readCallers(); - _other = address(uint160(1)); - _governanceCouncil = address(uint160(2)); - - // Deploy proxy and mint tokens for the `_defaultSender`. - _xanV1Proxy = XanV1( - Upgrades.deployUUPSProxy({ - contractName: "XanV1.sol:XanV1", - initializerData: abi.encodeCall(XanV1.initializeV1, (_defaultSender, _governanceCouncil)) - }) - ); - _xanV2Forwarder = address( - new XanV2Forwarder({ - xanProxy: address(_xanV1Proxy), - protocolAdapter: address(_mockProtocolAdapter), - calldataCarrierLogicRef: bytes32(uint256(1)) - }) - ); - - _xanV2Impl = address(new XanV2()); - - _winUpgradeVoteForV2Impl(_xanV1Proxy); - - skip(Parameters.DELAY_DURATION); - - UnsafeUpgrades.upgradeProxy({ - proxy: address(_xanV1Proxy), - newImpl: _xanV2Impl, - data: abi.encodeCall(XanV2.reinitializeFromV1, (_xanV2Forwarder)) - }); - - _xanV2Proxy = XanV2(address(_xanV1Proxy)); - } - - function test_initialize_sets_the_forwarder() public { - XanV2 v2Proxy = XanV2( - Upgrades.deployUUPSProxy({ - contractName: "XanV2.sol:XanV2", - initializerData: abi.encodeCall( - XanV2.initializeV2, (_defaultSender, _governanceCouncil, _xanV2Forwarder) - ) - }) - ); - assertEq(v2Proxy.forwarder(), _xanV2Forwarder); - } - - function test_initializeV2_sets_the_forwarder() public { - XanV2 v2ProxyUninitialized; - { - // Deploy v1 - XanV1 v1Proxy = XanV1( - Upgrades.deployUUPSProxy({ - contractName: "XanV1.sol:XanV1", - initializerData: abi.encodeCall(XanV1.initializeV1, (_defaultSender, _governanceCouncil)) - }) - ); - _winUpgradeVoteForV2Impl(v1Proxy); - - // Upgrade v1 to v2 but do not reinitialize. - UnsafeUpgrades.upgradeProxy({proxy: address(v1Proxy), newImpl: _xanV2Impl, data: ""}); - v2ProxyUninitialized = XanV2(address(v1Proxy)); - } - - // Check that the owner hasn't been set. - assertEq(v2ProxyUninitialized.forwarder(), address(0)); - - // Reinitialize and expect the owner to be set. - v2ProxyUninitialized.reinitializeFromV1({xanV2Forwarder: _xanV2Forwarder}); - assertEq(v2ProxyUninitialized.forwarder(), _xanV2Forwarder); - } - - function test_mint_is_callable_from_the_ProtocolAdapter_via_the_XanV2Forwarder() public { - uint256 valueToMint = 123; - - MockProtocolAdapter.ForwarderCalldata memory forwarderCalldata = MockProtocolAdapter.ForwarderCalldata({ - untrustedForwarder: _xanV2Forwarder, - input: abi.encodeCall(XanV2.mint, (_other, valueToMint)), - output: bytes("") - }); - - vm.expectEmit(address(_xanV2Proxy)); - emit IERC20.Transfer({from: address(0), to: _xanV2Forwarder, value: valueToMint}); - - _mockProtocolAdapter.executeForwarderCall(forwarderCalldata); - } - - function test_mint_is_callable_by_pranking_the_XanV2Forwarder() public { - uint256 valueToMint = 123; - - vm.expectEmit(address(_xanV2Proxy)); - emit IERC20.Transfer({from: address(0), to: _xanV2Forwarder, value: valueToMint}); - - // Call as the `XanV2Forwarder` contract. - vm.prank(_xanV2Forwarder); - _xanV2Proxy.mint({account: _xanV2Forwarder, value: valueToMint}); - } - - function test_mint_reverts_if_the_caller_is_not_the_XanV2Forwarder() public { - vm.expectRevert(abi.encodeWithSelector(XanV1.UnauthorizedCaller.selector, _defaultSender), address(_xanV2Proxy)); - - // Call without being the `XanV2Forwarder` contract. - vm.prank(_defaultSender); - _xanV2Proxy.mint({account: _other, value: 123}); - } - - function test_mint_mints_tokens_for_the_XanV2Forwarder() public { - uint256 valueToMint = 123; - - MockProtocolAdapter.ForwarderCalldata memory forwarderCalldata = MockProtocolAdapter.ForwarderCalldata({ - untrustedForwarder: _xanV2Forwarder, - input: abi.encodeCall(XanV2.mint, (_other, valueToMint)), - output: bytes("") - }); - _mockProtocolAdapter.executeForwarderCall(forwarderCalldata); - - // Check that the forwarder contract receives the minted tokens. - assertEq(_xanV2Proxy.balanceOf(_xanV2Forwarder), valueToMint); - assertEq(_xanV2Proxy.unlockedBalanceOf(_xanV2Forwarder), valueToMint); - assertEq(_xanV2Proxy.lockedBalanceOf(_xanV2Forwarder), 0); - - // Check that `_other` doesn't receive the minted tokens on the EVM side. - assertEq(_xanV2Proxy.balanceOf(_other), 0); - assertEq(_xanV2Proxy.unlockedBalanceOf(_other), 0); - assertEq(_xanV2Proxy.lockedBalanceOf(_other), 0); - } - - function test_mint_increases_the_total_supply() public { - uint256 currentSupply = _xanV1Proxy.totalSupply(); - - uint256 valueToMint = 123; - - MockProtocolAdapter.ForwarderCalldata memory forwarderCalldata = MockProtocolAdapter.ForwarderCalldata({ - untrustedForwarder: _xanV2Forwarder, - input: abi.encodeCall(XanV2.mint, (_other, valueToMint)), - output: bytes("") - }); - _mockProtocolAdapter.executeForwarderCall(forwarderCalldata); - - assertEq(_xanV2Proxy.totalSupply(), currentSupply + valueToMint); - } - - function _winUpgradeVoteForV2Impl(XanV1 xanV1Proxy) internal { - vm.startPrank(_defaultSender); - xanV1Proxy.lock(xanV1Proxy.unlockedBalanceOf(_defaultSender)); - xanV1Proxy.castVote(_xanV2Impl); - xanV1Proxy.scheduleVoterBodyUpgrade(); - vm.stopPrank(); - skip(Parameters.DELAY_DURATION); - } -} diff --git a/test/XanV2.unlocking.t.sol b/test/XanV2.unlocking.t.sol new file mode 100644 index 0000000..13967fb --- /dev/null +++ b/test/XanV2.unlocking.t.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {IXanV2} from "../src/interfaces/IXanV2.sol"; +import {Parameters} from "../src/libs/Parameters.sol"; +import {XanV2} from "../src/XanV2.sol"; +import {XanV2Fixture} from "./fixtures/XanV2Fixture.sol"; + +contract XanV2UnlockingTest is XanV2Fixture { + using SafeERC20 for XanV2; + + address internal immutable _OTHER = makeAddr("other"); + + function test_lockedBalanceOf_returns_full_principal_at_start() public { + // `_defaultSender` locked the entire supply in V1 before the upgrade. + vm.warp(_vestingStart); + assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY); + assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), 0); + assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), 0); + } + + function test_unlock_reverts_when_nothing_vested() public { + vm.warp(_vestingStart); + vm.prank(_defaultSender); + vm.expectRevert(abi.encodeWithSelector(XanV2.NothingToUnlock.selector, _defaultSender), address(_xanV2Proxy)); + _xanV2Proxy.unlock(); + } + + function test_claimableBalanceOf_returns_linear_amount_during_vesting() public { + vm.warp(_vestingMid); + assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), Parameters.SUPPLY / 2); + + // Vesting does not become spendable until it is unlocked. + assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), 0); + assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY); + } + + function test_unlock_makes_vested_tokens_spendable() public { + vm.warp(_vestingMid); + + vm.prank(_defaultSender); + uint256 value = _xanV2Proxy.unlock(); + assertEq(value, Parameters.SUPPLY / 2); + + assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), Parameters.SUPPLY / 2); + assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY / 2); + assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), 0); + + // The unlocked tokens can now be transferred; the still-locked ones cannot. + vm.prank(_defaultSender); + _xanV2Proxy.safeTransfer(_OTHER, Parameters.SUPPLY / 2); + assertEq(_xanV2Proxy.balanceOf(_OTHER), Parameters.SUPPLY / 2); + + vm.prank(_defaultSender); + vm.expectRevert( + abi.encodeWithSelector(XanV2.UnlockedBalanceInsufficient.selector, _defaultSender, 0, 1), + address(_xanV2Proxy) + ); + // We do not use `safeTransfer` here to obtain the expected error `UnlockedBalanceInsufficient` instead of + // `SafeERC20FailedOperation`. + // forge-lint: disable-next-line(erc20-unchecked-transfer) + _xanV2Proxy.transfer(_OTHER, 1); + } + + function test_claimableBalanceOf_returns_full_principal_after_duration() public { + vm.warp(_vestingEnd); + assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), Parameters.SUPPLY); + + vm.prank(_defaultSender); + _xanV2Proxy.unlock(); + assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), 0); + assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), Parameters.SUPPLY); + } + + function test_unlock_emits_the_Unlocked_event() public { + vm.warp(_vestingMid); + + vm.expectEmit(address(_xanV2Proxy)); + emit IXanV2.Unlocked({account: _defaultSender, value: Parameters.SUPPLY / 2}); + + vm.prank(_defaultSender); + _xanV2Proxy.unlock(); + } + + function test_unlock_reverts_if_no_new_amount_has_vested() public { + vm.warp(_vestingMid); + + vm.prank(_defaultSender); + _xanV2Proxy.unlock(); + + // A second unlock at the same timestamp has nothing newly vested to release. + vm.prank(_defaultSender); + vm.expectRevert(abi.encodeWithSelector(XanV2.NothingToUnlock.selector, _defaultSender), address(_xanV2Proxy)); + _xanV2Proxy.unlock(); + } + + function test_unlockedBalanceOf_excludes_the_locked_balance() public { + // Before vesting the whole principal is locked, so none of the balance is unlocked. + vm.warp(_vestingStart); + assertEq(_xanV2Proxy.balanceOf(_defaultSender), Parameters.SUPPLY); + assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY); + assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), 0); + } + + function test_transfer_reverts_if_value_exceeds_unlocked_balance() public { + // Before vesting every token is locked, so even a 1-wei transfer exceeds the unlocked balance. + vm.warp(_vestingStart); + vm.prank(_defaultSender); + vm.expectRevert( + abi.encodeWithSelector(XanV2.UnlockedBalanceInsufficient.selector, _defaultSender, 0, 1), + address(_xanV2Proxy) + ); + // forge-lint: disable-next-line(erc20-unchecked-transfer) + _xanV2Proxy.transfer(_OTHER, 1); + } + + function test_burn_reverts_if_value_exceeds_unlocked_balance() public { + // Burning routes through the same `_update` gate, so locked tokens cannot be burned. + vm.warp(_vestingStart); + vm.prank(_defaultSender); + vm.expectRevert(abi.encodeWithSelector(XanV2.UnlockedBalanceInsufficient.selector, _defaultSender, 0, 1)); + _xanV2Proxy.burn(1); + } + + function test_burn_burns_unlocked_tokens() public { + // After full vesting and unlock the entire balance is spendable and therefore burnable. + vm.warp(_vestingEnd); + vm.startPrank(_defaultSender); + _xanV2Proxy.unlock(); + _xanV2Proxy.burn(Parameters.SUPPLY / 2); + vm.stopPrank(); + + assertEq(_xanV2Proxy.balanceOf(_defaultSender), Parameters.SUPPLY / 2); + assertEq(_xanV2Proxy.totalSupply(), Parameters.SUPPLY / 2); + } + + function test_vestingStart_returns_expected_parameter() public view { + assertEq(_xanV2Proxy.vestingStart(), _vestingStart); + } + + function test_vestingEnd_returns_expected_parameter() public view { + assertEq(_xanV2Proxy.vestingEnd(), _vestingEnd); + } +} diff --git a/test/XanV2.upgrade.integration.t.sol b/test/XanV2.upgrade.integration.t.sol new file mode 100644 index 0000000..79877de --- /dev/null +++ b/test/XanV2.upgrade.integration.t.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +import {Upgrades, UnsafeUpgrades, Options} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; +import {Test} from "forge-std/Test.sol"; + +import {Parameters} from "../src/libs/Parameters.sol"; +import {XanV1} from "../src/XanV1.sol"; +import {XanV2} from "../src/XanV2.sol"; + +/// @notice Fork integration tests that exercise the upgrade of the live XAN proxies on: +/// * Mainnet +/// * Sepolia +contract XanV2UpgradeIntegrationTest is Test { + struct TestCase { + string name; + } + + /// @notice The live XAN proxy address (identical on Ethereum mainnet and Sepolia). + address internal constant _XAN_PROXY = 0xCEDbEA37C8872c4171259Cdfd5255CB8923Cf8e7; + + address internal immutable _INITIAL_OWNER = makeAddr("initialOwner"); + + function tableNetworksTest_XanV2_council_scheduling_and_upgrade_succeeds_on_all_supported_networks(TestCase memory network) + public + { + vm.createSelectFork(network.name); + + XanV1 proxy = XanV1(_XAN_PROXY); + + // 1. Prepare and schedule the XanV2 implementation. + Options memory opts; + opts.constructorData = abi.encode(_INITIAL_OWNER, Parameters.VESTING_START, Parameters.VESTING_DURATION); + address implV2 = Upgrades.prepareUpgrade({contractName: "XanV2.sol:XanV2", opts: opts}); + + // 2. Schedule the council upgrade as the governance council. + vm.prank(proxy.governanceCouncil()); + proxy.scheduleCouncilUpgrade({impl: implV2}); + + (address scheduledImpl, uint48 endTime) = proxy.scheduledCouncilUpgrade(); + assertEq(scheduledImpl, implV2, "council did not schedule the implementation"); + + // 3. Wait out the council delay and execute the upgrade permissionlessly. + vm.warp(endTime); + UnsafeUpgrades.upgradeProxy({ + proxy: _XAN_PROXY, newImpl: implV2, data: abi.encodeCall(XanV2.reinitializeFromV1, ()) + }); + + // 4. Ensure that the upgrade to XanV2 was successful. + assertEq(XanV2(_XAN_PROXY).implementation(), implV2, "proxy not upgraded to V2"); + } + + /// @notice The networks on which XanV1 is deployed. + function fixtureNetwork() public pure returns (TestCase[] memory network) { + network = new TestCase[](2); + network[0] = TestCase({name: "mainnet"}); + network[1] = TestCase({name: "sepolia"}); + } +} diff --git a/test/XanV2.upgrade.t.sol b/test/XanV2.upgrade.t.sol new file mode 100644 index 0000000..2199a65 --- /dev/null +++ b/test/XanV2.upgrade.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import {Parameters} from "../src/libs/Parameters.sol"; +import {XanV2} from "../src/XanV2.sol"; +import {XanV2Fixture} from "./fixtures/XanV2Fixture.sol"; +import {MockXanV2} from "./mocks/MockXanV2.sol"; + +contract XanV2UpgradeTest is XanV2Fixture { + address internal immutable _OTHER = makeAddr("other"); + + function test_authorizeUpgrade_reverts_if_the_caller_is_not_the_owner() public { + address newImpl = address(new XanV2(msg.sender, Parameters.VESTING_START, Parameters.VESTING_DURATION)); + + vm.prank(_OTHER); + vm.expectRevert( + abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, _OTHER), address(_xanV2Proxy) + ); + _xanV2Proxy.upgradeToAndCall(newImpl, ""); + } + + function test_authorizeUpgrade_upgrades_if_the_caller_is_the_owner() public { + address newImpl = address( + new MockXanV2( + _xanV1Proxy.implementation(), msg.sender, Parameters.VESTING_START, Parameters.VESTING_DURATION + ) + ); + + vm.prank(_xanV2Proxy.owner()); + _xanV2Proxy.upgradeToAndCall(newImpl, ""); + + assertEq(_xanV2Proxy.implementation(), newImpl); + } + + function test_implementation_returns_the_current_implementation() public view { + assertEq(_xanV2Proxy.implementation(), _xanV2Impl); + } +} diff --git a/test/XanV2.voting.t.sol b/test/XanV2.voting.t.sol new file mode 100644 index 0000000..d677a1f --- /dev/null +++ b/test/XanV2.voting.t.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; + +import {Parameters} from "../src/libs/Parameters.sol"; +import {XanV2} from "../src/XanV2.sol"; +import {XanV2Fixture} from "./fixtures/XanV2Fixture.sol"; + +contract XanV2VotingTest is XanV2Fixture { + using SafeERC20 for XanV2; + + address internal immutable _OTHER = makeAddr("other"); + + function test_getPastVotes_returns_the_checkpointed_value() public { + vm.warp(100); + vm.prank(_defaultSender); + _xanV2Proxy.delegate(_defaultSender); + + vm.warp(101); + assertEq(_xanV2Proxy.getPastVotes(_defaultSender, 100), Parameters.SUPPLY); + } + + function test_getPastTotalSupply_returns_the_seeded_supply_after_upgrade() public { + // The V1 supply predates `ERC20Votes`; the upgrade seeds the voting total-supply checkpoint so quorum is + // not zero. It becomes queryable once it is in the past (here, after the upgrade timestamp). + vm.warp(_vestingStart); + assertEq(_xanV2Proxy.getPastTotalSupply(_vestingStart - 1), Parameters.SUPPLY); + } + + function test_transfer_moves_voting_power_between_delegates() public { + vm.prank(_defaultSender); + _xanV2Proxy.delegate(_defaultSender); + vm.prank(_OTHER); + _xanV2Proxy.delegate(_OTHER); + + // Half-way through vesting, unlock the vested half and transfer it to `_OTHER`. + vm.warp(_vestingMid); + vm.startPrank(_defaultSender); + _xanV2Proxy.unlock(); + _xanV2Proxy.safeTransfer(_OTHER, Parameters.SUPPLY / 2); + vm.stopPrank(); + + assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY / 2); + assertEq(_xanV2Proxy.getVotes(_OTHER), Parameters.SUPPLY / 2); + } + + function test_delegate_grants_voting_power_equal_to_balance() public { + vm.prank(_defaultSender); + _xanV2Proxy.delegate(_defaultSender); + + // Voting power tracks the full balance, including the still-locked (unvested) tokens. + assertEq(_xanV2Proxy.getVotes(_defaultSender), _xanV2Proxy.balanceOf(_defaultSender)); + assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); + } + + function test_getVotes_counts_locked_tokens_before_vesting_starts() public { + vm.warp(_vestingStart - 1); + + // The clock is before the vesting period. + assertLt(Time.timestamp(), _vestingStart); + + // Nothing has vested, so nothing is claimable and the whole balance is still locked. + assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), 0); + assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), 0); + assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY); + + _selfDelegate(); + + assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); + } + + function test_getVotes_counts_vested_unclaimed_tokens_during_vesting() public { + vm.warp(_vestingMid); + + // The clock is inside the vesting period. + assertGe(Time.timestamp(), _vestingStart); + assertLt(Time.timestamp(), _vestingEnd); + + // Half has vested and is claimable, but nothing has been claimed: the full balance is still locked. + assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), Parameters.SUPPLY / 2); + assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), 0); + assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY); + + _selfDelegate(); + + assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); + } + + function test_getVotes_counts_partially_claimed_tokens_during_vesting() public { + vm.warp(_vestingMid); + + // The clock is inside the vesting period. + assertGe(Time.timestamp(), _vestingStart); + assertLt(Time.timestamp(), _vestingEnd); + + // Claim the vested half: tokens move from locked to unlocked, but the balance is unchanged. + _unlock(); + assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), Parameters.SUPPLY / 2); + assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY / 2); + assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), 0); + + _selfDelegate(); + + assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); + } + + function test_getVotes_counts_vested_unclaimed_tokens_after_vesting() public { + vm.warp(_vestingEnd + 1); + + // The clock is after the vesting period. + assertGt(Time.timestamp(), _vestingEnd); + + // Everything has vested and is claimable, but nothing has been claimed: the full balance is still locked. + assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), Parameters.SUPPLY); + assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), 0); + assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY); + + _selfDelegate(); + + assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); + } + + function test_getVotes_counts_fully_claimed_tokens_after_vesting() public { + vm.warp(_vestingEnd + 1); + + // The clock is after the vesting period. + assertGt(Time.timestamp(), _vestingEnd); + + // Claim everything: the entire balance is now unlocked, nothing is locked. + _unlock(); + assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), Parameters.SUPPLY); + assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), 0); + assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), 0); + + _selfDelegate(); + + assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); + } + + function test_getVotes_is_unchanged_by_claiming_across_vesting() public { + _selfDelegate(); + + // Before the vesting period: nothing to claim yet. + vm.warp(_vestingStart - 1); + assertLt(Time.timestamp(), _vestingStart); + assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); + + // During the vesting period: claim the vested half. + vm.warp(_vestingMid); + assertGe(Time.timestamp(), _vestingStart); + assertLt(Time.timestamp(), _vestingEnd); + _unlock(); + assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); + + // After the vesting period: claim the remainder. + vm.warp(_vestingEnd + 1); + assertGt(Time.timestamp(), _vestingEnd); + _unlock(); + assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); + } + + function test_getVotes_returns_zero_before_delegation() public view { + assertEq(_xanV2Proxy.getVotes(_defaultSender), 0); + } + + function test_clock_tracks_the_block_timestamp() public view { + assertEq(_xanV2Proxy.clock(), uint48(block.timestamp)); + } + + function test_CLOCK_MODE_returns_the_timestamp_mode() public view { + assertEq(_xanV2Proxy.CLOCK_MODE(), "mode=timestamp"); + } + + function _selfDelegate() internal { + vm.prank(_defaultSender); + _xanV2Proxy.delegate(_defaultSender); + } + + /// @notice Claims (unlocks) all of `_defaultSender`'s currently vested tokens. + function _unlock() internal { + vm.prank(_defaultSender); + _xanV2Proxy.unlock(); + } + + /// @notice Begin vesting after the V2 upgrade completes; the voter-body delay is waited out during `setUp`. + function _vestingSchedule() internal view override returns (uint48 start, uint48 duration) { + start = Time.timestamp() + Parameters.DELAY_DURATION + 1 hours; + duration = 24 hours; + } +} diff --git a/test/XanV2Forwarder.t.sol b/test/XanV2Forwarder.t.sol deleted file mode 100644 index f212174..0000000 --- a/test/XanV2Forwarder.t.sol +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -pragma solidity ^0.8.30; - -import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; -import {Upgrades} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; -import {Test} from "forge-std/Test.sol"; - -import {XanV2} from "../src/drafts/XanV2.sol"; -import {XanV2Forwarder} from "../src/drafts/XanV2Forwarder.sol"; -import {MockProtocolAdapter} from "../test/mocks/ProtocolAdapter.m.sol"; - -contract XanV2ForwarderUnitTest is Test { - address internal _defaultSender; - address internal _governanceCouncil; - XanV2 internal _xanV2Proxy; - XanV2Forwarder internal _xanV2Forwarder; - MockProtocolAdapter internal _mockProtocolAdapter = new MockProtocolAdapter(); - - function setUp() public { - (, _defaultSender,) = vm.readCallers(); - - address predictedAddressProxy = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 2); - - _xanV2Forwarder = new XanV2Forwarder({ - xanProxy: address(predictedAddressProxy), - protocolAdapter: address(_mockProtocolAdapter), - calldataCarrierLogicRef: bytes32(uint256(1)) - }); - - _xanV2Proxy = XanV2( - Upgrades.deployUUPSProxy({ - contractName: "XanV2.sol:XanV2", - initializerData: abi.encodeCall( - XanV2.initializeV2, (_defaultSender, _governanceCouncil, address(_xanV2Forwarder)) - ) - }) - ); - - assertEq(address(_xanV2Proxy), predictedAddressProxy); - } - - function test_forwardCall_reverts_if_the_caller_is_not_the_protocol_adapter() public { - vm.expectRevert( - abi.encodeWithSelector(XanV2Forwarder.UnauthorizedCaller.selector, _defaultSender), address(_xanV2Forwarder) - ); - - // Make the forwarder call but not as the protocol adapter. - vm.prank(_defaultSender); - _xanV2Forwarder.forwardCall(""); - } - - function test_forwardCall_forwards_calls_when_called_by_the_protocol_adapter() public { - // Expect a `Transfer` event reflecting the mint. - uint256 valueToMint = 123; - vm.expectEmit(address(_xanV2Proxy)); - emit IERC20.Transfer({from: address(0), to: address(_xanV2Forwarder), value: valueToMint}); - - // Make the forwarder call as the protocol adapter. - vm.prank(address(_mockProtocolAdapter)); - _xanV2Forwarder.forwardCall(abi.encodeCall(XanV2.mint, (_defaultSender, valueToMint))); - } -} diff --git a/test/fixtures/XanV2Fixture.sol b/test/fixtures/XanV2Fixture.sol new file mode 100644 index 0000000..c802045 --- /dev/null +++ b/test/fixtures/XanV2Fixture.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +import {Upgrades, UnsafeUpgrades} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; +import {Test} from "forge-std/Test.sol"; + +import {Parameters} from "../../src/libs/Parameters.sol"; +import {XanV1} from "../../src/XanV1.sol"; +import {XanV2} from "../../src/XanV2.sol"; +import {MockXanV2} from "../mocks/MockXanV2.sol"; + +/// @notice Shared fixture that deploys a fresh `XanV1` proxy (minting the whole supply to `_defaultSender`) and +/// upgrades it to `XanV2` via the production voter-body upgrade flow. The vesting schedule defaults to the +/// `Parameters` values; override `_vestingSchedule` to customize (e.g. to place the start after the upgrade). +abstract contract XanV2Fixture is Test { + address internal immutable _GOVERNANCE_COUNCIL = makeAddr("governanceCouncil"); + + XanV1 internal _xanV1Proxy; + XanV2 internal _xanV2Proxy; + address internal _xanV2Impl; + address internal _defaultSender; + + /// @notice The vesting schedule baked into the deployed `_xanV2Proxy`, captured in `setUp`. + uint48 internal _vestingStart; + uint48 internal _vestingMid; + uint48 internal _vestingEnd; + + function setUp() public virtual { + (, _defaultSender,) = vm.readCallers(); + + // Deploy the proxy and mint the whole supply to the `_defaultSender`. + _xanV1Proxy = XanV1( + Upgrades.deployUUPSProxy({ + contractName: "XanV1.sol:XanV1", + initializerData: abi.encodeCall(XanV1.initializeV1, (_defaultSender, _GOVERNANCE_COUNCIL)) + }) + ); + + uint48 vestingDuration; + (_vestingStart, vestingDuration) = _vestingSchedule(); + _vestingMid = _vestingStart + vestingDuration / 2; + _vestingEnd = _vestingStart + vestingDuration; + + // Point the V2 mock at the locally deployed V1 implementation (the vesting principal is stored under it). + _xanV2Impl = + address(new MockXanV2(_xanV1Proxy.implementation(), _defaultSender, _vestingStart, vestingDuration)); + + // Win the voter-body upgrade vote for `_xanV2Impl` and wait out the delay so the upgrade can be executed. + vm.startPrank(_defaultSender); + _xanV1Proxy.lock(_xanV1Proxy.unlockedBalanceOf(_defaultSender)); + _xanV1Proxy.castVote(_xanV2Impl); + _xanV1Proxy.scheduleVoterBodyUpgrade(); + vm.stopPrank(); + skip(Parameters.DELAY_DURATION); + + // Upgrade the proxy to V2. + UnsafeUpgrades.upgradeProxy({ + proxy: address(_xanV1Proxy), newImpl: _xanV2Impl, data: abi.encodeCall(XanV2.reinitializeFromV1, ()) + }); + + _xanV2Proxy = XanV2(address(_xanV1Proxy)); + } + + /// @notice The vesting `(start, duration)` baked into the deployed V2 implementation. Override to customize. + function _vestingSchedule() internal view virtual returns (uint48 start, uint48 duration) { + start = Parameters.VESTING_START; + duration = Parameters.VESTING_DURATION; + } +} diff --git a/test/invariants/HandlerXanV1.t.sol b/test/invariants/HandlerXanV1.t.sol index 7746c80..7a1dddb 100644 --- a/test/invariants/HandlerXanV1.t.sol +++ b/test/invariants/HandlerXanV1.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.30; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; diff --git a/test/invariants/HandlerXanV2.t.sol b/test/invariants/HandlerXanV2.t.sol new file mode 100644 index 0000000..78afd99 --- /dev/null +++ b/test/invariants/HandlerXanV2.t.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Test} from "forge-std/Test.sol"; + +import {XanV2} from "../../src/XanV2.sol"; + +contract XanV2Handler is Test { + using SafeERC20 for XanV2; + + XanV2 public token; + address public initialHolder; + + // ============ GHOST VARIABLES (for invariant tracking) ============ + + address[] internal _actors; + mapping(address actor => bool isActor) internal _isActor; + + constructor(XanV2 _token, address _initialHolder) { + token = _token; + initialHolder = _initialHolder; + + _addActor(_initialHolder); + _addActor(makeAddr("voter1")); + _addActor(makeAddr("voter2")); + _addActor(makeAddr("voter3")); + } + + // ============ FUZZED BUSINESS LOGIC FUNCTIONS ============ + + function delegateToSelf(uint256 actorSeed) external { + address actor = _actorAt(actorSeed); + + vm.prank(actor); + token.delegate(actor); + } + + function transfer(uint256 fromSeed, uint256 toSeed, uint256 amount) external { + address from = _actorAt(fromSeed); + address to = _actorAt(toSeed); + + // Only the unlocked balance is transferable; bounding to it keeps the call from reverting. + amount = bound(amount, 0, token.unlockedBalanceOf(from)); + + vm.prank(from); + token.safeTransfer(to, amount); + } + + function unlock(uint256 actorSeed) external { + address actor = _actorAt(actorSeed); + + // `unlock` reverts when nothing has vested since the last claim; skip in that case. + if (token.claimableBalanceOf(actor) == 0) { + return; + } + + vm.prank(actor); + token.unlock(); + } + + function advanceTime(uint256 secondsToAdd) external { + // Sweep the sequence through the before/during/after vesting phases. + secondsToAdd = bound(secondsToAdd, 0, 14 days); + vm.warp(block.timestamp + secondsToAdd); + } + + // ============ GETTER FUNCTIONS ============ + + function getActors() external view returns (address[] memory arr) { + arr = _actors; + } + + // ============ HELPER FUNCTIONS ============ + + function _addActor(address a) internal { + if (!_isActor[a]) { + _isActor[a] = true; + _actors.push(a); + } + } + + function _actorAt(uint256 seed) internal view returns (address actor) { + actor = _actors[bound(seed, 0, _actors.length - 1)]; + } +} diff --git a/test/invariants/InvariantXanV1.t.sol b/test/invariants/InvariantXanV1.t.sol index b91e670..320df84 100644 --- a/test/invariants/InvariantXanV1.t.sol +++ b/test/invariants/InvariantXanV1.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.30; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; diff --git a/test/invariants/InvariantXanV2.t.sol b/test/invariants/InvariantXanV2.t.sol new file mode 100644 index 0000000..3948c61 --- /dev/null +++ b/test/invariants/InvariantXanV2.t.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +import {Time} from "@openzeppelin/contracts/utils/types/Time.sol"; +import {Upgrades, UnsafeUpgrades} from "@openzeppelin/foundry-upgrades/Upgrades.sol"; + +import {StdInvariant, Test} from "forge-std/Test.sol"; + +import {Parameters} from "../../src/libs/Parameters.sol"; +import {XanV1} from "../../src/XanV1.sol"; +import {XanV2} from "../../src/XanV2.sol"; +import {MockXanV2} from "../mocks/MockXanV2.sol"; +import {XanV2Handler} from "./HandlerXanV2.t.sol"; + +contract XanV2Invariants is StdInvariant, Test { + address internal immutable _GOVERNANCE_COUNCIL = makeAddr("governanceCouncil"); + + XanV1 public xanV1Proxy; + XanV2 public token; + XanV2Handler public handler; + + address internal _xanV2Impl; + address internal _defaultSender; + + function setUp() public { + (, _defaultSender,) = vm.readCallers(); + + // Deploy the V1 proxy and mint the whole supply to the `_defaultSender`. + xanV1Proxy = XanV1( + Upgrades.deployUUPSProxy({ + contractName: "XanV1.sol:XanV1", + initializerData: abi.encodeCall(XanV1.initializeV1, (_defaultSender, _GOVERNANCE_COUNCIL)) + }) + ); + + // Start vesting well after the upgrade completes (which waits out the voter-body delay), so the fuzzing + // sequence begins before the vesting period and can warp forward through it. + uint48 vestingStart = Time.timestamp() + 8 weeks; + uint48 vestingDuration = 8 weeks; + + // Point the V2 mock at the locally deployed V1 implementation (the vesting principal is stored under it). + _xanV2Impl = address(new MockXanV2(xanV1Proxy.implementation(), _defaultSender, vestingStart, vestingDuration)); + + // Win the voter-body upgrade vote for `_xanV2Impl` and wait out the delay so the upgrade can be executed. + vm.startPrank(_defaultSender); + xanV1Proxy.lock(xanV1Proxy.unlockedBalanceOf(_defaultSender)); + xanV1Proxy.castVote(_xanV2Impl); + xanV1Proxy.scheduleVoterBodyUpgrade(); + vm.stopPrank(); + skip(Parameters.DELAY_DURATION); + + // Upgrade the proxy to V2. + UnsafeUpgrades.upgradeProxy({ + proxy: address(xanV1Proxy), newImpl: _xanV2Impl, data: abi.encodeCall(XanV2.reinitializeFromV1, ()) + }); + + token = XanV2(address(xanV1Proxy)); + + handler = new XanV2Handler(token, _defaultSender); + + // Register the handler for invariant fuzzing. + targetContract(address(handler)); + } + + // A user can vote with their entire balance. + // Invariant: for every actor that has delegated to itself, voting power equals the full balance — locked, + // unlocked, vested, or unvested alike. Guards the composition of the unlocked-only `_update` transfer gate with + // `ERC20Votes`, which checkpoints the whole balance. + function invariant_self_delegated_votes_equal_full_balance() public view { + address[] memory actors = handler.getActors(); + + for (uint256 i = 0; i < actors.length; ++i) { + address actor = actors[i]; + + // Voting power only accrues to an account that has delegated; the property concerns self-delegation. + if (token.delegates(actor) == actor) { + assertEq(token.getVotes(actor), token.balanceOf(actor), "self-delegated votes != balance"); + } + } + } +} diff --git a/test/mocks/MockXanV2.sol b/test/mocks/MockXanV2.sol new file mode 100644 index 0000000..8767cf6 --- /dev/null +++ b/test/mocks/MockXanV2.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.30; + +import {XanV2} from "../../src/XanV2.sol"; + +/// @notice This mock makes internal functions of the XanV2 token accessible to external callers. +/// @custom:oz-upgrades-unsafe-allow missing-initializer +contract MockXanV2 is XanV2 { + address private immutable _V1_IMPLEMENTATION; + + /// @custom:oz-upgrades-unsafe-allow constructor state-variable-immutable + constructor(address v1Implementation, address owner, uint48 vestingStart, uint48 vestingDuration) + XanV2(owner, vestingStart, vestingDuration) + { + _V1_IMPLEMENTATION = v1Implementation; + } + + function _implementationV1() internal view override returns (address v1Implementation) { + v1Implementation = _V1_IMPLEMENTATION; + } +} diff --git a/test/mocks/Persons.m.sol b/test/mocks/Persons.m.sol deleted file mode 100644 index 2637985..0000000 --- a/test/mocks/Persons.m.sol +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -pragma solidity ^0.8.30; - -contract MockPersons { - string[16] internal _names = [ - "Alice", - "Bob", - "Carol", - "Dave", - "Eve", - "Frank", - "Grace", - "Harold", - "Ivy", - "Jack", - "Kathrine", - "Luis", - "Mallory", - "Nick", - "Olga", - "Paul" - ]; - - address[16] internal _addresses; - mapping(string name => address person) internal _persons; - mapping(string name => uint256 personId) internal _personIds; - - constructor() { - for (uint256 i = 0; i < _names.length; ++i) { - _addresses[i] = address(uint160(i + 1)); - _persons[_names[i]] = _addresses[i]; - _personIds[_names[i]] = i; - } - } - - function _person(string memory name) internal view returns (address addr) { - addr = _persons[name]; - } - - function _person(uint256 id) internal view returns (address addr) { - addr = _addresses[id]; - } - - function _personId(string memory name) internal view returns (uint256 id) { - id = _personIds[name]; - } - - function _personAddrAndId(string memory name) internal view returns (address addr, uint256 id) { - addr = _person(name); - id = _personId(name); - } -} diff --git a/test/mocks/ProtocolAdapter.m.sol b/test/mocks/ProtocolAdapter.m.sol deleted file mode 100644 index d2a33f7..0000000 --- a/test/mocks/ProtocolAdapter.m.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -pragma solidity ^0.8.30; - -import {XanV2Forwarder} from "../../src/drafts/XanV2Forwarder.sol"; - -contract MockProtocolAdapter { - /// @notice A data structure containing the input data to be forwarded to the untrusted forwarder contract - /// and the anticipated output data. - /// @param untrustedForwarder The forwarder contract forwarding the call. - /// @param input The input data for the forwarded call that might or might not include the `bytes4` function selector. - /// @param output The anticipated output data from the forwarded call. - struct ForwarderCalldata { - address untrustedForwarder; - bytes input; - bytes output; - } - - event MockForwardCall(address indexed untrustedForwarder, bytes input, bytes output); - - function executeForwarderCall(ForwarderCalldata calldata call) external { - bytes memory output = XanV2Forwarder(call.untrustedForwarder).forwardCall(call.input); - - emit MockForwardCall({untrustedForwarder: call.untrustedForwarder, input: call.input, output: output}); - } -} diff --git a/test/mocks/Target.m.sol b/test/mocks/Target.m.sol deleted file mode 100644 index 8ce1a99..0000000 --- a/test/mocks/Target.m.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -pragma solidity ^0.8.30; - -contract MockTarget { - event Called(address indexed from, uint256 indexed value, bytes data); - - error CallReverted(); - - // solhint-disable-next-line no-empty-blocks - receive() external payable {} - - fallback() external payable { - emit Called(msg.sender, msg.value, msg.data); - } - - function ping() external payable returns (string memory message) { - emit Called(msg.sender, msg.value, msg.data); - message = "pong"; - } - - function revertingCall() external pure { - revert CallReverted(); - } -} From 847475c3fec82fc386e68be5e29d7931ecc8a8e1 Mon Sep 17 00:00:00 2001 From: Michael Heuer Date: Fri, 26 Jun 2026 11:57:55 +0200 Subject: [PATCH 2/6] refactor: remove burn functionality --- src/XanV2.sol | 4 ---- test/XanV2.unlocking.t.sol | 20 -------------------- 2 files changed, 24 deletions(-) diff --git a/src/XanV2.sol b/src/XanV2.sol index cde543a..7b2a9e4 100644 --- a/src/XanV2.sol +++ b/src/XanV2.sol @@ -5,9 +5,6 @@ import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Own import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; -import { - ERC20BurnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; import { ERC20PermitUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; @@ -35,7 +32,6 @@ contract XanV2 is IXanV2, Initializable, ERC20Upgradeable, - ERC20BurnableUpgradeable, ERC20PermitUpgradeable, ERC20VotesUpgradeable, OwnableUpgradeable, diff --git a/test/XanV2.unlocking.t.sol b/test/XanV2.unlocking.t.sol index 13967fb..2af4fce 100644 --- a/test/XanV2.unlocking.t.sol +++ b/test/XanV2.unlocking.t.sol @@ -116,26 +116,6 @@ contract XanV2UnlockingTest is XanV2Fixture { _xanV2Proxy.transfer(_OTHER, 1); } - function test_burn_reverts_if_value_exceeds_unlocked_balance() public { - // Burning routes through the same `_update` gate, so locked tokens cannot be burned. - vm.warp(_vestingStart); - vm.prank(_defaultSender); - vm.expectRevert(abi.encodeWithSelector(XanV2.UnlockedBalanceInsufficient.selector, _defaultSender, 0, 1)); - _xanV2Proxy.burn(1); - } - - function test_burn_burns_unlocked_tokens() public { - // After full vesting and unlock the entire balance is spendable and therefore burnable. - vm.warp(_vestingEnd); - vm.startPrank(_defaultSender); - _xanV2Proxy.unlock(); - _xanV2Proxy.burn(Parameters.SUPPLY / 2); - vm.stopPrank(); - - assertEq(_xanV2Proxy.balanceOf(_defaultSender), Parameters.SUPPLY / 2); - assertEq(_xanV2Proxy.totalSupply(), Parameters.SUPPLY / 2); - } - function test_vestingStart_returns_expected_parameter() public view { assertEq(_xanV2Proxy.vestingStart(), _vestingStart); } From c359ddfea4313580c7ebd5fa7fb7af39fb719748 Mon Sep 17 00:00:00 2001 From: Michael Heuer Date: Mon, 29 Jun 2026 20:56:45 +0200 Subject: [PATCH 3/6] refactor: move errors and rename variables --- script/ScheduleCouncilUpgradeToXanV2.s.sol | 8 ----- script/UpgradeToXanV2.s.sol | 15 ++++++--- src/XanV2.sol | 36 ++++++++++++++------ src/interfaces/IXanV2.sol | 6 ++-- test/XanV2.constructor.t.sol | 15 ++++++++- test/XanV2.unlocking.t.sol | 12 +++---- test/XanV2.voting.t.sol | 38 +++++++++++----------- test/invariants/HandlerXanV2.t.sol | 4 +-- 8 files changed, 80 insertions(+), 54 deletions(-) diff --git a/script/ScheduleCouncilUpgradeToXanV2.s.sol b/script/ScheduleCouncilUpgradeToXanV2.s.sol index b4d2fc8..be1aba8 100644 --- a/script/ScheduleCouncilUpgradeToXanV2.s.sol +++ b/script/ScheduleCouncilUpgradeToXanV2.s.sol @@ -9,17 +9,9 @@ import {Parameters} from "../src/libs/Parameters.sol"; import {XanV1} from "../src/XanV1.sol"; contract ScheduleCouncilUpgradeToXanV2 is Script { - error InvalidOwnerAddress(); - error InvalidVestingStart(); - error InvalidVestingDuration(); - function run(address proxy) public returns (address implV2) { Options memory opts; - require(Parameters.INITIAL_OWNER != address(0), InvalidOwnerAddress()); - require(Parameters.VESTING_START != 0, InvalidVestingStart()); - require(Parameters.VESTING_DURATION != 0, InvalidVestingDuration()); - // Bind the owner and vesting schedule into the implementation bytecode at deployment (the trusted step). The // scheduled implementation address is fixed, so whoever later executes the (permissionless) upgrade cannot // change these via calldata. Always use the `Parameters` constants so the vesting schedule cannot be picked diff --git a/script/UpgradeToXanV2.s.sol b/script/UpgradeToXanV2.s.sol index a59ee21..61ba0bd 100644 --- a/script/UpgradeToXanV2.s.sol +++ b/script/UpgradeToXanV2.s.sol @@ -11,19 +11,24 @@ import {XanV1} from "../src/XanV1.sol"; import {XanV2} from "../src/XanV2.sol"; contract UpgradeToXanV2 is Script { - function run(address proxy) public returns (address newImplementation) { - (address implV2, uint48 endTime) = XanV1(proxy).scheduledCouncilUpgrade(); + error ZeroImplementationV2NotAllowed(); + function run(address proxy) public returns (address implementationV2) { + uint48 endTime; + + (implementationV2, endTime) = XanV1(proxy).scheduledCouncilUpgrade(); + + require(implementationV2 != address(0), ZeroImplementationV2NotAllowed()); require(endTime <= Time.timestamp(), XanV1.DelayPeriodNotEnded({endTime: endTime})); vm.startBroadcast(); // The owner and vesting start are baked into `implV2` at deployment (see `ScheduleCouncilUpgradeToXanV2`), // so `reinitializeFromV1` takes no arguments and executing this upgrade cannot influence them. - UnsafeUpgrades.upgradeProxy({proxy: proxy, newImpl: implV2, data: abi.encodeCall(XanV2.reinitializeFromV1, ())}); + UnsafeUpgrades.upgradeProxy({ + proxy: proxy, newImpl: implementationV2, data: abi.encodeCall(XanV2.reinitializeFromV1, ()) + }); vm.stopBroadcast(); - - newImplementation = XanV2(proxy).implementation(); } } diff --git a/src/XanV2.sol b/src/XanV2.sol index 7b2a9e4..f6628db 100644 --- a/src/XanV2.sol +++ b/src/XanV2.sol @@ -96,15 +96,24 @@ contract XanV2 is /// @custom:oz-upgrades-unsafe-allow state-variable-immutable uint48 private immutable _VESTING_DURATION; + /// @notice Thrown if the zero address is provided as the owner in the constructor. + error ZeroOwnerNotAllowed(); + + /// @notice Thrown if the timestamp is provided as the vesting start in the constructor. + error ZeroVestingStartNotAllowed(); + + /// @notice Thrown if the zero duration is provided as the vesting duration in the constructor. + error ZeroVestingDurationNotAllowed(); + + /// @notice Thrown when a upgrade back to the XAN V1 implementation is attempted. + error UpgradeToXanV1NotAllowed(); + /// @notice Thrown when an account tries to move more than its unlocked (spendable) balance. error UnlockedBalanceInsufficient(address sender, uint256 unlockedBalance, uint256 valueToLock); /// @notice Thrown when `unlock` is called but no tokens have vested since the last unlock. error NothingToUnlock(address account); - /// @notice Thrown if the zero address is provided as owner in the constructor. - error ZeroOwnerNotAllowed(); - /// @notice Disables the initializers on the implementation contract to prevent it from being left uninitialized, /// and binds the owner and vesting schedule into the implementation bytecode. /// @param initialOwner The owner of the proxy after the upgrade (e.g. a multisig or DAO). @@ -113,9 +122,13 @@ contract XanV2 is /// @custom:oz-upgrades-unsafe-allow constructor state-variable-immutable constructor(address initialOwner, uint48 vestingStartTimestamp, uint48 vestingDuration) { require(initialOwner != address(0), ZeroOwnerNotAllowed()); + require(vestingStartTimestamp != 0, ZeroVestingStartNotAllowed()); + require(vestingDuration != 0, ZeroVestingDurationNotAllowed()); + _INITIAL_OWNER = initialOwner; _VESTING_START = vestingStartTimestamp; _VESTING_DURATION = vestingDuration; + _disableInitializers(); } @@ -190,7 +203,7 @@ contract XanV2 is } /// @inheritdoc IXanV2 - function claimableBalanceOf(address account) public view override returns (uint256 value) { + function unlockableBalanceOf(address account) public view override returns (uint256 value) { uint256 principal = _principalOf(account); uint256 vested = _vestedAmount(principal); uint256 alreadyUnlocked = _getXanV2Storage().unlocked[account]; @@ -262,19 +275,22 @@ contract XanV2 is /// @param principal The account's formerly locked V1 balance. /// @return vested The vested amount, linearly interpolated and capped at `principal`. function _vestedAmount(uint256 principal) internal view returns (uint256 vested) { - uint48 start = _VESTING_START; - uint48 nowTs = Time.timestamp(); + uint48 startTime = _VESTING_START; + uint48 currentTime = clock(); - if (nowTs < start + 1) { + if (currentTime < startTime + 1) { return vested = 0; } - uint48 elapsed = nowTs - start; - if (elapsed > _VESTING_DURATION - 1) { + uint48 elapsedTime = currentTime - startTime; + if (elapsedTime > _VESTING_DURATION - 1) { return vested = principal; } - vested = (principal * elapsed) / _VESTING_DURATION; + // An overflow is not possible. `principal` is bound by the total XanV1 supply (see `Parameters.SUPPLY`) and the + // elapsed time by `Parameters.VESTING_DURATION - 1`. Accordingly, the product can be assumed to not overflow. + // Still, we use safe math here. + vested = (principal * elapsedTime) / _VESTING_DURATION; } /// @notice Returns the formerly locked V1 balance of an account that is the principal subject to vesting. diff --git a/src/interfaces/IXanV2.sol b/src/interfaces/IXanV2.sol index 5175b82..07db780 100644 --- a/src/interfaces/IXanV2.sol +++ b/src/interfaces/IXanV2.sol @@ -20,10 +20,10 @@ interface IXanV2 { /// @return value The amount of tokens that became spendable. function unlock() external returns (uint256 value); - /// @notice Returns the amount of tokens the account can unlock right now (vested but not yet unlocked). + /// @notice Returns the amount of tokens that an account can unlock (vested but not yet unlocked). /// @param account The account to query. - /// @return value The currently claimable amount. - function claimableBalanceOf(address account) external view returns (uint256 value); + /// @return value The currently unlockable amount. + function unlockableBalanceOf(address account) external view returns (uint256 value); /// @notice Returns the unlocked (spendable) token balance of an account. /// @param from The account to query. diff --git a/test/XanV2.constructor.t.sol b/test/XanV2.constructor.t.sol index 48b2131..09f4798 100644 --- a/test/XanV2.constructor.t.sol +++ b/test/XanV2.constructor.t.sol @@ -30,12 +30,25 @@ contract XanV2ConstructorTest is Test { } function test_constructor_reverts_if_the_owner_is_the_zero_address() public { - // The revert happens inside the contract being created, whose address we can predict from this contract's nonce. address predictedImpl = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); vm.expectRevert(XanV2.ZeroOwnerNotAllowed.selector, predictedImpl); new XanV2({initialOwner: address(0), vestingStartTimestamp: _VESTING_START, vestingDuration: _VESTING_DURATION}); } + function test_constructor_reverts_if_the_vesting_start_is_the_zero_timestamp() public { + address predictedImpl = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); + vm.expectRevert(XanV2.ZeroVestingStartNotAllowed.selector, predictedImpl); + + new XanV2({initialOwner: _INITIAL_OWNER, vestingStartTimestamp: 0, vestingDuration: _VESTING_DURATION}); + } + + function test_constructor_reverts_if_the_vesting_duration_is_the_zero_duration() public { + address predictedImpl = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); + vm.expectRevert(XanV2.ZeroVestingDurationNotAllowed.selector, predictedImpl); + + new XanV2({initialOwner: _INITIAL_OWNER, vestingStartTimestamp: _VESTING_START, vestingDuration: 0}); + } + function test_constructor_binds_the_initial_owner() public { address proxy = UnsafeUpgrades.deployUUPSProxy(address(_impl), abi.encodeCall(XanV2.reinitializeFromV1, ())); diff --git a/test/XanV2.unlocking.t.sol b/test/XanV2.unlocking.t.sol index 2af4fce..de3238c 100644 --- a/test/XanV2.unlocking.t.sol +++ b/test/XanV2.unlocking.t.sol @@ -18,7 +18,7 @@ contract XanV2UnlockingTest is XanV2Fixture { vm.warp(_vestingStart); assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY); assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), 0); - assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), 0); + assertEq(_xanV2Proxy.unlockableBalanceOf(_defaultSender), 0); } function test_unlock_reverts_when_nothing_vested() public { @@ -28,9 +28,9 @@ contract XanV2UnlockingTest is XanV2Fixture { _xanV2Proxy.unlock(); } - function test_claimableBalanceOf_returns_linear_amount_during_vesting() public { + function test_unlockableBalanceOf_returns_linear_amount_during_vesting() public { vm.warp(_vestingMid); - assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), Parameters.SUPPLY / 2); + assertEq(_xanV2Proxy.unlockableBalanceOf(_defaultSender), Parameters.SUPPLY / 2); // Vesting does not become spendable until it is unlocked. assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), 0); @@ -46,7 +46,7 @@ contract XanV2UnlockingTest is XanV2Fixture { assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), Parameters.SUPPLY / 2); assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY / 2); - assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), 0); + assertEq(_xanV2Proxy.unlockableBalanceOf(_defaultSender), 0); // The unlocked tokens can now be transferred; the still-locked ones cannot. vm.prank(_defaultSender); @@ -64,9 +64,9 @@ contract XanV2UnlockingTest is XanV2Fixture { _xanV2Proxy.transfer(_OTHER, 1); } - function test_claimableBalanceOf_returns_full_principal_after_duration() public { + function test_unlockableBalanceOf_returns_full_principal_after_duration() public { vm.warp(_vestingEnd); - assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), Parameters.SUPPLY); + assertEq(_xanV2Proxy.unlockableBalanceOf(_defaultSender), Parameters.SUPPLY); vm.prank(_defaultSender); _xanV2Proxy.unlock(); diff --git a/test/XanV2.voting.t.sol b/test/XanV2.voting.t.sol index d677a1f..9e94e75 100644 --- a/test/XanV2.voting.t.sol +++ b/test/XanV2.voting.t.sol @@ -61,8 +61,8 @@ contract XanV2VotingTest is XanV2Fixture { // The clock is before the vesting period. assertLt(Time.timestamp(), _vestingStart); - // Nothing has vested, so nothing is claimable and the whole balance is still locked. - assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), 0); + // Nothing has vested, so nothing is unlockable and the whole balance is still locked. + assertEq(_xanV2Proxy.unlockableBalanceOf(_defaultSender), 0); assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), 0); assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY); @@ -71,15 +71,15 @@ contract XanV2VotingTest is XanV2Fixture { assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); } - function test_getVotes_counts_vested_unclaimed_tokens_during_vesting() public { + function test_getVotes_counts_vested_but_not_unlocked_tokens_during_vesting() public { vm.warp(_vestingMid); // The clock is inside the vesting period. assertGe(Time.timestamp(), _vestingStart); assertLt(Time.timestamp(), _vestingEnd); - // Half has vested and is claimable, but nothing has been claimed: the full balance is still locked. - assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), Parameters.SUPPLY / 2); + // Half has vested and is unlockable, but nothing has been unlocked: the full balance is still locked. + assertEq(_xanV2Proxy.unlockableBalanceOf(_defaultSender), Parameters.SUPPLY / 2); assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), 0); assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY); @@ -88,32 +88,32 @@ contract XanV2VotingTest is XanV2Fixture { assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); } - function test_getVotes_counts_partially_claimed_tokens_during_vesting() public { + function test_getVotes_counts_partially_unlocked_tokens_during_vesting() public { vm.warp(_vestingMid); // The clock is inside the vesting period. assertGe(Time.timestamp(), _vestingStart); assertLt(Time.timestamp(), _vestingEnd); - // Claim the vested half: tokens move from locked to unlocked, but the balance is unchanged. + // Unlock the vested half: tokens move from locked to unlocked, but the balance is unchanged. _unlock(); assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), Parameters.SUPPLY / 2); assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY / 2); - assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), 0); + assertEq(_xanV2Proxy.unlockableBalanceOf(_defaultSender), 0); _selfDelegate(); assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); } - function test_getVotes_counts_vested_unclaimed_tokens_after_vesting() public { + function test_getVotes_counts_vested_but_not_unlocked_tokens_after_vesting() public { vm.warp(_vestingEnd + 1); // The clock is after the vesting period. assertGt(Time.timestamp(), _vestingEnd); - // Everything has vested and is claimable, but nothing has been claimed: the full balance is still locked. - assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), Parameters.SUPPLY); + // Everything has vested and is unlockable, but nothing has been unlocked: the full balance is still locked. + assertEq(_xanV2Proxy.unlockableBalanceOf(_defaultSender), Parameters.SUPPLY); assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), 0); assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), Parameters.SUPPLY); @@ -122,39 +122,39 @@ contract XanV2VotingTest is XanV2Fixture { assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); } - function test_getVotes_counts_fully_claimed_tokens_after_vesting() public { + function test_getVotes_counts_fully_unlocked_tokens_after_vesting() public { vm.warp(_vestingEnd + 1); // The clock is after the vesting period. assertGt(Time.timestamp(), _vestingEnd); - // Claim everything: the entire balance is now unlocked, nothing is locked. + // Unlock everything: the entire balance is now unlocked, nothing is locked. _unlock(); assertEq(_xanV2Proxy.unlockedBalanceOf(_defaultSender), Parameters.SUPPLY); assertEq(_xanV2Proxy.lockedBalanceOf(_defaultSender), 0); - assertEq(_xanV2Proxy.claimableBalanceOf(_defaultSender), 0); + assertEq(_xanV2Proxy.unlockableBalanceOf(_defaultSender), 0); _selfDelegate(); assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); } - function test_getVotes_is_unchanged_by_claiming_across_vesting() public { + function test_getVotes_is_unchanged_by_unlocking_across_vesting() public { _selfDelegate(); - // Before the vesting period: nothing to claim yet. + // Before the vesting period: nothing to unlock yet. vm.warp(_vestingStart - 1); assertLt(Time.timestamp(), _vestingStart); assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); - // During the vesting period: claim the vested half. + // During the vesting period: unlock the vested half. vm.warp(_vestingMid); assertGe(Time.timestamp(), _vestingStart); assertLt(Time.timestamp(), _vestingEnd); _unlock(); assertEq(_xanV2Proxy.getVotes(_defaultSender), Parameters.SUPPLY); - // After the vesting period: claim the remainder. + // After the vesting period: unlock the remainder. vm.warp(_vestingEnd + 1); assertGt(Time.timestamp(), _vestingEnd); _unlock(); @@ -178,7 +178,7 @@ contract XanV2VotingTest is XanV2Fixture { _xanV2Proxy.delegate(_defaultSender); } - /// @notice Claims (unlocks) all of `_defaultSender`'s currently vested tokens. + /// @notice Unlocks all of `_defaultSender`'s currently vested tokens. function _unlock() internal { vm.prank(_defaultSender); _xanV2Proxy.unlock(); diff --git a/test/invariants/HandlerXanV2.t.sol b/test/invariants/HandlerXanV2.t.sol index 78afd99..a70fd5c 100644 --- a/test/invariants/HandlerXanV2.t.sol +++ b/test/invariants/HandlerXanV2.t.sol @@ -50,8 +50,8 @@ contract XanV2Handler is Test { function unlock(uint256 actorSeed) external { address actor = _actorAt(actorSeed); - // `unlock` reverts when nothing has vested since the last claim; skip in that case. - if (token.claimableBalanceOf(actor) == 0) { + // `unlock` reverts when nothing has vested since the last unlock; skip in that case. + if (token.unlockableBalanceOf(actor) == 0) { return; } From dea0a01f0de95a5582c6ca37746be94b909e095a Mon Sep 17 00:00:00 2001 From: Michael Heuer Date: Mon, 29 Jun 2026 21:41:46 +0200 Subject: [PATCH 4/6] refactor: prevent downgrade to v1 --- src/XanV2.sol | 5 +++-- test/XanV2.upgrade.t.sol | 6 ++++++ test/fixtures/XanV2Fixture.sol | 6 ++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/XanV2.sol b/src/XanV2.sol index f6628db..923d625 100644 --- a/src/XanV2.sol +++ b/src/XanV2.sol @@ -265,10 +265,11 @@ contract XanV2 is super._update({from: from, to: to, value: value}); } - /// @notice Authorizes an upgrade. Restricted to the owner (e.g. a multisig or DAO). + /// @notice Authorizes an upgrade. Restricted to the owner (e.g. a multisig or DAO) and to an implementation other + /// than XAN V1. /// @param newImpl The new implementation to authorize the upgrade to. function _authorizeUpgrade(address newImpl) internal view override onlyOwner { - (newImpl); + require(newImpl != _implementationV1(), UpgradeToXanV1NotAllowed()); } /// @notice Returns the amount of an account's V1 principal that has vested by the current timestamp. diff --git a/test/XanV2.upgrade.t.sol b/test/XanV2.upgrade.t.sol index 2199a65..3aab155 100644 --- a/test/XanV2.upgrade.t.sol +++ b/test/XanV2.upgrade.t.sol @@ -21,6 +21,12 @@ contract XanV2UpgradeTest is XanV2Fixture { _xanV2Proxy.upgradeToAndCall(newImpl, ""); } + function test_authorizeUpgrade_reverts_if_the_new_implementation_is_the_V1_implementation() public { + vm.prank(_xanV2Proxy.owner()); + vm.expectRevert(XanV2.UpgradeToXanV1NotAllowed.selector, address(_xanV2Proxy)); + _xanV2Proxy.upgradeToAndCall(_xanV1Impl, ""); + } + function test_authorizeUpgrade_upgrades_if_the_caller_is_the_owner() public { address newImpl = address( new MockXanV2( diff --git a/test/fixtures/XanV2Fixture.sol b/test/fixtures/XanV2Fixture.sol index c802045..6c05467 100644 --- a/test/fixtures/XanV2Fixture.sol +++ b/test/fixtures/XanV2Fixture.sol @@ -17,6 +17,7 @@ abstract contract XanV2Fixture is Test { XanV1 internal _xanV1Proxy; XanV2 internal _xanV2Proxy; + address internal _xanV1Impl; address internal _xanV2Impl; address internal _defaultSender; @@ -42,8 +43,9 @@ abstract contract XanV2Fixture is Test { _vestingEnd = _vestingStart + vestingDuration; // Point the V2 mock at the locally deployed V1 implementation (the vesting principal is stored under it). - _xanV2Impl = - address(new MockXanV2(_xanV1Proxy.implementation(), _defaultSender, _vestingStart, vestingDuration)); + // Captured before the in-place upgrade below, after which the proxy reports the V2 implementation instead. + _xanV1Impl = _xanV1Proxy.implementation(); + _xanV2Impl = address(new MockXanV2(_xanV1Impl, _defaultSender, _vestingStart, vestingDuration)); // Win the voter-body upgrade vote for `_xanV2Impl` and wait out the delay so the upgrade can be executed. vm.startPrank(_defaultSender); From bd0fec71f8e5e4580b7b05f5dd107e2efe9ae6cc Mon Sep 17 00:00:00 2001 From: Michael Heuer Date: Mon, 29 Jun 2026 22:14:22 +0200 Subject: [PATCH 5/6] refactor: remove redundant mint case --- src/XanV2.sol | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/XanV2.sol b/src/XanV2.sol index 923d625..eacadd9 100644 --- a/src/XanV2.sol +++ b/src/XanV2.sol @@ -240,8 +240,7 @@ contract XanV2 is // solhint-enable func-name-mixedcase - /// @notice Updates the balances. Only the unlocked token balances can be moved, except for the minting case, - /// where `from == address(0)`. + /// @notice Updates the balances, allowing only an account's unlocked tokens to be moved. /// @param from The address to take the tokens from. /// @param to The address to give the tokens to. /// @param value The amount of tokens to update that must be unlocked. @@ -249,18 +248,13 @@ contract XanV2 is internal override(ERC20Upgradeable, ERC20VotesUpgradeable) { - // Require the unlocked balance to be at least the updated value, except for the minting case, - // where `from == address(0)`. - // In this case, tokens are created ex-nihilo and formally sent from `address(0)` to the `to` address - // without balance checks. - if (from != address(0)) { - uint256 unlockedBalance = unlockedBalanceOf(from); - - require( - value < unlockedBalance + 1, - UnlockedBalanceInsufficient({sender: from, unlockedBalance: unlockedBalance, valueToLock: value}) - ); - } + // Require the unlocked balance to be at least the updated value. + uint256 unlockedBalance = unlockedBalanceOf(from); + + require( + value < unlockedBalance + 1, + UnlockedBalanceInsufficient({sender: from, unlockedBalance: unlockedBalance, valueToLock: value}) + ); super._update({from: from, to: to, value: value}); } From 4ed25a01f1d026a1d16a8006d237423c1c599e60 Mon Sep 17 00:00:00 2001 From: Michael Heuer Date: Mon, 29 Jun 2026 22:41:40 +0200 Subject: [PATCH 6/6] refactor: rename input argument --- src/interfaces/IXanV2.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/interfaces/IXanV2.sol b/src/interfaces/IXanV2.sol index 07db780..8f45551 100644 --- a/src/interfaces/IXanV2.sol +++ b/src/interfaces/IXanV2.sol @@ -26,14 +26,14 @@ interface IXanV2 { function unlockableBalanceOf(address account) external view returns (uint256 value); /// @notice Returns the unlocked (spendable) token balance of an account. - /// @param from The account to query. + /// @param account The account to query. /// @return unlockedBalance The unlocked balance. - function unlockedBalanceOf(address from) external view returns (uint256 unlockedBalance); + function unlockedBalanceOf(address account) external view returns (uint256 unlockedBalance); /// @notice Returns the still-locked token balance of an account that has not vested or not been unlocked yet. - /// @param from The account to query. + /// @param account The account to query. /// @return lockedBalance The locked balance. - function lockedBalanceOf(address from) external view returns (uint256 lockedBalance); + function lockedBalanceOf(address account) external view returns (uint256 lockedBalance); /// @notice Returns the timestamp at which vesting started. /// @return start The vesting start timestamp.