From 470b0b9a5e8eb28f305fe6d437dfe24b47981914 Mon Sep 17 00:00:00 2001 From: Alex B Date: Sat, 18 Jul 2026 16:38:30 +0200 Subject: [PATCH 01/24] feat: add nest vote and tests --- .../workflows/stonks_integration_tests.yml | 73 + Makefile | 6 + interfaces/BuybackAllocator.json | 1160 ++++++++++++ interfaces/BuybackExecutor.json | 1648 +++++++++++++++++ interfaces/OracleRouter.json | 840 +++++++++ interfaces/StakingRevenueSource.json | 266 +++ interfaces/TokenRateNotifierV2.json | 242 +++ scripts/vote_2026_07_27.py | 220 +++ tests/test_2026_07_27.py | 413 +++++ 9 files changed, 4868 insertions(+) create mode 100644 .github/workflows/stonks_integration_tests.yml create mode 100644 interfaces/BuybackAllocator.json create mode 100644 interfaces/BuybackExecutor.json create mode 100644 interfaces/OracleRouter.json create mode 100644 interfaces/StakingRevenueSource.json create mode 100644 interfaces/TokenRateNotifierV2.json create mode 100644 scripts/vote_2026_07_27.py create mode 100644 tests/test_2026_07_27.py diff --git a/.github/workflows/stonks_integration_tests.yml b/.github/workflows/stonks_integration_tests.yml new file mode 100644 index 00000000..9d494ac1 --- /dev/null +++ b/.github/workflows/stonks_integration_tests.yml @@ -0,0 +1,73 @@ +name: Stonks integration tests + +on: + push: + branches: + - feat/next-vote + workflow_dispatch: + +env: + # Stonks branch carrying the NEST V2 integration tests. + # TODO: switch to `main/master` once the tests land there. + STONKS_REF: feature/dao-898-nest-v2-develop-the-protocol + +jobs: + run-tests: + name: Stonks integration tests in docker + runs-on: [ubuntu-latest] + timeout-minutes: 120 + env: + NODE_OPTIONS: --max_old_space_size=7200 + + # Fork node on localhost:8545, same scripts image core_tests uses (has brownie + node/npm). + services: + tests-runner: + image: ghcr.io/lidofinance/scripts:v21 + ports: + - 8545:8545 + volumes: + - ${{ github.workspace }}:/root/scripts + options: >- + --name=tests-runner + + steps: + - name: Checkout scripts + uses: actions/checkout@v4 + + - name: Checkout stonks (${{ env.STONKS_REF }}) + uses: actions/checkout@v4 + with: + repository: lidofinance/stonks + ref: ${{ env.STONKS_REF }} + path: stonks + persist-credentials: false + + - name: Run init script + run: docker exec tests-runner bash -c 'PYTHONPATH=$PWD make init-scripts' + + - name: Run node + run: docker exec -e ETH_RPC_URL -e NODE_OPTIONS --detach tests-runner bash -c 'NODE_PORT=8545 make node' + env: + ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }} + + - name: Check that the fork is ready + shell: bash + run: | + echo "Waiting for fork node on 127.0.0.1:8545..." + sleep 10 + for i in {1..30}; do + if (echo > /dev/tcp/127.0.0.1/8545) >/dev/null 2>&1; then + echo "Fork is ready ✅" + exit 0 + fi + echo "Not ready yet... ($i/30)" + sleep 1 + done + echo "❌ Fork was not ready after 30 seconds" >&2 + exit 1 + + - name: Enact vote and run stonks integration tests + run: docker exec -e ETH_RPC_URL -e ETHERSCAN_TOKEN -e NODE_OPTIONS tests-runner bash -c 'make test-stonks-integration STONKS_DIR=stonks' + env: + ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }} + ETHERSCAN_TOKEN: ${{ secrets.ETHERSCAN_TOKEN }} diff --git a/Makefile b/Makefile index 47d7b1a9..49c1a0c6 100644 --- a/Makefile +++ b/Makefile @@ -30,6 +30,7 @@ CORE_BRANCH ?= master NODE_PORT ?= 8545 SECONDARY_NETWORK ?= mfh-2 NETWORK_STATE_FILE ?= deployed-mainnet.json +STONKS_DIR ?= ../stonks test-1/2: poetry run brownie test tests/[tc]*.py tests/regression/test_staking_router_stake_distribution.py --durations=20 --network mfh-1 @@ -94,6 +95,11 @@ test-core: FORKING_BLOCK_NUMBER=$$LATEST_BLOCK_NUMBER \ yarn test:integration +test-stonks-integration: ci-prepare-environment + cd $(STONKS_DIR) && \ + { [ -d node_modules ] || npm ci; } && \ + RPC_URL=$(ETH_RPC_URL) npm run test:integration + slots: @echo "Input https://github.com/lidofinance/protocol-onchain-mon-bots/blob/main/bots/ethereum-steth-v2/src/utils/constants.mainnet.ts file content (end with Enter and Ctrl+D):" @cat | grep -v "import { ERC20 } from './constants'" | sed 's/ERC20/any/g' > slots2.ts diff --git a/interfaces/BuybackAllocator.json b/interfaces/BuybackAllocator.json new file mode 100644 index 00000000..091f03f8 --- /dev/null +++ b/interfaces/BuybackAllocator.json @@ -0,0 +1,1160 @@ +[ + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "admin", + "type": "address" + }, + { + "internalType": "address", + "name": "treasury", + "type": "address" + }, + { + "internalType": "address", + "name": "stEth", + "type": "address" + }, + { + "internalType": "address", + "name": "oracleRouter", + "type": "address" + }, + { + "internalType": "address", + "name": "executor", + "type": "address" + }, + { + "internalType": "uint128", + "name": "dailyCapUSD", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "yearlyCapUSD", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "reserveDailyRateUSD", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "minStEthPriceUSD", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "minSpendPerCallUSD", + "type": "uint128" + }, + { + "internalType": "uint16", + "name": "surplusShareBP", + "type": "uint16" + }, + { + "internalType": "address[]", + "name": "revenueSources", + "type": "address[]" + } + ], + "internalType": "struct BuybackAllocator.ConstructorParams", + "name": "initParams_", + "type": "tuple" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AlreadyActivated", + "type": "error" + }, + { + "inputs": [], + "name": "DailyCapExceedsYearlyCap", + "type": "error" + }, + { + "inputs": [], + "name": "DailyCapUSDZero", + "type": "error" + }, + { + "inputs": [], + "name": "ExecutorZeroAddress", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidAdminAddress", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidTreasuryAddress", + "type": "error" + }, + { + "inputs": [], + "name": "MinSpendPerCallExceedsDailyCap", + "type": "error" + }, + { + "inputs": [], + "name": "MinSpendPerCallUSDZero", + "type": "error" + }, + { + "inputs": [], + "name": "NotActivated", + "type": "error" + }, + { + "inputs": [], + "name": "OracleRouterZeroAddress", + "type": "error" + }, + { + "inputs": [], + "name": "RevenueSourceAlreadyRegistered", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "maxSources", + "type": "uint256" + } + ], + "name": "RevenueSourceLimitReached", + "type": "error" + }, + { + "inputs": [], + "name": "RevenueSourceNotRegistered", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "source", + "type": "address" + } + ], + "name": "RevenueSourceUnsupported", + "type": "error" + }, + { + "inputs": [], + "name": "RevenueSourceZeroAddress", + "type": "error" + }, + { + "inputs": [], + "name": "StEthZeroAddress", + "type": "error" + }, + { + "inputs": [], + "name": "SurplusShareBPInvalid", + "type": "error" + }, + { + "inputs": [], + "name": "YearlyCapUSDZero", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "activationTS", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "lastTotalRevenueUSD", + "type": "uint256" + } + ], + "name": "Activated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "triggeredBy", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "executor", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "spendUSD", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "spendStEth", + "type": "uint256" + } + ], + "name": "Allocated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "indexed": false, + "internalType": "enum IBuybackAllocator.AllocationStatus", + "name": "reason", + "type": "uint8" + } + ], + "name": "AllocationSkipped", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "lastTotalRevenueUSD", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "reserveUSD", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "budgetDeltaUSD", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "budgetUSD", + "type": "int256" + } + ], + "name": "Checkpoint", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "dailyCapUSD", + "type": "uint256" + } + ], + "name": "DailyCapUSDSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ERC20Recovered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "EtherRecovered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "executor", + "type": "address" + } + ], + "name": "ExecutorSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "minSpendPerCallUSD", + "type": "uint256" + } + ], + "name": "MinSpendPerCallUSDSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "minStEthPriceUSD", + "type": "uint256" + } + ], + "name": "MinStEthPriceUSDSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "anchorTS", + "type": "uint256" + } + ], + "name": "ReserveAnchored", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "reserveDailyRateUSD", + "type": "uint256" + } + ], + "name": "ReserveDailyRateUSDSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "source", + "type": "address" + } + ], + "name": "RevenueSourceAdded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "source", + "type": "address" + } + ], + "name": "RevenueSourceRemoved", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32" + } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "surplusShareBP", + "type": "uint256" + } + ], + "name": "SurplusShareBPSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "windowDurationSeconds", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newEndTS", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "previousSpentUSD", + "type": "uint256" + } + ], + "name": "WindowRolled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "yearlyCapUSD", + "type": "uint256" + } + ], + "name": "YearlyCapUSDSet", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MANAGER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_BASIS_POINTS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_REVENUE_SOURCES", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ORACLE_ROUTER", + "outputs": [ + { + "internalType": "contract IOracleRouter", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "STETH", + "outputs": [ + { + "internalType": "contract IStETH", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "TREASURY", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "activate", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "activationTS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "source_", + "type": "address" + } + ], + "name": "addRevenueSource", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "allocate", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "budgetUSD", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "daily", + "outputs": [ + { + "internalType": "uint64", + "name": "endTS", + "type": "uint64" + }, + { + "internalType": "uint192", + "name": "spentUSD", + "type": "uint192" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "dailyCapUSD", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "executor", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleAdmin", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "getRoleMember", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleMemberCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "hasRole", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lastTotalRevenueUSD", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minSpendPerCallUSD", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minStEthPriceUSD", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token_", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount_", + "type": "uint256" + } + ], + "name": "recoverERC20", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "recoverEther", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "source_", + "type": "address" + } + ], + "name": "removeRevenueSource", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "reserveAnchorTS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "reserveDailyRateUSD", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "revenueSources", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "dailyCapUSD_", + "type": "uint256" + } + ], + "name": "setDailyCapUSD", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newExecutor_", + "type": "address" + } + ], + "name": "setExecutor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "minSpendPerCallUSD_", + "type": "uint256" + } + ], + "name": "setMinSpendPerCallUSD", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "minStEthPriceUSD_", + "type": "uint256" + } + ], + "name": "setMinStEthPriceUSD", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reserveDailyRateUSD_", + "type": "uint256" + } + ], + "name": "setReserveDailyRateUSD", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "surplusShareBP_", + "type": "uint256" + } + ], + "name": "setSurplusShareBP", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "yearlyCapUSD_", + "type": "uint256" + } + ], + "name": "setYearlyCapUSD", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "spendable", + "outputs": [ + { + "internalType": "enum IBuybackAllocator.AllocationStatus", + "name": "status", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "spendableUSD", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spendableStEth", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "surplusShareBP", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "yearly", + "outputs": [ + { + "internalType": "uint64", + "name": "endTS", + "type": "uint64" + }, + { + "internalType": "uint192", + "name": "spentUSD", + "type": "uint192" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "yearlyCapUSD", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/interfaces/BuybackExecutor.json b/interfaces/BuybackExecutor.json new file mode 100644 index 00000000..893259f6 --- /dev/null +++ b/interfaces/BuybackExecutor.json @@ -0,0 +1,1648 @@ +[ + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "admin", + "type": "address" + }, + { + "internalType": "address", + "name": "treasury", + "type": "address" + }, + { + "internalType": "address", + "name": "wstEth", + "type": "address" + }, + { + "internalType": "address", + "name": "ldo", + "type": "address" + }, + { + "internalType": "address", + "name": "oracleRouter", + "type": "address" + }, + { + "internalType": "address", + "name": "curvePoolAndToken", + "type": "address" + }, + { + "internalType": "uint16", + "name": "poolPriceDivergenceToleranceBps", + "type": "uint16" + }, + { + "internalType": "uint128", + "name": "minAllowedOrderAmount", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "maxAllowedOrderAmount", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "minDepositValueUsd", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "maxDepositValueUsd", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "poolBootstrapMinTvlUsd", + "type": "uint128" + } + ], + "internalType": "struct BuybackExecutor.InitParams", + "name": "initParams_", + "type": "tuple" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "depositValueUsd", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "minDepositValueUsd", + "type": "uint256" + } + ], + "name": "DepositValueBelowMinimum", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requested", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "available", + "type": "uint256" + } + ], + "name": "InsufficientLpTokenBalance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "minAllowedOrderAmount", + "type": "uint256" + } + ], + "name": "InsufficientStonksBalance", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidAdminAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "coin0", + "type": "address" + }, + { + "internalType": "address", + "name": "coin1", + "type": "address" + } + ], + "name": "InvalidCurvePool", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidCurvePoolAndTokenAddress", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidCurvePoolPriceOracle", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "minDepositValueUsd", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "maxDepositValueUsd", + "type": "uint256" + } + ], + "name": "InvalidDepositValueLimits", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidLdoAddress", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidOraclePrice", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidOracleRouterAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "minAllowedOrderAmount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "maxAllowedOrderAmount", + "type": "uint256" + } + ], + "name": "InvalidOrderAmountLimits", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "poolBootstrapMinTvlUsd", + "type": "uint256" + } + ], + "name": "InvalidPoolBootstrapMinTvlUsd", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "poolPriceDivergenceToleranceBps", + "type": "uint256" + } + ], + "name": "InvalidPoolPriceDivergenceTolerance", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidStEthAddress", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidStonksAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "manager", + "type": "address" + } + ], + "name": "InvalidStonksManager", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "stonks", + "type": "address" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "InvalidStonksReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "tokenFrom", + "type": "address" + }, + { + "internalType": "address", + "name": "tokenTo", + "type": "address" + } + ], + "name": "InvalidStonksTokenPair", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidTreasuryAddress", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidWstEthAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "order", + "type": "address" + }, + { + "internalType": "uint256", + "name": "validTo", + "type": "uint256" + } + ], + "name": "LiveOrderInPlace", + "type": "error" + }, + { + "inputs": [], + "name": "NoTrackedOrder", + "type": "error" + }, + { + "inputs": [], + "name": "NotInLpMode", + "type": "error" + }, + { + "inputs": [], + "name": "OraclePriceUnavailable", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "poolPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "oraclePrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "divergenceBps", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "toleranceBps", + "type": "uint256" + } + ], + "name": "PoolPriceDivergenceTooHigh", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroLdoBalance", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroLpAmount", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroStEthBalance", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "stonks", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "freeStEth", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "forwardedToStonks", + "type": "uint256" + } + ], + "name": "AllocationProcessed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ERC20Recovered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "EtherRecovered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "order", + "type": "address" + } + ], + "name": "LastOrderCancelled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "ldoAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "wstEthAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "lpTokensMinted", + "type": "uint256" + } + ], + "name": "LiquidityAdded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "lpAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "ldoAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "wstEthAmount", + "type": "uint256" + } + ], + "name": "LiquidityRemoved", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "previousMaxAllowedOrderAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newMaxAllowedOrderAmount", + "type": "uint256" + } + ], + "name": "MaxAllowedOrderAmountSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "previousMaxDepositValueUsd", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newMaxDepositValueUsd", + "type": "uint256" + } + ], + "name": "MaxDepositValueUsdSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "previousMinAllowedOrderAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newMinAllowedOrderAmount", + "type": "uint256" + } + ], + "name": "MinAllowedOrderAmountSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "previousMinDepositValueUsd", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newMinDepositValueUsd", + "type": "uint256" + } + ], + "name": "MinDepositValueUsdSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "order", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "validTo", + "type": "uint256" + } + ], + "name": "OrderAbandoned", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "order", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "sellAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "minBuyAmount", + "type": "uint256" + } + ], + "name": "OrderPlaced", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Paused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "previousPoolBootstrapMinTvlUsd", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newPoolBootstrapMinTvlUsd", + "type": "uint256" + } + ], + "name": "PoolBootstrapMinTvlUsdSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "previousPoolPriceDivergenceToleranceBps", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newPoolPriceDivergenceToleranceBps", + "type": "uint256" + } + ], + "name": "PoolPriceDivergenceToleranceBpsSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousStonks", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "stEthAmount", + "type": "uint256" + } + ], + "name": "PreviousStonksStEthRecovered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32" + } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "order", + "type": "address" + } + ], + "name": "StaleOrderCleared", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousStonks", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newStonks", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "previousLpModeEnabled", + "type": "bool" + }, + { + "indexed": false, + "internalType": "bool", + "name": "newLpModeEnabled", + "type": "bool" + } + ], + "name": "StonksAndOperatingModeSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Unpaused", + "type": "event" + }, + { + "inputs": [], + "name": "ALLOCATOR_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "CURVE_POOL_AND_TOKEN", + "outputs": [ + { + "internalType": "contract ICurvePool", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "EMERGENCY_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "LDO", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MANAGER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_BASIS_POINTS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_POOL_BOOTSTRAP_MIN_TVL_USD", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_POOL_DIVERGENCE_TOLERANCE_BPS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_ORDER_RESIDUAL_TO_RECOVER", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ORACLE_ROUTER", + "outputs": [ + { + "internalType": "contract IOracleRouter", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "PRICE_UNIT", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "STETH", + "outputs": [ + { + "internalType": "contract IStETH", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "TREASURY", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "WSTETH", + "outputs": [ + { + "internalType": "contract IWstETH", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "addLiquidity", + "outputs": [ + { + "internalType": "uint256", + "name": "lpTokensMinted", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "canAddLiquidity", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "cancelLastOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "getAvailableLiquidity", + "outputs": [ + { + "internalType": "uint256", + "name": "ldoAmount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "stEthAmount", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getLpTokenBalance", + "outputs": [ + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getPlacementStatus", + "outputs": [ + { + "components": [ + { + "internalType": "bool", + "name": "canPlace", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "sellAmount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "estimatedBuyAmount", + "type": "uint256" + }, + { + "internalType": "address", + "name": "activeOrder", + "type": "address" + }, + { + "internalType": "uint256", + "name": "activeOrderValidTo", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "isStonksCreationPaused", + "type": "bool" + }, + { + "internalType": "bool", + "name": "isStonksKilled", + "type": "bool" + } + ], + "internalType": "struct IBuybackExecutor.PlacementStatus", + "name": "status", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleAdmin", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "getRoleMember", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleMemberCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "hasRole", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lastOrderAddress", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lastOrderValidTo", + "outputs": [ + { + "internalType": "uint32", + "name": "", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lpModeEnabled", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxAllowedOrderAmount", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxDepositValueUsd", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minAllowedOrderAmount", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minDepositValueUsd", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "onStEthAllocated", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "pause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "pauseStonksCreation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "pauseStonksSignatures", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paused", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "placeOrder", + "outputs": [ + { + "internalType": "address", + "name": "newOrder", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "poolBootstrapMinTvlUsd", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "poolPriceDivergenceToleranceBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token_", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount_", + "type": "uint256" + } + ], + "name": "recoverERC20", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "recoverEther", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "lpAmount_", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "minLdoAmount_", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "minWstEthAmount_", + "type": "uint256" + } + ], + "name": "removeLiquidityAndRecoverToTreasury", + "outputs": [ + { + "internalType": "uint256", + "name": "ldoAmount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "stEthAmount", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "maxAllowedOrderAmount_", + "type": "uint256" + } + ], + "name": "setMaxAllowedOrderAmount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "maxDepositValueUsd_", + "type": "uint256" + } + ], + "name": "setMaxDepositValueUsd", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "minAllowedOrderAmount_", + "type": "uint256" + } + ], + "name": "setMinAllowedOrderAmount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "minDepositValueUsd_", + "type": "uint256" + } + ], + "name": "setMinDepositValueUsd", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "poolBootstrapMinTvlUsd_", + "type": "uint256" + } + ], + "name": "setPoolBootstrapMinTvlUsd", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "poolPriceDivergenceToleranceBps_", + "type": "uint256" + } + ], + "name": "setPoolPriceDivergenceToleranceBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "stonks_", + "type": "address" + } + ], + "name": "setStonks", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "stonks", + "outputs": [ + { + "internalType": "contract IStonks", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "stonksOrderDurationSeconds", + "outputs": [ + { + "internalType": "uint32", + "name": "", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "unpause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "unpauseStonksCreation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "unpauseStonksSignatures", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/interfaces/OracleRouter.json b/interfaces/OracleRouter.json new file mode 100644 index 00000000..dc584eaf --- /dev/null +++ b/interfaces/OracleRouter.json @@ -0,0 +1,840 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "admin_", + "type": "address" + }, + { + "internalType": "address", + "name": "feedRegistry_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "EthUsdBridgeMissing", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "expectedAggregator", + "type": "address" + }, + { + "internalType": "address", + "name": "actualAggregator", + "type": "address" + }, + { + "internalType": "uint8", + "name": "expectedDecimals", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "actualDecimals", + "type": "uint8" + } + ], + "name": "FeedConfigOutOfSync", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "base", + "type": "address" + }, + { + "internalType": "address", + "name": "quote", + "type": "address" + } + ], + "name": "FeedMissing", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "admin", + "type": "address" + } + ], + "name": "InvalidAdminAddress", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidAggregatorDecimals", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "feedRegistry", + "type": "address" + } + ], + "name": "InvalidFeedRegistryAddress", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidStaleness", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "InvalidTokenAddress", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidTokenDecimals", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "NotAdmin", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "NotAdminOrManager", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "NotEmergencyOperator", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "aggregator", + "type": "address" + }, + { + "internalType": "int256", + "name": "answer", + "type": "int256" + } + ], + "name": "OracleBadAnswer", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "aggregator", + "type": "address" + }, + { + "internalType": "uint8", + "name": "feedDecimals", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "unitDecimals", + "type": "uint8" + } + ], + "name": "OracleQuantizedToZero", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "aggregator", + "type": "address" + }, + { + "internalType": "uint256", + "name": "lastUpdate", + "type": "uint256" + } + ], + "name": "OracleStale", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "aggregator", + "type": "address" + }, + { + "internalType": "uint80", + "name": "roundId", + "type": "uint80" + }, + { + "internalType": "uint80", + "name": "answeredInRound", + "type": "uint80" + } + ], + "name": "OracleUnanswered", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "TokenNotConfigured", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "bool", + "name": "currentState", + "type": "bool" + } + ], + "name": "TokenStateUnchanged", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "admin", + "type": "address" + } + ], + "name": "AdminSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "emergencyOperator", + "type": "address" + } + ], + "name": "EmergencyOperatorSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "aggregator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "aggregatorDecimals", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "maxStalenessSeconds", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "scaleNumerator", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "scaleDenominator", + "type": "uint128" + } + ], + "name": "EthUsdBridgeConfigured", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "manager", + "type": "address" + } + ], + "name": "ManagerSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "isActive", + "type": "bool" + } + ], + "name": "TokenActiveUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "enum IOracleRouter.QuoteDenomination", + "name": "primaryQuote", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "address", + "name": "aggregator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "aggregatorDecimals", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "maxStalenessSeconds", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "tokenDecimals", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "scaleNumerator", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "scaleDenominator", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "bool", + "name": "isActive", + "type": "bool" + } + ], + "name": "TokenConfigured", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "overrideSeconds", + "type": "uint32" + } + ], + "name": "TokenEthUsdStalenessOverridden", + "type": "event" + }, + { + "inputs": [], + "name": "ADMIN", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "FEED_REGISTRY", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_DECIMALS", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "PRICE_DECIMALS", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "PRICE_UNIT", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "emergencyOperator", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ethUsdBridge", + "outputs": [ + { + "internalType": "uint128", + "name": "scaleNumerator", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "scaleDenominator", + "type": "uint128" + }, + { + "internalType": "address", + "name": "aggregator", + "type": "address" + }, + { + "internalType": "uint32", + "name": "maxStalenessSeconds", + "type": "uint32" + }, + { + "internalType": "uint8", + "name": "aggregatorDecimals", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "baseToken_", + "type": "address" + }, + { + "internalType": "address", + "name": "quoteToken_", + "type": "address" + }, + { + "internalType": "enum IOracleRouter.QuoteDenomination", + "name": "quote_", + "type": "uint8" + } + ], + "name": "getPricesAndDecimals", + "outputs": [ + { + "internalType": "uint256", + "name": "basePrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "quotePrice", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "baseTokenDecimals", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "quoteTokenDecimals", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "baseToken_", + "type": "address" + }, + { + "internalType": "address", + "name": "quoteToken_", + "type": "address" + } + ], + "name": "getUsdPrices", + "outputs": [ + { + "internalType": "uint256", + "name": "baseUsdPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "quoteUsdPrice", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "isBridgeInSync", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token_", + "type": "address" + } + ], + "name": "isFeedInSync", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "manager", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "emergencyOperator_", + "type": "address" + } + ], + "name": "setEmergencyOperator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint32", + "name": "maxStalenessSeconds_", + "type": "uint32" + } + ], + "name": "setEthUsdBridge", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "manager_", + "type": "address" + } + ], + "name": "setManager", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token_", + "type": "address" + }, + { + "internalType": "bool", + "name": "isActive_", + "type": "bool" + } + ], + "name": "setTokenActive", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token_", + "type": "address" + }, + { + "internalType": "uint32", + "name": "overrideSeconds_", + "type": "uint32" + } + ], + "name": "setTokenEthUsdStalenessOverride", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token_", + "type": "address" + }, + { + "internalType": "enum IOracleRouter.QuoteDenomination", + "name": "primaryQuote_", + "type": "uint8" + }, + { + "internalType": "uint32", + "name": "maxStalenessSeconds_", + "type": "uint32" + }, + { + "internalType": "bool", + "name": "isActive_", + "type": "bool" + } + ], + "name": "setTokenFeed", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "syncEthUsdBridge", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token_", + "type": "address" + } + ], + "name": "syncTokenFeed", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "tokenConfig", + "outputs": [ + { + "components": [ + { + "internalType": "uint128", + "name": "scaleNumerator", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "scaleDenominator", + "type": "uint128" + }, + { + "internalType": "address", + "name": "aggregator", + "type": "address" + }, + { + "internalType": "uint32", + "name": "maxStalenessSeconds", + "type": "uint32" + }, + { + "internalType": "uint8", + "name": "aggregatorDecimals", + "type": "uint8" + } + ], + "internalType": "struct OracleRouter.FeedConfig", + "name": "primaryFeed", + "type": "tuple" + }, + { + "internalType": "uint32", + "name": "ethUsdMaxStalenessOverrideSeconds", + "type": "uint32" + }, + { + "internalType": "uint8", + "name": "tokenDecimals", + "type": "uint8" + }, + { + "internalType": "enum IOracleRouter.QuoteDenomination", + "name": "primaryQuote", + "type": "uint8" + }, + { + "internalType": "bool", + "name": "isActive", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/interfaces/StakingRevenueSource.json b/interfaces/StakingRevenueSource.json new file mode 100644 index 00000000..e98dc2b8 --- /dev/null +++ b/interfaces/StakingRevenueSource.json @@ -0,0 +1,266 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "oracleRouter_", + "type": "address" + }, + { + "internalType": "address", + "name": "lidoLocator_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "lidoLocator", + "type": "address" + } + ], + "name": "InvalidLidoLocatorAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "oracleRouter", + "type": "address" + } + ], + "name": "InvalidOracleRouterAddress", + "type": "error" + }, + { + "inputs": [], + "name": "OracleReturnedZeroPrice", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller", + "type": "address" + } + ], + "name": "UnauthorizedCaller", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "stEthConverted", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "stEthUsdPrice", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "revenueUSD", + "type": "uint256" + } + ], + "name": "PendingRevenueConverted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "stEthAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "pendingRevenueStEth", + "type": "uint256" + } + ], + "name": "RevenueAccumulatedInStEth", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "amountUSD", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "cumulativeRevenueUSD", + "type": "uint256" + } + ], + "name": "RevenueAdded", + "type": "event" + }, + { + "inputs": [], + "name": "LIDO_LOCATOR", + "outputs": [ + { + "internalType": "contract ILidoLocator", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ORACLE_ROUTER", + "outputs": [ + { + "internalType": "contract IOracleRouter", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "PRICE_UNIT", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "convertPendingRevenueToUSD", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "getCumulativeRevenueUSD", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lastReportTimestamp", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pendingRevenueStEth", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "reportTimestamp_", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sharesMintedAsFees_", + "type": "uint256" + } + ], + "name": "pushTokenRate", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId_", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/interfaces/TokenRateNotifierV2.json b/interfaces/TokenRateNotifierV2.json new file mode 100644 index 00000000..3640c7a2 --- /dev/null +++ b/interfaces/TokenRateNotifierV2.json @@ -0,0 +1,242 @@ +[ + { + "inputs": [], + "name": "ErrorAddExistedObserver", + "type": "error" + }, + { + "inputs": [], + "name": "ErrorBadObserverInterface", + "type": "error" + }, + { + "inputs": [], + "name": "ErrorMaxObserversCountExceeded", + "type": "error" + }, + { + "inputs": [], + "name": "ErrorNoObserverToRemove", + "type": "error" + }, + { + "inputs": [], + "name": "ErrorNotAuthorizedRebaseCaller", + "type": "error" + }, + { + "inputs": [], + "name": "ErrorTokenRateNotifierRevertedWithNoData", + "type": "error" + }, + { + "inputs": [], + "name": "ErrorZeroAddressObserver", + "type": "error" + }, + { + "inputs": [], + "name": "ErrorZeroAddressOwner", + "type": "error" + }, + { + "inputs": [], + "name": "ErrorZeroAddressTokenRateProvider", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "observer", + "type": "address" + } + ], + "name": "ObserverAdded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "observer", + "type": "address" + } + ], + "name": "ObserverRemoved", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "observer", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "lowLevelRevertData", + "type": "bytes" + } + ], + "name": "PushTokenRateFailed", + "type": "event" + }, + { + "inputs": [], + "name": "MAX_OBSERVERS_COUNT", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "TOKEN_RATE_PROVIDER", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "observer_", + "type": "address" + }, + { + "internalType": "enum ITokenRateNotifier.ObserverKind", + "name": "kind_", + "type": "uint8" + } + ], + "name": "addObserver", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_reportTimestamp", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_timeElapsed", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_preTotalShares", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_preTotalEther", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_postTotalShares", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_postTotalEther", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_sharesMintedAsFees", + "type": "uint256" + } + ], + "name": "handlePostTokenRebase", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "observers", + "outputs": [ + { + "internalType": "address", + "name": "observer", + "type": "address" + }, + { + "internalType": "enum ITokenRateNotifier.ObserverKind", + "name": "kind", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "observersLength", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "observer_", + "type": "address" + } + ], + "name": "removeObserver", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/scripts/vote_2026_07_27.py b/scripts/vote_2026_07_27.py new file mode 100644 index 00000000..dd25f51f --- /dev/null +++ b/scripts/vote_2026_07_27.py @@ -0,0 +1,220 @@ +""" +Vote 2026_07_27 + +Launch of the NEST Automated Buybacks system in treasury-only mode (LIP-36). + +=== 1. DG PROPOSAL === +1.1. Add OpStackTokenRatePusher 0xd54c1c6413caac3477ac14b2a80d5398e3c32ffe as NoArgs (kind = 0) to the new TokenRateNotifier 0xbe05d12Fd10919F1881125006523452F6aFF791b +1.2. Add StakingRevenueSource 0x6220212a33a87Ed7Cc386B67eB2c393974F28C38 as WithArgs (kind = 1) to the new TokenRateNotifier 0xbe05d12Fd10919F1881125006523452F6aFF791b +1.3. Upgrade LidoLocator proxy 0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb implementation to 0xF2Ffb952e129a63F0614Ff87126E1d4a494A2313 +1.4. Add BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 as allowed recipient "Buyback Allocator" on the Stonks stETH top-up AllowedRecipientsRegistry 0x1a7cFA9EFB4D5BfFDE87B0FaEb1fC65d653868C0 + +=== NON-DG ITEMS === +2. Set Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 as manager on OracleRouter 0x79ef3a538200Fe4981D67E7e886bfb36D4Cb5a31 +3. Set treasury-mode Stonks 0xb368586CB980895E51e1D82102E63b3F69d3F151 on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7 +4. Grant Buybacks.BuybackExecutor.ALLOCATOR_ROLE 0x87905334ad07701d0cd9b21ea0599de1a0cab067e0ab49596d423d87159ac7f2 to BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7 +5. Grant Buybacks.MANAGER_ROLE 0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e to Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7 +6. Grant Buybacks.BuybackExecutor.EMERGENCY_ROLE 0xc748c205190870b4e890036f373e30556929f7fbf3db8644c998a652c1996dbd to Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7 +7. Grant Buybacks.BuybackExecutor.EMERGENCY_ROLE 0xc748c205190870b4e890036f373e30556929f7fbf3db8644c998a652c1996dbd to Emergency Committee 0x73b047fe6337183A454c5217241D780a932777bD on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7 +8. Grant Buybacks.MANAGER_ROLE 0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e to Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 on BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 +9. Call activate() on BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 + +TODO (after vote) Vote #{vote number} passed & executed on {date+time}, block {blockNumber}. +""" + +from brownie import interface +from typing import Dict, List, Tuple + +from utils.voting import bake_vote_items, confirm_vote_script, create_vote +from utils.ipfs import upload_vote_ipfs_description, calculate_vote_ipfs_description +from utils.config import get_deployer_account, get_is_live, get_priority_fee +from utils.mainnet_fork import pass_and_exec_dao_vote +from utils.dual_governance import submit_proposals + +from utils.agent import agent_forward + + +# ============================== Addresses =================================== +ARAGON_AGENT = "0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c" +TMC = "0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647" # Treasury Management Committee (MANAGER_ROLE) +EMERGENCY_COMMITTEE = "0x73b047fe6337183A454c5217241D780a932777bD" # Emergency Brakes (EMERGENCY_ROLE) +ORACLE_ROUTER = "0x79ef3a538200Fe4981D67E7e886bfb36D4Cb5a31" +LIDO_LOCATOR = "0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb" +OP_STACK_TOKEN_RATE_PUSHER = "0xd54c1c6413caac3477ac14b2a80d5398e3c32ffe" +STONKS_STETH_TOPUP_REGISTRY = "0x1a7cFA9EFB4D5BfFDE87B0FaEb1fC65d653868C0" + +# NEST contracts +NEW_TOKEN_RATE_NOTIFIER = "0xbe05d12Fd10919F1881125006523452F6aFF791b" +NEW_LIDO_LOCATOR_IMPL = "0xF2Ffb952e129a63F0614Ff87126E1d4a494A2313" +STAKING_REVENUE_SOURCE = "0x6220212a33a87Ed7Cc386B67eB2c393974F28C38" +BUYBACK_EXECUTOR = "0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7" +BUYBACK_STONKS_TREASURY = "0xb368586CB980895E51e1D82102E63b3F69d3F151" +BUYBACK_ALLOCATOR = "0xAA568141c051f2D1132b110f8391F18D48E8D889" + + +# ============================== Constants =================================== +MANAGER_ROLE = "0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e" # keccak256("Buybacks.MANAGER_ROLE") +ALLOCATOR_ROLE = "0x87905334ad07701d0cd9b21ea0599de1a0cab067e0ab49596d423d87159ac7f2" # keccak256("Buybacks.BuybackExecutor.ALLOCATOR_ROLE") +EMERGENCY_ROLE = "0xc748c205190870b4e890036f373e30556929f7fbf3db8644c998a652c1996dbd" # keccak256("Buybacks.BuybackExecutor.EMERGENCY_ROLE") + +# TokenRateNotifier.ObserverKind +OBSERVER_KIND_NO_ARGS = 0 +OBSERVER_KIND_WITH_ARGS = 1 + +DG_PROPOSAL_METADATA = "Register the StakingRevenueSource on the TokenRateNotifier to enable NEST" + +# ipfs description +IPFS_DESCRIPTION = """ +# Launch NEST Automated Buybacks in treasury-only mode + +1. **Register the StakingRevenueSource on the TokenRateNotifier to enable NEST** through a Dual +Governance proposal. Migrate the OpStack token rate pusher and register the StakingRevenueSource +on the new TokenRateNotifier, upgrade the LidoLocator implementation to use the new notifier as +the post-token-rebase receiver, and allow the BuybackAllocator on the Stonks stETH top-up Easy +Track registry. Item 1. +2. **Launch NEST Automated Buybacks in treasury-only mode** (LIP-36). Set the Treasury Management +Committee as the OracleRouter manager, configure the treasury-mode Stonks on the BuybackExecutor, +grant the manager, allocator, and emergency roles on the BuybackExecutor and BuybackAllocator, +and activate the BuybackAllocator. Items 2-9. +""" + + +# ================================ Main ====================================== +def get_dg_items() -> List[Tuple[str, str]]: + new_token_rate_notifier = interface.TokenRateNotifierV2(NEW_TOKEN_RATE_NOTIFIER) + lido_locator_proxy = interface.OssifiableProxy(LIDO_LOCATOR) + stonks_topup_registry = interface.AllowedRecipientRegistry(STONKS_STETH_TOPUP_REGISTRY) + + return [ + agent_forward([ + # 1.1. Add OpStackTokenRatePusher as NoArgs (kind = 0) to the new TokenRateNotifier + ( + new_token_rate_notifier.address, + new_token_rate_notifier.addObserver.encode_input(OP_STACK_TOKEN_RATE_PUSHER, OBSERVER_KIND_NO_ARGS), + ), + # 1.2. Add StakingRevenueSource as WithArgs (kind = 1) to the new TokenRateNotifier + ( + new_token_rate_notifier.address, + new_token_rate_notifier.addObserver.encode_input(STAKING_REVENUE_SOURCE, OBSERVER_KIND_WITH_ARGS), + ), + # 1.3. Upgrade LidoLocator proxy implementation to the new implementation + ( + lido_locator_proxy.address, + lido_locator_proxy.proxy__upgradeTo.encode_input(NEW_LIDO_LOCATOR_IMPL), + ), + # 1.4. Add BuybackAllocator as allowed recipient "Buyback Allocator" on the Stonks stETH top-up AllowedRecipientsRegistry + ( + stonks_topup_registry.address, + stonks_topup_registry.addRecipient.encode_input(BUYBACK_ALLOCATOR, "Buyback Allocator"), + ), + ]), + ] + + +def get_vote_items() -> Tuple[List[str], List[Tuple[str, str]]]: + oracle_router = interface.OracleRouter(ORACLE_ROUTER) + buyback_executor = interface.BuybackExecutor(BUYBACK_EXECUTOR) + buyback_allocator = interface.BuybackAllocator(BUYBACK_ALLOCATOR) + + dg_items = get_dg_items() + dg_call_script = submit_proposals([(dg_items, DG_PROPOSAL_METADATA)]) + + vote_desc_items, call_script_items = zip( + ( + "1. Submit a Dual Governance proposal to register the StakingRevenueSource on the new TokenRateNotifier and enable NEST", + dg_call_script[0], + ), + ( + "2. Set the Treasury Management Committee as manager on the OracleRouter", + ( + oracle_router.address, + oracle_router.setManager.encode_input(TMC), + ), + ), + ( + "3. Set the treasury-mode Stonks on the BuybackExecutor", + ( + buyback_executor.address, + buyback_executor.setStonks.encode_input(BUYBACK_STONKS_TREASURY), + ), + ), + ( + "4. Grant Buybacks.BuybackExecutor.ALLOCATOR_ROLE to the BuybackAllocator on the BuybackExecutor", + ( + buyback_executor.address, + buyback_executor.grantRole.encode_input(ALLOCATOR_ROLE, BUYBACK_ALLOCATOR), + ), + ), + ( + "5. Grant Buybacks.MANAGER_ROLE to the Treasury Management Committee on the BuybackExecutor", + ( + buyback_executor.address, + buyback_executor.grantRole.encode_input(MANAGER_ROLE, TMC), + ), + ), + ( + "6. Grant Buybacks.BuybackExecutor.EMERGENCY_ROLE to the Treasury Management Committee on the BuybackExecutor", + ( + buyback_executor.address, + buyback_executor.grantRole.encode_input(EMERGENCY_ROLE, TMC), + ), + ), + ( + "7. Grant Buybacks.BuybackExecutor.EMERGENCY_ROLE to the Emergency Committee on the BuybackExecutor", + ( + buyback_executor.address, + buyback_executor.grantRole.encode_input(EMERGENCY_ROLE, EMERGENCY_COMMITTEE), + ), + ), + ( + "8. Grant Buybacks.MANAGER_ROLE to the Treasury Management Committee on the BuybackAllocator", + ( + buyback_allocator.address, + buyback_allocator.grantRole.encode_input(MANAGER_ROLE, TMC), + ), + ), + ( + "9. Call activate() on the BuybackAllocator", + ( + buyback_allocator.address, + buyback_allocator.activate.encode_input(), + ), + ), + ) + + return vote_desc_items, call_script_items + + +def start_vote(tx_params: Dict[str, str], silent: bool = False): + vote_desc_items, call_script_items = get_vote_items() + vote_items = bake_vote_items(list(vote_desc_items), list(call_script_items)) + + desc_ipfs = ( + calculate_vote_ipfs_description(IPFS_DESCRIPTION) + if silent else upload_vote_ipfs_description(IPFS_DESCRIPTION) + ) + + vote_id, tx = confirm_vote_script(vote_items, silent, desc_ipfs) and list( + create_vote(vote_items, tx_params, desc_ipfs=desc_ipfs) + ) + + return vote_id, tx + + +def main(): + tx_params: Dict[str, str] = {"from": get_deployer_account().address} + if get_is_live(): + tx_params["priority_fee"] = get_priority_fee() + + vote_id, _ = start_vote(tx_params=tx_params, silent=False) + vote_id >= 0 and print(f"Vote created: {vote_id}.") + + +def start_and_execute_vote_on_fork_manual(): + if get_is_live(): + raise Exception("This script is for local testing only.") + + tx_params = {"from": get_deployer_account()} + vote_id, _ = start_vote(tx_params=tx_params, silent=True) + print(f"Vote created: {vote_id}.") + pass_and_exec_dao_vote(int(vote_id), step_by_step=True) diff --git a/tests/test_2026_07_27.py b/tests/test_2026_07_27.py new file mode 100644 index 00000000..d03940e9 --- /dev/null +++ b/tests/test_2026_07_27.py @@ -0,0 +1,413 @@ +from brownie import chain, convert, interface, web3, accounts, ZERO_ADDRESS, reverts +from brownie.network.event import EventDict +from brownie.network.transaction import TransactionReceipt +import pytest + +from utils.tx_tracing import tx_events_from_receipt +from utils.test.tx_tracing_helpers import ( + group_voting_events_from_receipt, + group_dg_events_from_receipt, + count_vote_items_by_events, + add_event_emitter, + display_voting_events, + display_dg_events +) +from utils.evm_script import encode_call_script +from utils.dual_governance import PROPOSAL_STATUS +from utils.test.event_validators.common import validate_events_chain +from utils.test.event_validators.dual_governance import validate_dual_governance_submit_event +from utils.test.event_validators.proxy import validate_proxy_upgrade_event + +from utils.voting import find_metadata_by_vote_id +from utils.ipfs import get_lido_vote_cid_from_str + + +# ============================================================================ +# ============================== Import vote ================================= +# ============================================================================ +from scripts.vote_2026_07_27 import ( + start_vote, + get_vote_items, + get_dg_items, + DG_PROPOSAL_METADATA, +) + + +# ============================================================================ +# ============================== Constants =================================== +# =========================================================================== +# --- Committees --- +TMC = "0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647" +EMERGENCY_COMMITTEE = "0x73b047fe6337183A454c5217241D780a932777bD" + +# --- Lido contracts --- +LIDO_LOCATOR = "0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb" +OP_STACK_TOKEN_RATE_PUSHER = "0xd54c1c6413caac3477ac14b2a80d5398e3c32ffe" +STONKS_STETH_TOPUP_REGISTRY = "0x1a7cFA9EFB4D5BfFDE87B0FaEb1fC65d653868C0" +OLD_TOKEN_RATE_NOTIFIER = "0x25e35855783bec3E49355a29e110f02Ed8b05ba9" +VOTING = "0x2e59A20f205bB85a89C53f1936454680651E618e" +AGENT = "0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c" +EMERGENCY_PROTECTED_TIMELOCK = "0xCE0425301C85c5Ea2A0873A2dEe44d78E02D2316" +DUAL_GOVERNANCE = "0xC1db28B3301331277e307FDCfF8DE28242A4486E" +DUAL_GOVERNANCE_ADMIN_EXECUTOR = "0x23E0B465633FF5178808F4A75186E2F2F9537021" + +# --- NEST contracts --- +ORACLE_ROUTER = "0x79ef3a538200Fe4981D67E7e886bfb36D4Cb5a31" +NEW_TOKEN_RATE_NOTIFIER = "0xbe05d12Fd10919F1881125006523452F6aFF791b" +NEW_LIDO_LOCATOR_IMPL = "0xF2Ffb952e129a63F0614Ff87126E1d4a494A2313" +STAKING_REVENUE_SOURCE = "0x6220212a33a87Ed7Cc386B67eB2c393974F28C38" +BUYBACK_EXECUTOR = "0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7" +BUYBACK_STONKS_TREASURY = "0xb368586CB980895E51e1D82102E63b3F69d3F151" +BUYBACK_ALLOCATOR = "0xAA568141c051f2D1132b110f8391F18D48E8D889" + +# --- Roles --- +MANAGER_ROLE = "0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e" # keccak256("Buybacks.MANAGER_ROLE") +ALLOCATOR_ROLE = "0x87905334ad07701d0cd9b21ea0599de1a0cab067e0ab49596d423d87159ac7f2" # keccak256("Buybacks.BuybackExecutor.ALLOCATOR_ROLE") +EMERGENCY_ROLE = "0xc748c205190870b4e890036f373e30556929f7fbf3db8644c998a652c1996dbd" # keccak256("Buybacks.BuybackExecutor.EMERGENCY_ROLE") + +# TokenRateNotifier.ObserverKind +OBSERVER_KIND_NO_ARGS = 0 +OBSERVER_KIND_WITH_ARGS = 1 + +ONE_DAY = 86400 + + +# ============================================================================ +# ============================= Test params ================================== +# ============================================================================ +EXPECTED_VOTE_ID = 204 +EXPECTED_VOTE_ITEMS_COUNT = 9 +EXPECTED_VOTE_EVENTS_COUNT = 9 +EXPECTED_DG_PROPOSAL_ID = 12 # TODO: change to 13 +EXPECTED_DG_EVENTS_FROM_AGENT = 4 +EXPECTED_DG_EVENTS_COUNT = 1 + +# TODO set once the IPFS description is uploaded. +IPFS_DESCRIPTION_HASH = "" + + +# ============================================================================ +# ============================ Event validators ============================== +# ============================================================================ +def _single_event(event: EventDict, name: str): + assert event.count(name) == 1, f"Expected exactly one {name} event, got {event.count(name)}" + return event[name][0] + + +def _assert_emitted_by(e, emitted_by: str) -> None: + assert convert.to_address(e["_emitted_by"]) == convert.to_address(emitted_by), "Wrong event emitter" + + +def validate_manager_set_event(event: EventDict, manager: str, emitted_by: str) -> None: + validate_events_chain([e.name for e in event], ["LogScriptCall", "ManagerSet"]) + e = _single_event(event, "ManagerSet") + assert convert.to_address(e["manager"]) == convert.to_address(manager), "Wrong OracleRouter manager" + _assert_emitted_by(e, emitted_by) + + +def validate_stonks_set_event(event: EventDict, new_stonks: str, emitted_by: str) -> None: + validate_events_chain([e.name for e in event], ["LogScriptCall", "StonksAndOperatingModeSet"]) + e = _single_event(event, "StonksAndOperatingModeSet") + assert convert.to_address(e["newStonks"]) == convert.to_address(new_stonks), "Wrong stonks set" + _assert_emitted_by(e, emitted_by) + + +def validate_role_grant_event(event: EventDict, role_hash: str, account: str, sender: str, emitted_by: str) -> None: + validate_events_chain([e.name for e in event], ["LogScriptCall", "RoleGranted"]) + e = _single_event(event, "RoleGranted") + assert str(e["role"]).lower().replace("0x", "") == role_hash.lower().replace("0x", ""), "Wrong role hash" + assert convert.to_address(e["account"]) == convert.to_address(account), "Wrong granted account" + assert convert.to_address(e["sender"]) == convert.to_address(sender), "Wrong role grant sender" + _assert_emitted_by(e, emitted_by) + + +def validate_activated_event(event: EventDict, emitted_by: str) -> None: + assert "LogScriptCall" in event, "No LogScriptCall event found" + e = _single_event(event, "Activated") + assert e["activationTS"] > 0, "activationTS not recorded" + _assert_emitted_by(e, emitted_by) + + +def validate_observer_added_event(event: EventDict, observer: str, emitted_by: str) -> None: + validate_events_chain([e.name for e in event], ["LogScriptCall", "ObserverAdded", "ScriptResult", "Executed"]) + e = _single_event(event, "ObserverAdded") + assert convert.to_address(e["observer"]) == convert.to_address(observer), "Wrong observer added" + _assert_emitted_by(e, emitted_by) + + +def validate_recipient_added_event(event: EventDict, recipient: str, title: str, emitted_by: str) -> None: + validate_events_chain([e.name for e in event], ["LogScriptCall", "RecipientAdded", "ScriptResult", "Executed"]) + e = _single_event(event, "RecipientAdded") + assert convert.to_address(e["_recipient"]) == convert.to_address(recipient), "Wrong recipient added" + assert e["_title"] == title, "Wrong recipient title" + _assert_emitted_by(e, emitted_by) + + +def _group_agent_dg_events_from_receipt(receipt: TransactionReceipt, timelock: str, agent: str): + """Re-group a single-agent-forward DG proposal into one group per forwarded sub-call. + + The default DG grouping yields one group. Here each sub-call is delimited by a LogScriptCall + emitted by the Agent while it runs the forwarded call script. + """ + events = tx_events_from_receipt(receipt) + assert ( + web3.to_checksum_address(events[-1]["address"]) == web3.to_checksum_address(timelock) + and events[-1]["name"] == "ProposalExecuted" + ), "Unexpected Dual Governance service event" + + groups = [] + current_group = None + for event in events[:-1]: + is_start_of_new_group = event["name"] == "LogScriptCall" and web3.to_checksum_address( + event["address"] + ) == web3.to_checksum_address(agent) + if is_start_of_new_group: + current_group = [] + groups.append(current_group) + if current_group is not None: + current_group.append(add_event_emitter(event)) + + return [EventDict(group) for group in groups] + + +@pytest.fixture(scope="module") +def dual_governance_proposal_calls(): + dg_items = get_dg_items() + + # Convert each dg_item to the expected format + proposal_calls = [] + for dg_item in dg_items: + target, data = dg_item # agent_forward returns (target, data) + proposal_calls.append({ + "target": target, + "value": 0, + "data": data + }) + + return proposal_calls + + +def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_governance_proposal_calls): + + # ======================================================================= + # ========================= Arrange variables =========================== + # ======================================================================= + voting = interface.Voting(VOTING) + agent = interface.Agent(AGENT) + timelock = interface.EmergencyProtectedTimelock(EMERGENCY_PROTECTED_TIMELOCK) + dual_governance = interface.DualGovernance(DUAL_GOVERNANCE) + + oracle_router = interface.OracleRouter(ORACLE_ROUTER) + buyback_executor = interface.BuybackExecutor(BUYBACK_EXECUTOR) + buyback_allocator = interface.BuybackAllocator(BUYBACK_ALLOCATOR) + new_token_rate_notifier = interface.TokenRateNotifierV2(NEW_TOKEN_RATE_NOTIFIER) + old_token_rate_notifier = interface.TokenRateNotifier(OLD_TOKEN_RATE_NOTIFIER) + lido_locator_proxy = interface.OssifiableProxy(LIDO_LOCATOR) + stonks_topup_registry = interface.AllowedRecipientRegistry(STONKS_STETH_TOPUP_REGISTRY) + + + # ========================================================================= + # ======================== Identify or Create vote ======================== + # ========================================================================= + if vote_ids_from_env: + vote_id = vote_ids_from_env[0] + if EXPECTED_VOTE_ID is not None: + assert vote_id == EXPECTED_VOTE_ID + elif EXPECTED_VOTE_ID is not None and voting.votesLength() > EXPECTED_VOTE_ID: + vote_id = EXPECTED_VOTE_ID + else: + vote_id, _ = start_vote({"from": ldo_holder}, silent=True) + + _, call_script_items = get_vote_items() + onchain_script = voting.getVote(vote_id)["script"] + assert str(onchain_script).lower() == encode_call_script(call_script_items).lower() + + + # ========================================================================= + # ============================= Execute Vote ============================== + # ========================================================================= + is_executed = voting.getVote(vote_id)["executed"] + if not is_executed: + # ======================================================================= + # ========================= Before voting checks ======================== + # ======================================================================= + assert oracle_router.manager() != TMC, "OracleRouter manager already set to TMC" + assert buyback_executor.stonks() == ZERO_ADDRESS, "BuybackExecutor stonks already set" + assert not buyback_executor.hasRole(ALLOCATOR_ROLE, BUYBACK_ALLOCATOR) + assert not buyback_executor.hasRole(MANAGER_ROLE, TMC) + assert not buyback_executor.hasRole(EMERGENCY_ROLE, TMC) + assert not buyback_executor.hasRole(EMERGENCY_ROLE, EMERGENCY_COMMITTEE) + assert not buyback_allocator.hasRole(MANAGER_ROLE, TMC) + assert buyback_allocator.activationTS() == 0, "BuybackAllocator already activated" + + if IPFS_DESCRIPTION_HASH: + assert get_lido_vote_cid_from_str(find_metadata_by_vote_id(vote_id)) == IPFS_DESCRIPTION_HASH + + vote_tx: TransactionReceipt = helpers.execute_vote(vote_id=vote_id, accounts=accounts, dao_voting=voting) + display_voting_events(vote_tx) + vote_events = group_voting_events_from_receipt(vote_tx) + + + # ======================================================================= + # ========================= After voting checks ========================= + # ======================================================================= + assert oracle_router.manager() == TMC, "OracleRouter manager not set to TMC" + assert buyback_executor.stonks() == BUYBACK_STONKS_TREASURY, "BuybackExecutor stonks not set" + assert buyback_executor.hasRole(ALLOCATOR_ROLE, BUYBACK_ALLOCATOR), "ALLOCATOR_ROLE not granted to allocator" + assert buyback_executor.hasRole(MANAGER_ROLE, TMC), "executor MANAGER_ROLE not granted to TMC" + assert buyback_executor.hasRole(EMERGENCY_ROLE, TMC), "executor EMERGENCY_ROLE not granted to TMC" + assert buyback_executor.hasRole(EMERGENCY_ROLE, EMERGENCY_COMMITTEE), "executor EMERGENCY_ROLE not granted to EC" + assert buyback_allocator.hasRole(MANAGER_ROLE, TMC), "allocator MANAGER_ROLE not granted to TMC" + # activate() records activationTS as midnight UTC of the execution day + assert buyback_allocator.activationTS() == vote_tx.timestamp - (vote_tx.timestamp % ONE_DAY), \ + "BuybackAllocator not activated at the vote-execution day's midnight UTC" + + # Check that stranger cannot grant roles to buyback contracts + with reverts(): + buyback_executor.grantRole(MANAGER_ROLE, stranger, {"from": stranger}) + with reverts(): + buyback_allocator.grantRole(MANAGER_ROLE, stranger, {"from": stranger}) + + # Check that buyback contracts point to the correct OracleRouter + assert buyback_executor.ORACLE_ROUTER() == ORACLE_ROUTER, "BuybackExecutor points at wrong OracleRouter" + assert buyback_allocator.ORACLE_ROUTER() == ORACLE_ROUTER, "BuybackAllocator points at wrong OracleRouter" + + + assert len(vote_events) == EXPECTED_VOTE_EVENTS_COUNT + assert count_vote_items_by_events(vote_tx, voting.address) == EXPECTED_VOTE_ITEMS_COUNT + + if EXPECTED_DG_PROPOSAL_ID is not None: + assert EXPECTED_DG_PROPOSAL_ID == timelock.getProposalsCount() + + validate_dual_governance_submit_event( + vote_events[0], + proposal_id=EXPECTED_DG_PROPOSAL_ID, + proposer=VOTING, + executor=DUAL_GOVERNANCE_ADMIN_EXECUTOR, + metadata=DG_PROPOSAL_METADATA, + proposal_calls=dual_governance_proposal_calls, + ) + + # Direct Aragon Voting items + validate_manager_set_event(vote_events[1], manager=TMC, emitted_by=ORACLE_ROUTER) + validate_stonks_set_event(vote_events[2], new_stonks=BUYBACK_STONKS_TREASURY, emitted_by=BUYBACK_EXECUTOR) + validate_role_grant_event( + vote_events[3], role_hash=ALLOCATOR_ROLE, account=BUYBACK_ALLOCATOR, sender=VOTING, emitted_by=BUYBACK_EXECUTOR + ) + validate_role_grant_event( + vote_events[4], role_hash=MANAGER_ROLE, account=TMC, sender=VOTING, emitted_by=BUYBACK_EXECUTOR + ) + validate_role_grant_event( + vote_events[5], role_hash=EMERGENCY_ROLE, account=TMC, sender=VOTING, emitted_by=BUYBACK_EXECUTOR + ) + validate_role_grant_event( + vote_events[6], role_hash=EMERGENCY_ROLE, account=EMERGENCY_COMMITTEE, sender=VOTING, emitted_by=BUYBACK_EXECUTOR + ) + validate_role_grant_event( + vote_events[7], role_hash=MANAGER_ROLE, account=TMC, sender=VOTING, emitted_by=BUYBACK_ALLOCATOR + ) + validate_activated_event(vote_events[8], emitted_by=BUYBACK_ALLOCATOR) + + + # ========================================================================= + # ======================= Execute DG Proposal ============================= + # ========================================================================= + if EXPECTED_DG_PROPOSAL_ID is not None: + details = timelock.getProposalDetails(EXPECTED_DG_PROPOSAL_ID) + if details["status"] != PROPOSAL_STATUS["executed"]: + # ========================================================================= + # ================== DG before proposal executed checks =================== + # ========================================================================= + assert lido_locator_proxy.proxy__getImplementation() != NEW_LIDO_LOCATOR_IMPL, "LidoLocator already upgraded" + assert not stonks_topup_registry.isRecipientAllowed(BUYBACK_ALLOCATOR), "Allocator already an allowed recipient" + + new_notifier_observers_before = [ + str(new_token_rate_notifier.observers(i)[0]).lower() + for i in range(new_token_rate_notifier.observersLength()) + ] + assert str(STAKING_REVENUE_SOURCE).lower() not in new_notifier_observers_before, "Revenue source already registered" + + # Check that LidoLocator still resolves to the old postTokenRebaseReceiver + assert ( + interface.LidoLocator(LIDO_LOCATOR).postTokenRebaseReceiver() != NEW_TOKEN_RATE_NOTIFIER + ), "postTokenRebaseReceiver already repointed to the new notifier" + + + if details["status"] == PROPOSAL_STATUS["submitted"]: + chain.sleep(timelock.getAfterSubmitDelay() + 1) + dual_governance.scheduleProposal(EXPECTED_DG_PROPOSAL_ID, {"from": stranger}) + + if timelock.getProposalDetails(EXPECTED_DG_PROPOSAL_ID)["status"] == PROPOSAL_STATUS["scheduled"]: + chain.sleep(timelock.getAfterScheduleDelay() + 1) + + dg_tx: TransactionReceipt = timelock.execute(EXPECTED_DG_PROPOSAL_ID, {"from": stranger}) + display_dg_events(dg_tx) + outer_dg_events = group_dg_events_from_receipt( + dg_tx, + timelock=EMERGENCY_PROTECTED_TIMELOCK, + admin_executor=DUAL_GOVERNANCE_ADMIN_EXECUTOR, + ) + # Regroup by the Agent's sub-calls to validate each sub-calls events + agent_dg_events = _group_agent_dg_events_from_receipt( + dg_tx, timelock=EMERGENCY_PROTECTED_TIMELOCK, agent=AGENT + ) + assert count_vote_items_by_events(dg_tx, agent.address) == EXPECTED_DG_EVENTS_FROM_AGENT + assert len(outer_dg_events) == EXPECTED_DG_EVENTS_COUNT + assert len(agent_dg_events) == EXPECTED_DG_EVENTS_FROM_AGENT + + # 1.1-2: observers added to the new TokenRateNotifier + validate_observer_added_event( + agent_dg_events[0], observer=OP_STACK_TOKEN_RATE_PUSHER, emitted_by=NEW_TOKEN_RATE_NOTIFIER + ) + validate_observer_added_event( + agent_dg_events[1], observer=STAKING_REVENUE_SOURCE, emitted_by=NEW_TOKEN_RATE_NOTIFIER + ) + # 1.3: LidoLocator implementation upgrade + validate_proxy_upgrade_event(agent_dg_events[2], NEW_LIDO_LOCATOR_IMPL, emitted_by=LIDO_LOCATOR) + # 1.4: BuybackAllocator added as an allowed recipient + validate_recipient_added_event( + agent_dg_events[3], + recipient=BUYBACK_ALLOCATOR, + title="Buyback Allocator", + emitted_by=STONKS_STETH_TOPUP_REGISTRY, + ) + + + # ========================================================================= + # ==================== After DG proposal executed checks ================== + # ========================================================================= + assert lido_locator_proxy.proxy__getImplementation() == NEW_LIDO_LOCATOR_IMPL, "LidoLocator impl not upgraded" + + # New TokenRateNotifier observers: OpStack rate pusher (NoArgs) + StakingRevenueSource (WithArgs). + new_observers = [ + tuple(new_token_rate_notifier.observers(i)) + for i in range(new_token_rate_notifier.observersLength()) + ] + new_observers_normalized = [(str(addr).lower(), kind) for addr, kind in new_observers] + new_observer_addrs = [addr for addr, _ in new_observers_normalized] + + assert (str(OP_STACK_TOKEN_RATE_PUSHER).lower(), OBSERVER_KIND_NO_ARGS) in new_observers_normalized, \ + "OpStack rate pusher not registered as NoArgs observer" + assert (str(STAKING_REVENUE_SOURCE).lower(), OBSERVER_KIND_WITH_ARGS) in new_observers_normalized, \ + "Revenue source not registered as WithArgs observer" + + # Dropped-observer guard: every observer on the old notifier must have been migrated to the + # new one before postTokenRebaseReceiver was repointed, or that downstream consumer goes dark. + for i in range(old_token_rate_notifier.observersLength()): + old_observer = str(old_token_rate_notifier.observers(i)).lower() + assert old_observer in new_observer_addrs, \ + f"Observer {old_observer} on the old notifier was not migrated to the new notifier" + + assert stonks_topup_registry.isRecipientAllowed(BUYBACK_ALLOCATOR), "Allocator not an allowed recipient" + + # Check that LidoLocator now resolves postTokenRebaseReceiver to the new notifier, + # so protocol rebases flow into it and reach the migrated observers. + assert ( + interface.LidoLocator(LIDO_LOCATOR).postTokenRebaseReceiver() == NEW_TOKEN_RATE_NOTIFIER + ), "postTokenRebaseReceiver not repointed to the new notifier" + + # Check that registered StakingRevenueSource observer is itself connected to the + # shared OracleRouter and LidoLocator it will read from. + staking_revenue_source = interface.StakingRevenueSource(STAKING_REVENUE_SOURCE) + assert staking_revenue_source.ORACLE_ROUTER() == ORACLE_ROUTER, "StakingRevenueSource points at wrong OracleRouter" + assert staking_revenue_source.LIDO_LOCATOR() == LIDO_LOCATOR, "StakingRevenueSource points at wrong LidoLocator" From fd13e34b265eeb5bde0bee444dad4fe926d21572 Mon Sep 17 00:00:00 2001 From: phonktown Date: Wed, 22 Jul 2026 16:04:50 +0300 Subject: [PATCH 02/24] fix(Tests): move validators to corresponding utils paths --- ...te_2026_07_27.py => upgrade_2026_08_05.py} | 2 +- tests/test_2026_07_27.py | 51 ++++++++----------- .../allowed_recipients_registry.py | 24 +++++++++ utils/test/event_validators/permission.py | 6 +-- 4 files changed, 50 insertions(+), 33 deletions(-) rename scripts/{vote_2026_07_27.py => upgrade_2026_08_05.py} (99%) diff --git a/scripts/vote_2026_07_27.py b/scripts/upgrade_2026_08_05.py similarity index 99% rename from scripts/vote_2026_07_27.py rename to scripts/upgrade_2026_08_05.py index dd25f51f..7d5966d1 100644 --- a/scripts/vote_2026_07_27.py +++ b/scripts/upgrade_2026_08_05.py @@ -1,5 +1,5 @@ """ -Vote 2026_07_27 +Vote 2026_08_05 Launch of the NEST Automated Buybacks system in treasury-only mode (LIP-36). diff --git a/tests/test_2026_07_27.py b/tests/test_2026_07_27.py index d03940e9..4972673c 100644 --- a/tests/test_2026_07_27.py +++ b/tests/test_2026_07_27.py @@ -17,6 +17,8 @@ from utils.test.event_validators.common import validate_events_chain from utils.test.event_validators.dual_governance import validate_dual_governance_submit_event from utils.test.event_validators.proxy import validate_proxy_upgrade_event +from utils.test.event_validators.permission import validate_grant_role_event +from utils.test.event_validators.allowed_recipients_registry import validate_recipient_added_event from utils.voting import find_metadata_by_vote_id from utils.ipfs import get_lido_vote_cid_from_str @@ -25,7 +27,7 @@ # ============================================================================ # ============================== Import vote ================================= # ============================================================================ -from scripts.vote_2026_07_27 import ( +from scripts.upgrade_2026_08_05 import ( start_vote, get_vote_items, get_dg_items, @@ -71,6 +73,9 @@ ONE_DAY = 86400 +# The Buybacks role grants (vote items 4-8) are executed directly by Aragon Voting, +# not forwarded through the Agent, so each grant emits a single LogScriptCall followed by RoleGranted. +DIRECT_GRANT_ROLE_EVENTS_CHAIN = ["LogScriptCall", "RoleGranted"] # ============================================================================ # ============================= Test params ================================== @@ -78,7 +83,7 @@ EXPECTED_VOTE_ID = 204 EXPECTED_VOTE_ITEMS_COUNT = 9 EXPECTED_VOTE_EVENTS_COUNT = 9 -EXPECTED_DG_PROPOSAL_ID = 12 # TODO: change to 13 +EXPECTED_DG_PROPOSAL_ID = 13 EXPECTED_DG_EVENTS_FROM_AGENT = 4 EXPECTED_DG_EVENTS_COUNT = 1 @@ -112,15 +117,6 @@ def validate_stonks_set_event(event: EventDict, new_stonks: str, emitted_by: str _assert_emitted_by(e, emitted_by) -def validate_role_grant_event(event: EventDict, role_hash: str, account: str, sender: str, emitted_by: str) -> None: - validate_events_chain([e.name for e in event], ["LogScriptCall", "RoleGranted"]) - e = _single_event(event, "RoleGranted") - assert str(e["role"]).lower().replace("0x", "") == role_hash.lower().replace("0x", ""), "Wrong role hash" - assert convert.to_address(e["account"]) == convert.to_address(account), "Wrong granted account" - assert convert.to_address(e["sender"]) == convert.to_address(sender), "Wrong role grant sender" - _assert_emitted_by(e, emitted_by) - - def validate_activated_event(event: EventDict, emitted_by: str) -> None: assert "LogScriptCall" in event, "No LogScriptCall event found" e = _single_event(event, "Activated") @@ -135,14 +131,6 @@ def validate_observer_added_event(event: EventDict, observer: str, emitted_by: s _assert_emitted_by(e, emitted_by) -def validate_recipient_added_event(event: EventDict, recipient: str, title: str, emitted_by: str) -> None: - validate_events_chain([e.name for e in event], ["LogScriptCall", "RecipientAdded", "ScriptResult", "Executed"]) - e = _single_event(event, "RecipientAdded") - assert convert.to_address(e["_recipient"]) == convert.to_address(recipient), "Wrong recipient added" - assert e["_title"] == title, "Wrong recipient title" - _assert_emitted_by(e, emitted_by) - - def _group_agent_dg_events_from_receipt(receipt: TransactionReceipt, timelock: str, agent: str): """Re-group a single-agent-forward DG proposal into one group per forwarded sub-call. @@ -291,20 +279,25 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g # Direct Aragon Voting items validate_manager_set_event(vote_events[1], manager=TMC, emitted_by=ORACLE_ROUTER) validate_stonks_set_event(vote_events[2], new_stonks=BUYBACK_STONKS_TREASURY, emitted_by=BUYBACK_EXECUTOR) - validate_role_grant_event( - vote_events[3], role_hash=ALLOCATOR_ROLE, account=BUYBACK_ALLOCATOR, sender=VOTING, emitted_by=BUYBACK_EXECUTOR + validate_grant_role_event( + vote_events[3], role=ALLOCATOR_ROLE, grant_to=BUYBACK_ALLOCATOR, sender=VOTING, + emitted_by=BUYBACK_EXECUTOR, event_chain=DIRECT_GRANT_ROLE_EVENTS_CHAIN, ) - validate_role_grant_event( - vote_events[4], role_hash=MANAGER_ROLE, account=TMC, sender=VOTING, emitted_by=BUYBACK_EXECUTOR + validate_grant_role_event( + vote_events[4], role=MANAGER_ROLE, grant_to=TMC, sender=VOTING, + emitted_by=BUYBACK_EXECUTOR, event_chain=DIRECT_GRANT_ROLE_EVENTS_CHAIN, ) - validate_role_grant_event( - vote_events[5], role_hash=EMERGENCY_ROLE, account=TMC, sender=VOTING, emitted_by=BUYBACK_EXECUTOR + validate_grant_role_event( + vote_events[5], role=EMERGENCY_ROLE, grant_to=TMC, sender=VOTING, + emitted_by=BUYBACK_EXECUTOR, event_chain=DIRECT_GRANT_ROLE_EVENTS_CHAIN, ) - validate_role_grant_event( - vote_events[6], role_hash=EMERGENCY_ROLE, account=EMERGENCY_COMMITTEE, sender=VOTING, emitted_by=BUYBACK_EXECUTOR + validate_grant_role_event( + vote_events[6], role=EMERGENCY_ROLE, grant_to=EMERGENCY_COMMITTEE, sender=VOTING, + emitted_by=BUYBACK_EXECUTOR, event_chain=DIRECT_GRANT_ROLE_EVENTS_CHAIN, ) - validate_role_grant_event( - vote_events[7], role_hash=MANAGER_ROLE, account=TMC, sender=VOTING, emitted_by=BUYBACK_ALLOCATOR + validate_grant_role_event( + vote_events[7], role=MANAGER_ROLE, grant_to=TMC, sender=VOTING, + emitted_by=BUYBACK_ALLOCATOR, event_chain=DIRECT_GRANT_ROLE_EVENTS_CHAIN, ) validate_activated_event(vote_events[8], emitted_by=BUYBACK_ALLOCATOR) diff --git a/utils/test/event_validators/allowed_recipients_registry.py b/utils/test/event_validators/allowed_recipients_registry.py index bd740281..b16f8508 100644 --- a/utils/test/event_validators/allowed_recipients_registry.py +++ b/utils/test/event_validators/allowed_recipients_registry.py @@ -1,3 +1,4 @@ +from typing import List from brownie.network.event import EventDict from .common import validate_events_chain from brownie import convert @@ -74,3 +75,26 @@ def validate_set_spent_amount_event( assert event_emitted_by == convert.to_address( emitted_by ), f"Wrong event emitter {event_emitted_by} but expected {emitted_by}" + + +def validate_recipient_added_event( + event: EventDict, + recipient: str, + title: str, + emitted_by: str | None = None, + event_chain: List[str] | None = None, +): + _events_chain = event_chain or ["LogScriptCall", "RecipientAdded", "ScriptResult", "Executed"] + + validate_events_chain([e.name for e in event], _events_chain) + + assert event.count("RecipientAdded") == 1 + assert convert.to_address(event["RecipientAdded"]["_recipient"]) == convert.to_address( + recipient + ), "Wrong recipient added" + assert event["RecipientAdded"]["_title"] == title, "Wrong recipient title" + + event_emitted_by = convert.to_address(event["RecipientAdded"]["_emitted_by"]) + assert event_emitted_by == convert.to_address( + emitted_by + ), f"Wrong event emitter {event_emitted_by} but expected {emitted_by}" diff --git a/utils/test/event_validators/permission.py b/utils/test/event_validators/permission.py index e664f513..c5427fa0 100644 --- a/utils/test/event_validators/permission.py +++ b/utils/test/event_validators/permission.py @@ -115,10 +115,10 @@ def validate_permission_grantp_event(event: EventDict, p: Permission, params: Li ), "Wrong event emitter" -def validate_grant_role_event(events: EventDict, role: str, grant_to: str, sender: str, emitted_by: str = None) -> None: - # this event chain is actual if grant role is forwarded through +def validate_grant_role_event(events: EventDict, role: str, grant_to: str, sender: str, emitted_by: str = None, event_chain: List[str] = None) -> None: + # defaults to the Agent-forwarded chain; pass event_chain for a direct grant, e.g. ["LogScriptCall", "RoleGranted"] - _events_chain = ["LogScriptCall", "LogScriptCall", "RoleGranted", "ScriptResult", "Executed"] + _events_chain = event_chain or ["LogScriptCall", "LogScriptCall", "RoleGranted", "ScriptResult", "Executed"] validate_events_chain([e.name for e in events], _events_chain) From a9f719ce9b5acc2b4cf7021d42865717cc281092 Mon Sep 17 00:00:00 2001 From: phonktown Date: Wed, 22 Jul 2026 17:27:27 +0300 Subject: [PATCH 03/24] feat(Tests): tag acceptance checks with vote item numbers and order them by item --- ...{test_2026_07_27.py => test_2026_08_05.py} | 73 ++++++++++++------- 1 file changed, 48 insertions(+), 25 deletions(-) rename tests/{test_2026_07_27.py => test_2026_08_05.py} (91%) diff --git a/tests/test_2026_07_27.py b/tests/test_2026_08_05.py similarity index 91% rename from tests/test_2026_07_27.py rename to tests/test_2026_08_05.py index 4972673c..fd4b3ba3 100644 --- a/tests/test_2026_07_27.py +++ b/tests/test_2026_08_05.py @@ -219,13 +219,21 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g # ======================================================================= # ========================= Before voting checks ======================== # ======================================================================= + # vote item 2 assert oracle_router.manager() != TMC, "OracleRouter manager already set to TMC" + # vote item 3 assert buyback_executor.stonks() == ZERO_ADDRESS, "BuybackExecutor stonks already set" + # vote item 4 assert not buyback_executor.hasRole(ALLOCATOR_ROLE, BUYBACK_ALLOCATOR) + # vote item 5 assert not buyback_executor.hasRole(MANAGER_ROLE, TMC) + # vote item 6 assert not buyback_executor.hasRole(EMERGENCY_ROLE, TMC) + # vote item 7 assert not buyback_executor.hasRole(EMERGENCY_ROLE, EMERGENCY_COMMITTEE) + # vote item 8 assert not buyback_allocator.hasRole(MANAGER_ROLE, TMC) + # vote item 9 assert buyback_allocator.activationTS() == 0, "BuybackAllocator already activated" if IPFS_DESCRIPTION_HASH: @@ -239,24 +247,31 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g # ======================================================================= # ========================= After voting checks ========================= # ======================================================================= + # vote item 2 assert oracle_router.manager() == TMC, "OracleRouter manager not set to TMC" + # vote item 3 assert buyback_executor.stonks() == BUYBACK_STONKS_TREASURY, "BuybackExecutor stonks not set" + # vote item 4 assert buyback_executor.hasRole(ALLOCATOR_ROLE, BUYBACK_ALLOCATOR), "ALLOCATOR_ROLE not granted to allocator" + # vote item 5 assert buyback_executor.hasRole(MANAGER_ROLE, TMC), "executor MANAGER_ROLE not granted to TMC" + # vote item 6 assert buyback_executor.hasRole(EMERGENCY_ROLE, TMC), "executor EMERGENCY_ROLE not granted to TMC" + # vote item 7 assert buyback_executor.hasRole(EMERGENCY_ROLE, EMERGENCY_COMMITTEE), "executor EMERGENCY_ROLE not granted to EC" + # vote item 8 assert buyback_allocator.hasRole(MANAGER_ROLE, TMC), "allocator MANAGER_ROLE not granted to TMC" - # activate() records activationTS as midnight UTC of the execution day + # vote item 9: activate() records activationTS as midnight UTC of the execution day assert buyback_allocator.activationTS() == vote_tx.timestamp - (vote_tx.timestamp % ONE_DAY), \ "BuybackAllocator not activated at the vote-execution day's midnight UTC" - # Check that stranger cannot grant roles to buyback contracts + # vote items 4-8: a non-admin (stranger) cannot grant the buyback roles with reverts(): buyback_executor.grantRole(MANAGER_ROLE, stranger, {"from": stranger}) with reverts(): buyback_allocator.grantRole(MANAGER_ROLE, stranger, {"from": stranger}) - # Check that buyback contracts point to the correct OracleRouter + # buyback contracts point to the correct OracleRouter assert buyback_executor.ORACLE_ROUTER() == ORACLE_ROUTER, "BuybackExecutor points at wrong OracleRouter" assert buyback_allocator.ORACLE_ROUTER() == ORACLE_ROUTER, "BuybackAllocator points at wrong OracleRouter" @@ -276,29 +291,36 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g proposal_calls=dual_governance_proposal_calls, ) - # Direct Aragon Voting items + # vote item 2 validate_manager_set_event(vote_events[1], manager=TMC, emitted_by=ORACLE_ROUTER) + # vote item 3 validate_stonks_set_event(vote_events[2], new_stonks=BUYBACK_STONKS_TREASURY, emitted_by=BUYBACK_EXECUTOR) + # vote item 4 validate_grant_role_event( vote_events[3], role=ALLOCATOR_ROLE, grant_to=BUYBACK_ALLOCATOR, sender=VOTING, emitted_by=BUYBACK_EXECUTOR, event_chain=DIRECT_GRANT_ROLE_EVENTS_CHAIN, ) + # vote item 5 validate_grant_role_event( vote_events[4], role=MANAGER_ROLE, grant_to=TMC, sender=VOTING, emitted_by=BUYBACK_EXECUTOR, event_chain=DIRECT_GRANT_ROLE_EVENTS_CHAIN, ) + # vote item 6 validate_grant_role_event( vote_events[5], role=EMERGENCY_ROLE, grant_to=TMC, sender=VOTING, emitted_by=BUYBACK_EXECUTOR, event_chain=DIRECT_GRANT_ROLE_EVENTS_CHAIN, ) + # vote item 7 validate_grant_role_event( vote_events[6], role=EMERGENCY_ROLE, grant_to=EMERGENCY_COMMITTEE, sender=VOTING, emitted_by=BUYBACK_EXECUTOR, event_chain=DIRECT_GRANT_ROLE_EVENTS_CHAIN, ) + # vote item 8 validate_grant_role_event( vote_events[7], role=MANAGER_ROLE, grant_to=TMC, sender=VOTING, emitted_by=BUYBACK_ALLOCATOR, event_chain=DIRECT_GRANT_ROLE_EVENTS_CHAIN, ) + # vote item 9 validate_activated_event(vote_events[8], emitted_by=BUYBACK_ALLOCATOR) @@ -311,20 +333,22 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g # ========================================================================= # ================== DG before proposal executed checks =================== # ========================================================================= - assert lido_locator_proxy.proxy__getImplementation() != NEW_LIDO_LOCATOR_IMPL, "LidoLocator already upgraded" - assert not stonks_topup_registry.isRecipientAllowed(BUYBACK_ALLOCATOR), "Allocator already an allowed recipient" - + # DG item 1.2 new_notifier_observers_before = [ str(new_token_rate_notifier.observers(i)[0]).lower() for i in range(new_token_rate_notifier.observersLength()) ] assert str(STAKING_REVENUE_SOURCE).lower() not in new_notifier_observers_before, "Revenue source already registered" - # Check that LidoLocator still resolves to the old postTokenRebaseReceiver + # DG item 1.3: LidoLocator still uses the old implementation and still resolves to the old postTokenRebaseReceiver + assert lido_locator_proxy.proxy__getImplementation() != NEW_LIDO_LOCATOR_IMPL, "LidoLocator already upgraded" assert ( interface.LidoLocator(LIDO_LOCATOR).postTokenRebaseReceiver() != NEW_TOKEN_RATE_NOTIFIER ), "postTokenRebaseReceiver already repointed to the new notifier" + # DG item 1.4 + assert not stonks_topup_registry.isRecipientAllowed(BUYBACK_ALLOCATOR), "Allocator already an allowed recipient" + if details["status"] == PROPOSAL_STATUS["submitted"]: chain.sleep(timelock.getAfterSubmitDelay() + 1) @@ -348,16 +372,17 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g assert len(outer_dg_events) == EXPECTED_DG_EVENTS_COUNT assert len(agent_dg_events) == EXPECTED_DG_EVENTS_FROM_AGENT - # 1.1-2: observers added to the new TokenRateNotifier + # DG item 1.1: OpStack rate pusher observer added to the new TokenRateNotifier validate_observer_added_event( agent_dg_events[0], observer=OP_STACK_TOKEN_RATE_PUSHER, emitted_by=NEW_TOKEN_RATE_NOTIFIER ) + # DG item 1.2: StakingRevenueSource observer added to the new TokenRateNotifier validate_observer_added_event( agent_dg_events[1], observer=STAKING_REVENUE_SOURCE, emitted_by=NEW_TOKEN_RATE_NOTIFIER ) - # 1.3: LidoLocator implementation upgrade + # DG item 1.3: LidoLocator implementation upgrade validate_proxy_upgrade_event(agent_dg_events[2], NEW_LIDO_LOCATOR_IMPL, emitted_by=LIDO_LOCATOR) - # 1.4: BuybackAllocator added as an allowed recipient + # DG item 1.4: BuybackAllocator added as an allowed recipient validate_recipient_added_event( agent_dg_events[3], recipient=BUYBACK_ALLOCATOR, @@ -369,9 +394,7 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g # ========================================================================= # ==================== After DG proposal executed checks ================== # ========================================================================= - assert lido_locator_proxy.proxy__getImplementation() == NEW_LIDO_LOCATOR_IMPL, "LidoLocator impl not upgraded" - - # New TokenRateNotifier observers: OpStack rate pusher (NoArgs) + StakingRevenueSource (WithArgs). + # DG items 1.1-1.2: new TokenRateNotifier observers — OpStack pusher (NoArgs) + StakingRevenueSource (WithArgs) new_observers = [ tuple(new_token_rate_notifier.observers(i)) for i in range(new_token_rate_notifier.observersLength()) @@ -379,28 +402,28 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g new_observers_normalized = [(str(addr).lower(), kind) for addr, kind in new_observers] new_observer_addrs = [addr for addr, _ in new_observers_normalized] + # DG item 1.1 assert (str(OP_STACK_TOKEN_RATE_PUSHER).lower(), OBSERVER_KIND_NO_ARGS) in new_observers_normalized, \ "OpStack rate pusher not registered as NoArgs observer" + # DG item 1.2 assert (str(STAKING_REVENUE_SOURCE).lower(), OBSERVER_KIND_WITH_ARGS) in new_observers_normalized, \ "Revenue source not registered as WithArgs observer" + # DG item 1.2: the registered StakingRevenueSource observer is wired to the right OracleRouter and LidoLocator + staking_revenue_source = interface.StakingRevenueSource(STAKING_REVENUE_SOURCE) + assert staking_revenue_source.ORACLE_ROUTER() == ORACLE_ROUTER, "StakingRevenueSource points at wrong OracleRouter" + assert staking_revenue_source.LIDO_LOCATOR() == LIDO_LOCATOR, "StakingRevenueSource points at wrong LidoLocator" - # Dropped-observer guard: every observer on the old notifier must have been migrated to the - # new one before postTokenRebaseReceiver was repointed, or that downstream consumer goes dark. + # DG items 1.1-1.3: every old-notifier observer must be migrated to the new one before the repoint for i in range(old_token_rate_notifier.observersLength()): old_observer = str(old_token_rate_notifier.observers(i)).lower() assert old_observer in new_observer_addrs, \ f"Observer {old_observer} on the old notifier was not migrated to the new notifier" - assert stonks_topup_registry.isRecipientAllowed(BUYBACK_ALLOCATOR), "Allocator not an allowed recipient" - - # Check that LidoLocator now resolves postTokenRebaseReceiver to the new notifier, - # so protocol rebases flow into it and reach the migrated observers. + # DG item 1.3: LidoLocator upgraded and postTokenRebaseReceiver repointed to the new notifier + assert lido_locator_proxy.proxy__getImplementation() == NEW_LIDO_LOCATOR_IMPL, "LidoLocator impl not upgraded" assert ( interface.LidoLocator(LIDO_LOCATOR).postTokenRebaseReceiver() == NEW_TOKEN_RATE_NOTIFIER ), "postTokenRebaseReceiver not repointed to the new notifier" - # Check that registered StakingRevenueSource observer is itself connected to the - # shared OracleRouter and LidoLocator it will read from. - staking_revenue_source = interface.StakingRevenueSource(STAKING_REVENUE_SOURCE) - assert staking_revenue_source.ORACLE_ROUTER() == ORACLE_ROUTER, "StakingRevenueSource points at wrong OracleRouter" - assert staking_revenue_source.LIDO_LOCATOR() == LIDO_LOCATOR, "StakingRevenueSource points at wrong LidoLocator" + # DG item 1.4 + assert stonks_topup_registry.isRecipientAllowed(BUYBACK_ALLOCATOR), "Allocator not an allowed recipient" From c27febd9b7e2db47273b6a9b4cb976316f8ca8c5 Mon Sep 17 00:00:00 2001 From: phonktown Date: Thu, 23 Jul 2026 11:58:40 +0300 Subject: [PATCH 04/24] fix(Tests): remove unused import --- tests/test_2026_08_05.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_2026_08_05.py b/tests/test_2026_08_05.py index fd4b3ba3..1057b993 100644 --- a/tests/test_2026_08_05.py +++ b/tests/test_2026_08_05.py @@ -1,4 +1,4 @@ -from brownie import chain, convert, interface, web3, accounts, ZERO_ADDRESS, reverts +from brownie import chain, convert, interface, web3, ZERO_ADDRESS, reverts from brownie.network.event import EventDict from brownie.network.transaction import TransactionReceipt import pytest From bd8974a19ba97121761e1714f76d21ea6fec9c09 Mon Sep 17 00:00:00 2001 From: phonktown Date: Fri, 24 Jul 2026 12:21:43 +0300 Subject: [PATCH 05/24] feat(Nest): add happy path regression test --- .../test_nest_buyback_happy_path.py | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/regression/test_nest_buyback_happy_path.py diff --git a/tests/regression/test_nest_buyback_happy_path.py b/tests/regression/test_nest_buyback_happy_path.py new file mode 100644 index 00000000..7cebe50e --- /dev/null +++ b/tests/regression/test_nest_buyback_happy_path.py @@ -0,0 +1,129 @@ +from brownie import convert, interface, ZERO_ADDRESS + +from utils.config import contracts +from utils.test.helpers import ETH +from utils.test.deposits_helpers import WEI_TOLERANCE +from utils.test.oracle_report_helpers import oracle_report + +from scripts.upgrade_2026_08_05 import ( + TMC, + ORACLE_ROUTER, + LIDO_LOCATOR, + NEW_TOKEN_RATE_NOTIFIER, + STAKING_REVENUE_SOURCE, + BUYBACK_EXECUTOR, + BUYBACK_STONKS_TREASURY, + BUYBACK_ALLOCATOR, +) + +QUOTE_DENOMINATION_ETH = 1 +ONE_DAY = 86400 + +# Launch staleness (finance) is 1h ETH/USD bridge, 24h token feeds. Not usable on the fork: fast-forwarded +# DG timelock delays plus the oracle_report frame push the clock past the Chainlink feeds' frozen updatedAt, +# so 1h/24h trip OracleStale. We widen the bound to cover that gap for the fork run — quote (ETH), active +# flag and underlying feeds are identical to launch. +FORK_FEED_STALENESS_SECONDS = 30 * ONE_DAY + +TOTAL_BASIS_POINTS = 10_000 +SECONDS_PER_YEAR = 365 * ONE_DAY +NORMAL_CL_APR_BP = 300 # 3%, well under the oracle's ~10% annual CL-increase sanity limit + + +def _configure_oracle_router_feeds(oracle_router, tmc): + """Set the OracleRouter feeds as TMC — the operational step the vote leaves to TMC post-launch.""" + steth = contracts.lido.address + ldo = contracts.ldo_token.address + if oracle_router.ethUsdBridge()["aggregator"] == ZERO_ADDRESS: + oracle_router.setEthUsdBridge(FORK_FEED_STALENESS_SECONDS, {"from": tmc}) + if not oracle_router.tokenConfig(steth)["isActive"]: + oracle_router.setTokenFeed(steth, QUOTE_DENOMINATION_ETH, FORK_FEED_STALENESS_SECONDS, True, {"from": tmc}) + if not oracle_router.tokenConfig(ldo)["isActive"]: + oracle_router.setTokenFeed(ldo, QUOTE_DENOMINATION_ETH, FORK_FEED_STALENESS_SECONDS, True, {"from": tmc}) + + +def _production_rebase_cl_diff(): + """CL rewards a normal report carries: current CL balance at ~3% APR over the report's period.""" + consensus = contracts.hash_consensus_for_accounting_oracle + slots_per_epoch, seconds_per_slot, _ = consensus.getChainConfig() + _, epochs_per_frame, _ = consensus.getFrameConfig() + current_ref_slot, _ = consensus.getCurrentFrame() + last_processing_ref_slot = contracts.accounting_oracle.getLastProcessingRefSlot() + + next_ref_slot = current_ref_slot + epochs_per_frame * slots_per_epoch + elapsed_seconds = (next_ref_slot - last_processing_ref_slot) * seconds_per_slot + + cl_validators_balance, cl_pending_balance, *_ = contracts.lido.getBalanceStats() + cl_balance = cl_validators_balance + cl_pending_balance + return cl_balance * NORMAL_CL_APR_BP * elapsed_seconds // (TOTAL_BASIS_POINTS * SECONDS_PER_YEAR) + + +def test_nest_buyback_happy_path(accounts, stranger): + """Drive the launched NEST buyback end to end: rebase -> revenue -> allocate -> CoW order. + + The vote and its DG proposal are applied by the autouse fixtures in tests/regression/conftest.py. + """ + steth = contracts.lido + tmc = accounts.at(TMC, force=True) + + oracle_router = interface.OracleRouter(ORACLE_ROUTER) + buyback_executor = interface.BuybackExecutor(BUYBACK_EXECUTOR) + buyback_allocator = interface.BuybackAllocator(BUYBACK_ALLOCATOR) + staking_revenue_source = interface.StakingRevenueSource(STAKING_REVENUE_SOURCE) + + # Preconditions: the launch is in place + assert interface.LidoLocator(LIDO_LOCATOR).postTokenRebaseReceiver() == NEW_TOKEN_RATE_NOTIFIER + assert buyback_allocator.activationTS() > 0, "BuybackAllocator not activated" + assert buyback_executor.stonks() == BUYBACK_STONKS_TREASURY, "BuybackExecutor stonks not set" + assert buyback_allocator.STETH() == steth.address, "Allocator stETH handle mismatch" + + _configure_oracle_router_feeds(oracle_router, tmc) + assert oracle_router.tokenConfig(steth.address)["isActive"], "stETH feed not configured on OracleRouter" + + min_order_steth = buyback_executor.minAllowedOrderAmount() + + # Rebase -> revenue accrues in the StakingRevenueSource, then convert it to USD + cumulative_revenue_before = staking_revenue_source.getCumulativeRevenueUSD() + oracle_report(cl_diff=_production_rebase_cl_diff(), silent=True) + assert staking_revenue_source.pendingRevenueStEth() > 0, "no fee shares reached the StakingRevenueSource" + + staking_revenue_source.convertPendingRevenueToUSD({"from": stranger}) + assert staking_revenue_source.getCumulativeRevenueUSD() > cumulative_revenue_before, "cumulative revenue did not grow" + assert staking_revenue_source.pendingRevenueStEth() == 0, "pending revenue not fully converted" + + # Fund the allocator with fresh stETH + steth_to_fund = ETH(1000) + steth.submit(ZERO_ADDRESS, {"from": stranger, "value": steth_to_fund}) + steth.transfer(BUYBACK_ALLOCATOR, steth_to_fund, {"from": stranger}) + assert steth.balanceOf(BUYBACK_ALLOCATOR) >= steth_to_fund - WEI_TOLERANCE, "allocator not funded with stETH" + + spendable_steth = buyback_allocator.spendable()["spendableStEth"] + assert spendable_steth > 0, "allocator reports nothing spendable" + + # allocate() spends and forwards stETH to Stonks + stonks_steth_before = steth.balanceOf(BUYBACK_STONKS_TREASURY) + allocate_tx = buyback_allocator.allocate({"from": stranger}) + assert "AllocationSkipped" not in allocate_tx.events, "allocate() skipped instead of spending" + allocated = allocate_tx.events["Allocated"] + assert allocated["spendStEth"] > 0 and allocated["spendUSD"] > 0, "allocate() spent nothing" + assert convert.to_address(allocated["executor"]) == convert.to_address(BUYBACK_EXECUTOR), "wrong executor" + processed = allocate_tx.events["AllocationProcessed"] + assert convert.to_address(processed["stonks"]) == convert.to_address(BUYBACK_STONKS_TREASURY), "wrong stonks" + assert processed["forwardedToStonks"] > 0, "nothing forwarded to Stonks" + assert steth.balanceOf(BUYBACK_STONKS_TREASURY) > stonks_steth_before, "Stonks stETH balance did not grow" + + # placeOrder() creates the Stonks/CoW order (stop before off-chain settlement) + placement = buyback_executor.getPlacementStatus() + assert placement["canPlace"], "executor cannot place an order" + assert not placement["isStonksCreationPaused"] and not placement["isStonksKilled"], "Stonks creation blocked" + assert placement["sellAmount"] >= min_order_steth, "sell amount below the minimum order amount" + + place_order_tx = buyback_executor.placeOrder({"from": stranger}) + order = place_order_tx.return_value + assert order is not None, "placeOrder returned no order" + assert order != ZERO_ADDRESS, "placeOrder returned the zero address" + order_placed = place_order_tx.events["OrderPlaced"] + assert convert.to_address(order_placed["order"]) == convert.to_address(order), "OrderPlaced order mismatch" + assert order_placed["sellAmount"] > 0 and order_placed["minBuyAmount"] > 0, "order has zero sell/buy amount" + assert buyback_executor.lastOrderAddress() == order, "lastOrderAddress not updated" + assert buyback_executor.lastOrderValidTo() > 0, "lastOrderValidTo not set" From f1f3c2e8f7ccd36c95294a178d48373ceeb48b44 Mon Sep 17 00:00:00 2001 From: Sergey K Date: Fri, 24 Jul 2026 12:15:56 +0500 Subject: [PATCH 06/24] feat: replace staking module share limits factory --- configs/config_mainnet.py | 2 +- scripts/upgrade_2026_08_05.py | 26 +++++++++++++ tests/test_2026_08_05.py | 69 ++++++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/configs/config_mainnet.py b/configs/config_mainnet.py index 0f0fd560..d1faff71 100644 --- a/configs/config_mainnet.py +++ b/configs/config_mainnet.py @@ -88,7 +88,7 @@ EASYTRACK_CM_REPORT_WITHDRAWALS_FOR_SLASHED_VALIDATORS_FACTORY = "0x71862Abd99819597670007bb992A7a7562fE50f2" EASYTRACK_CM_SETTLE_GENERAL_DELAYED_PENALTY_FACTORY = "0xfffEFC16231eDC6Dc9C93e364ff4D4E3f787f416" EASYTRACK_CM_CREATE_OR_UPDATE_OPERATOR_GROUP_FACTORY = "0x2fC78638b77381e9D040163Bd6EB1cac967bDBdF" -EASYTRACK_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY = "0x0C6703F1d8D9DdfB6c6e5F57b4f7432a6500D6D8" +EASYTRACK_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY = "0xde3e46E3129fA4e4e3f66c9024B0A3Ad509b27a1" EASYTRACK_ALLOW_CONSOLIDATION_PAIR_FACTORY = "0x29e23B1EF0c9fffAc8330F9abaCebDDD827E4b5C" # Liquidity Observation Lab (LOL) stETH AllowedRecipientsRegistry diff --git a/scripts/upgrade_2026_08_05.py b/scripts/upgrade_2026_08_05.py index 7d5966d1..796451cd 100644 --- a/scripts/upgrade_2026_08_05.py +++ b/scripts/upgrade_2026_08_05.py @@ -18,6 +18,8 @@ 7. Grant Buybacks.BuybackExecutor.EMERGENCY_ROLE 0xc748c205190870b4e890036f373e30556929f7fbf3db8644c998a652c1996dbd to Emergency Committee 0x73b047fe6337183A454c5217241D780a932777bD on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7 8. Grant Buybacks.MANAGER_ROLE 0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e to Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 on BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 9. Call activate() on BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 +10. Remove UpdateStakingModuleShareLimits EVM script factory 0x0C6703F1d8D9DdfB6c6e5F57b4f7432a6500D6D8 +11. Add replacement UpdateStakingModuleShareLimits EVM script factory 0xde3e46E3129fA4e4e3f66c9024B0A3Ad509b27a1 TODO (after vote) Vote #{vote number} passed & executed on {date+time}, block {blockNumber}. """ @@ -32,6 +34,7 @@ from utils.dual_governance import submit_proposals from utils.agent import agent_forward +from utils.easy_track import add_evmscript_factory, create_permissions, remove_evmscript_factory # ============================== Addresses =================================== @@ -42,6 +45,7 @@ LIDO_LOCATOR = "0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb" OP_STACK_TOKEN_RATE_PUSHER = "0xd54c1c6413caac3477ac14b2a80d5398e3c32ffe" STONKS_STETH_TOPUP_REGISTRY = "0x1a7cFA9EFB4D5BfFDE87B0FaEb1fC65d653868C0" +STAKING_ROUTER = "0xFdDf38947aFB03C621C71b06C9C70bce73f12999" # NEST contracts NEW_TOKEN_RATE_NOTIFIER = "0xbe05d12Fd10919F1881125006523452F6aFF791b" @@ -51,6 +55,10 @@ BUYBACK_STONKS_TREASURY = "0xb368586CB980895E51e1D82102E63b3F69d3F151" BUYBACK_ALLOCATOR = "0xAA568141c051f2D1132b110f8391F18D48E8D889" +# Easy Track factories +OLD_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY = "0x0C6703F1d8D9DdfB6c6e5F57b4f7432a6500D6D8" +NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY = "0xde3e46E3129fA4e4e3f66c9024B0A3Ad509b27a1" + # ============================== Constants =================================== MANAGER_ROLE = "0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e" # keccak256("Buybacks.MANAGER_ROLE") @@ -76,6 +84,8 @@ Committee as the OracleRouter manager, configure the treasury-mode Stonks on the BuybackExecutor, grant the manager, allocator, and emergency roles on the BuybackExecutor and BuybackAllocator, and activate the BuybackAllocator. Items 2-9. +3. **Replace the incorrectly configured UpdateStakingModuleShareLimits Easy Track factory** with +the corrected deployment. Items 10-11. """ @@ -115,6 +125,10 @@ def get_vote_items() -> Tuple[List[str], List[Tuple[str, str]]]: oracle_router = interface.OracleRouter(ORACLE_ROUTER) buyback_executor = interface.BuybackExecutor(BUYBACK_EXECUTOR) buyback_allocator = interface.BuybackAllocator(BUYBACK_ALLOCATOR) + new_update_staking_module_share_limits_factory = interface.UpdateStakingModuleShareLimits( + NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY + ) + staking_router = interface.StakingRouter(STAKING_ROUTER) dg_items = get_dg_items() dg_call_script = submit_proposals([(dg_items, DG_PROPOSAL_METADATA)]) @@ -180,6 +194,18 @@ def get_vote_items() -> Tuple[List[str], List[Tuple[str, str]]]: buyback_allocator.activate.encode_input(), ), ), + ( + "10. Remove the UpdateStakingModuleShareLimits EVM script factory from Easy Track", + remove_evmscript_factory(OLD_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY), + ), + ( + "11. Add the replacement UpdateStakingModuleShareLimits EVM script factory to Easy Track", + add_evmscript_factory( + NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY, + create_permissions(new_update_staking_module_share_limits_factory, "validateParams") + + create_permissions(staking_router, "updateModuleShares")[2:], + ), + ), ) return vote_desc_items, call_script_items diff --git a/tests/test_2026_08_05.py b/tests/test_2026_08_05.py index 1057b993..e7220fde 100644 --- a/tests/test_2026_08_05.py +++ b/tests/test_2026_08_05.py @@ -19,6 +19,12 @@ from utils.test.event_validators.proxy import validate_proxy_upgrade_event from utils.test.event_validators.permission import validate_grant_role_event from utils.test.event_validators.allowed_recipients_registry import validate_recipient_added_event +from utils.test.event_validators.easy_track import ( + EVMScriptFactoryAdded, + validate_evmscript_factory_added_event, + validate_evmscript_factory_removed_event, +) +from utils.easy_track import create_permissions from utils.voting import find_metadata_by_vote_id from utils.ipfs import get_lido_vote_cid_from_str @@ -46,6 +52,8 @@ LIDO_LOCATOR = "0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb" OP_STACK_TOKEN_RATE_PUSHER = "0xd54c1c6413caac3477ac14b2a80d5398e3c32ffe" STONKS_STETH_TOPUP_REGISTRY = "0x1a7cFA9EFB4D5BfFDE87B0FaEb1fC65d653868C0" +EASY_TRACK = "0xF0211b7660680B49De1A7E9f25C65660F0a13Fea" +STAKING_ROUTER = "0xFdDf38947aFB03C621C71b06C9C70bce73f12999" OLD_TOKEN_RATE_NOTIFIER = "0x25e35855783bec3E49355a29e110f02Ed8b05ba9" VOTING = "0x2e59A20f205bB85a89C53f1936454680651E618e" AGENT = "0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c" @@ -62,6 +70,12 @@ BUYBACK_STONKS_TREASURY = "0xb368586CB980895E51e1D82102E63b3F69d3F151" BUYBACK_ALLOCATOR = "0xAA568141c051f2D1132b110f8391F18D48E8D889" +# --- Easy Track factories --- +OLD_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY = "0x0C6703F1d8D9DdfB6c6e5F57b4f7432a6500D6D8" +NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY = "0xde3e46E3129fA4e4e3f66c9024B0A3Ad509b27a1" +CSM_TRUSTED_CALLER = "0xC52fC3081123073078698F1EAc2f1Dc7Bd71880f" +CSM_STAKING_MODULE_ID = 3 + # --- Roles --- MANAGER_ROLE = "0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e" # keccak256("Buybacks.MANAGER_ROLE") ALLOCATOR_ROLE = "0x87905334ad07701d0cd9b21ea0599de1a0cab067e0ab49596d423d87159ac7f2" # keccak256("Buybacks.BuybackExecutor.ALLOCATOR_ROLE") @@ -81,8 +95,8 @@ # ============================= Test params ================================== # ============================================================================ EXPECTED_VOTE_ID = 204 -EXPECTED_VOTE_ITEMS_COUNT = 9 -EXPECTED_VOTE_EVENTS_COUNT = 9 +EXPECTED_VOTE_ITEMS_COUNT = 11 +EXPECTED_VOTE_EVENTS_COUNT = 11 EXPECTED_DG_PROPOSAL_ID = 13 EXPECTED_DG_EVENTS_FROM_AGENT = 4 EXPECTED_DG_EVENTS_COUNT = 1 @@ -192,6 +206,18 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g old_token_rate_notifier = interface.TokenRateNotifier(OLD_TOKEN_RATE_NOTIFIER) lido_locator_proxy = interface.OssifiableProxy(LIDO_LOCATOR) stonks_topup_registry = interface.AllowedRecipientRegistry(STONKS_STETH_TOPUP_REGISTRY) + easy_track = interface.EasyTrack(EASY_TRACK) + staking_router = interface.StakingRouter(STAKING_ROUTER) + old_update_staking_module_share_limits_factory = interface.UpdateStakingModuleShareLimits( + OLD_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY + ) + new_update_staking_module_share_limits_factory = interface.UpdateStakingModuleShareLimits( + NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY + ) + new_factory_permissions = ( + create_permissions(new_update_staking_module_share_limits_factory, "validateParams") + + create_permissions(staking_router, "updateModuleShares")[2:] + ) # ========================================================================= @@ -235,6 +261,22 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g assert not buyback_allocator.hasRole(MANAGER_ROLE, TMC) # vote item 9 assert buyback_allocator.activationTS() == 0, "BuybackAllocator already activated" + # vote items 10-11 + initial_factories = easy_track.getEVMScriptFactories() + assert OLD_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY in initial_factories + assert NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY not in initial_factories + assert old_update_staking_module_share_limits_factory.maxStakeShareLimitIncrease() == 500 + assert old_update_staking_module_share_limits_factory.maxStakeShareLimitDecrease() == 500 + assert old_update_staking_module_share_limits_factory.maxPriorityExitShareThresholdIncrease() == 600 + assert old_update_staking_module_share_limits_factory.maxPriorityExitShareThresholdDecrease() == 600 + assert new_update_staking_module_share_limits_factory.name() == "CSM" + assert new_update_staking_module_share_limits_factory.trustedCaller() == CSM_TRUSTED_CALLER + assert new_update_staking_module_share_limits_factory.stakingRouter() == STAKING_ROUTER + assert new_update_staking_module_share_limits_factory.stakingModuleId() == CSM_STAKING_MODULE_ID + assert new_update_staking_module_share_limits_factory.maxStakeShareLimitIncrease() == 50 + assert new_update_staking_module_share_limits_factory.maxStakeShareLimitDecrease() == 50 + assert new_update_staking_module_share_limits_factory.maxPriorityExitShareThresholdIncrease() == 60 + assert new_update_staking_module_share_limits_factory.maxPriorityExitShareThresholdDecrease() == 60 if IPFS_DESCRIPTION_HASH: assert get_lido_vote_cid_from_str(find_metadata_by_vote_id(vote_id)) == IPFS_DESCRIPTION_HASH @@ -264,6 +306,14 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g # vote item 9: activate() records activationTS as midnight UTC of the execution day assert buyback_allocator.activationTS() == vote_tx.timestamp - (vote_tx.timestamp % ONE_DAY), \ "BuybackAllocator not activated at the vote-execution day's midnight UTC" + # vote items 10-11 + updated_factories = easy_track.getEVMScriptFactories() + assert OLD_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY not in updated_factories + assert bytes(easy_track.evmScriptFactoryPermissions(OLD_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY)) == b"" + assert NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY in updated_factories + assert bytes(easy_track.evmScriptFactoryPermissions(NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY)) == bytes.fromhex( + new_factory_permissions.removeprefix("0x") + ) # vote items 4-8: a non-admin (stranger) cannot grant the buyback roles with reverts(): @@ -322,6 +372,21 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g ) # vote item 9 validate_activated_event(vote_events[8], emitted_by=BUYBACK_ALLOCATOR) + # vote item 10 + validate_evmscript_factory_removed_event( + vote_events[9], + factory_addr=OLD_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY, + emitted_by=easy_track, + ) + # vote item 11 + validate_evmscript_factory_added_event( + event=vote_events[10], + p=EVMScriptFactoryAdded( + factory_addr=NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY, + permissions=new_factory_permissions, + ), + emitted_by=easy_track, + ) # ========================================================================= From a8ef3d667a028db70091a2d4371a949d54a848aa Mon Sep 17 00:00:00 2001 From: phonktown Date: Mon, 27 Jul 2026 14:19:43 +0300 Subject: [PATCH 07/24] feat(Nest): adjust happy-path test --- .../test_nest_buyback_happy_path.py | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/regression/test_nest_buyback_happy_path.py b/tests/regression/test_nest_buyback_happy_path.py index 7cebe50e..faccb76b 100644 --- a/tests/regression/test_nest_buyback_happy_path.py +++ b/tests/regression/test_nest_buyback_happy_path.py @@ -5,16 +5,14 @@ from utils.test.deposits_helpers import WEI_TOLERANCE from utils.test.oracle_report_helpers import oracle_report -from scripts.upgrade_2026_08_05 import ( - TMC, - ORACLE_ROUTER, - LIDO_LOCATOR, - NEW_TOKEN_RATE_NOTIFIER, - STAKING_REVENUE_SOURCE, - BUYBACK_EXECUTOR, - BUYBACK_STONKS_TREASURY, - BUYBACK_ALLOCATOR, -) +TMC = "0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647" # Treasury Management Committee (OracleRouter manager) +ORACLE_ROUTER = "0x79ef3a538200Fe4981D67E7e886bfb36D4Cb5a31" +LIDO_LOCATOR = "0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb" +NEW_TOKEN_RATE_NOTIFIER = "0xbe05d12Fd10919F1881125006523452F6aFF791b" +STAKING_REVENUE_SOURCE = "0x6220212a33a87Ed7Cc386B67eB2c393974F28C38" +BUYBACK_EXECUTOR = "0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7" +BUYBACK_STONKS_TREASURY = "0xb368586CB980895E51e1D82102E63b3F69d3F151" +BUYBACK_ALLOCATOR = "0xAA568141c051f2D1132b110f8391F18D48E8D889" QUOTE_DENOMINATION_ETH = 1 ONE_DAY = 86400 @@ -70,6 +68,8 @@ def test_nest_buyback_happy_path(accounts, stranger): buyback_executor = interface.BuybackExecutor(BUYBACK_EXECUTOR) buyback_allocator = interface.BuybackAllocator(BUYBACK_ALLOCATOR) staking_revenue_source = interface.StakingRevenueSource(STAKING_REVENUE_SOURCE) + notifier = interface.TokenRateNotifierV2(NEW_TOKEN_RATE_NOTIFIER) + observer_addresses = [notifier.observers(index)["observer"] for index in range(notifier.observersLength())] # Preconditions: the launch is in place assert interface.LidoLocator(LIDO_LOCATOR).postTokenRebaseReceiver() == NEW_TOKEN_RATE_NOTIFIER @@ -77,11 +77,14 @@ def test_nest_buyback_happy_path(accounts, stranger): assert buyback_executor.stonks() == BUYBACK_STONKS_TREASURY, "BuybackExecutor stonks not set" assert buyback_allocator.STETH() == steth.address, "Allocator stETH handle mismatch" + # Wiring the launch established: revenue source registered on the notifier and the allocator granted its role + assert STAKING_REVENUE_SOURCE in observer_addresses, "StakingRevenueSource not registered as a TokenRateNotifier observer" + assert STAKING_REVENUE_SOURCE in buyback_allocator.revenueSources(), "StakingRevenueSource not a revenue source on the allocator" + assert buyback_executor.hasRole(buyback_executor.ALLOCATOR_ROLE(), BUYBACK_ALLOCATOR), "allocator lacks ALLOCATOR_ROLE on the executor" + _configure_oracle_router_feeds(oracle_router, tmc) assert oracle_router.tokenConfig(steth.address)["isActive"], "stETH feed not configured on OracleRouter" - min_order_steth = buyback_executor.minAllowedOrderAmount() - # Rebase -> revenue accrues in the StakingRevenueSource, then convert it to USD cumulative_revenue_before = staking_revenue_source.getCumulativeRevenueUSD() oracle_report(cl_diff=_production_rebase_cl_diff(), silent=True) @@ -93,12 +96,13 @@ def test_nest_buyback_happy_path(accounts, stranger): # Fund the allocator with fresh stETH steth_to_fund = ETH(1000) - steth.submit(ZERO_ADDRESS, {"from": stranger, "value": steth_to_fund}) + # submit rounds minted shares down; mint a few extra wei so the exact transfer below can't revert + steth.submit(ZERO_ADDRESS, {"from": stranger, "value": steth_to_fund + WEI_TOLERANCE}) steth.transfer(BUYBACK_ALLOCATOR, steth_to_fund, {"from": stranger}) assert steth.balanceOf(BUYBACK_ALLOCATOR) >= steth_to_fund - WEI_TOLERANCE, "allocator not funded with stETH" - spendable_steth = buyback_allocator.spendable()["spendableStEth"] - assert spendable_steth > 0, "allocator reports nothing spendable" + spendable_status, _spendable_usd, spendable_steth = buyback_allocator.spendable() + assert spendable_steth > 0, f"allocator reports nothing spendable (status={spendable_status})" # allocate() spends and forwards stETH to Stonks stonks_steth_before = steth.balanceOf(BUYBACK_STONKS_TREASURY) @@ -113,6 +117,7 @@ def test_nest_buyback_happy_path(accounts, stranger): assert steth.balanceOf(BUYBACK_STONKS_TREASURY) > stonks_steth_before, "Stonks stETH balance did not grow" # placeOrder() creates the Stonks/CoW order (stop before off-chain settlement) + min_order_steth = buyback_executor.minAllowedOrderAmount() placement = buyback_executor.getPlacementStatus() assert placement["canPlace"], "executor cannot place an order" assert not placement["isStonksCreationPaused"] and not placement["isStonksKilled"], "Stonks creation blocked" @@ -126,4 +131,6 @@ def test_nest_buyback_happy_path(accounts, stranger): assert convert.to_address(order_placed["order"]) == convert.to_address(order), "OrderPlaced order mismatch" assert order_placed["sellAmount"] > 0 and order_placed["minBuyAmount"] > 0, "order has zero sell/buy amount" assert buyback_executor.lastOrderAddress() == order, "lastOrderAddress not updated" - assert buyback_executor.lastOrderValidTo() > 0, "lastOrderValidTo not set" + expected_valid_to = place_order_tx.timestamp + buyback_executor.stonksOrderDurationSeconds() + assert buyback_executor.lastOrderValidTo() == expected_valid_to, "lastOrderValidTo not placement time + order duration" + assert steth.balanceOf(order) >= order_placed["sellAmount"] - WEI_TOLERANCE, "order does not hold the sold stETH" From e97a2d8efde846589395a0c54a8624e57e7b746d Mon Sep 17 00:00:00 2001 From: phonktown Date: Tue, 28 Jul 2026 17:28:58 +0300 Subject: [PATCH 08/24] feat(Vote): add lol stables factories + tests --- scripts/upgrade_2026_08_05.py | 54 ++++ tests/regression/test_easy_track_factories.py | 23 +- .../regression/test_lol_stables_easy_track.py | 274 ++++++++++++++++++ tests/test_2026_08_05.py | 151 +++++++++- utils/test/easy_track_helpers.py | 16 +- 5 files changed, 498 insertions(+), 20 deletions(-) create mode 100644 tests/regression/test_lol_stables_easy_track.py diff --git a/scripts/upgrade_2026_08_05.py b/scripts/upgrade_2026_08_05.py index 796451cd..7f187bbc 100644 --- a/scripts/upgrade_2026_08_05.py +++ b/scripts/upgrade_2026_08_05.py @@ -8,6 +8,8 @@ 1.2. Add StakingRevenueSource 0x6220212a33a87Ed7Cc386B67eB2c393974F28C38 as WithArgs (kind = 1) to the new TokenRateNotifier 0xbe05d12Fd10919F1881125006523452F6aFF791b 1.3. Upgrade LidoLocator proxy 0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb implementation to 0xF2Ffb952e129a63F0614Ff87126E1d4a494A2313 1.4. Add BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 as allowed recipient "Buyback Allocator" on the Stonks stETH top-up AllowedRecipientsRegistry 0x1a7cFA9EFB4D5BfFDE87B0FaEb1fC65d653868C0 +1.5. Grant ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE 0xec20c52871c824e5437859e75ac830e83aaaaeb7b0ffd850de830ddd3e385276 to EVMScriptExecutor 0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977 on the LOL stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 +1.6. Grant REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE 0x491d7752c25cfca0f73715cde1130022a9b815373f91a996bbb1ba8943efc99b to EVMScriptExecutor 0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977 on the LOL stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 === NON-DG ITEMS === 2. Set Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 as manager on OracleRouter 0x79ef3a538200Fe4981D67E7e886bfb36D4Cb5a31 @@ -20,6 +22,9 @@ 9. Call activate() on BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 10. Remove UpdateStakingModuleShareLimits EVM script factory 0x0C6703F1d8D9DdfB6c6e5F57b4f7432a6500D6D8 11. Add replacement UpdateStakingModuleShareLimits EVM script factory 0xde3e46E3129fA4e4e3f66c9024B0A3Ad509b27a1 +12. Add LOL stablecoins TopUpAllowedRecipients EVM script factory 0xc72d4C3e86b681D7c9EE306D41193C64D709C303 (AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89) +13. Add LOL stablecoins AddAllowedRecipient EVM script factory 0xe24230619e9218C1eed3de3489a22f6BC3ce18FF +14. Add LOL stablecoins RemoveAllowedRecipient EVM script factory 0xF4d5D97C85eD18f77F99B57f55E9E11d52992632 TODO (after vote) Vote #{vote number} passed & executed on {date+time}, block {blockNumber}. """ @@ -34,7 +39,9 @@ from utils.dual_governance import submit_proposals from utils.agent import agent_forward +from utils.allowed_recipients_registry import create_top_up_allowed_recipient_permission from utils.easy_track import add_evmscript_factory, create_permissions, remove_evmscript_factory +from utils.permissions import encode_oz_grant_role # ============================== Addresses =================================== @@ -46,6 +53,7 @@ OP_STACK_TOKEN_RATE_PUSHER = "0xd54c1c6413caac3477ac14b2a80d5398e3c32ffe" STONKS_STETH_TOPUP_REGISTRY = "0x1a7cFA9EFB4D5BfFDE87B0FaEb1fC65d653868C0" STAKING_ROUTER = "0xFdDf38947aFB03C621C71b06C9C70bce73f12999" +EVM_SCRIPT_EXECUTOR = "0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977" # NEST contracts NEW_TOKEN_RATE_NOTIFIER = "0xbe05d12Fd10919F1881125006523452F6aFF791b" @@ -59,6 +67,12 @@ OLD_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY = "0x0C6703F1d8D9DdfB6c6e5F57b4f7432a6500D6D8" NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY = "0xde3e46E3129fA4e4e3f66c9024B0A3Ad509b27a1" +# LOL (Liquidity Observation Lab) stablecoins Easy Track setup +LOL_STABLES_REGISTRY = "0x8d8b35cA51e7808098afF4918C21Ce428c943F89" +LOL_STABLES_TOP_UP_FACTORY = "0xc72d4C3e86b681D7c9EE306D41193C64D709C303" +LOL_STABLES_ADD_RECIPIENT_FACTORY = "0xe24230619e9218C1eed3de3489a22f6BC3ce18FF" +LOL_STABLES_REMOVE_RECIPIENT_FACTORY = "0xF4d5D97C85eD18f77F99B57f55E9E11d52992632" + # ============================== Constants =================================== MANAGER_ROLE = "0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e" # keccak256("Buybacks.MANAGER_ROLE") @@ -69,9 +83,11 @@ OBSERVER_KIND_NO_ARGS = 0 OBSERVER_KIND_WITH_ARGS = 1 +# TODO does not cover DG items 1.5-1.6 (LOL stablecoins registry role grants) yet — awaiting the final wording DG_PROPOSAL_METADATA = "Register the StakingRevenueSource on the TokenRateNotifier to enable NEST" # ipfs description +# TODO - add lol factories to description IPFS_DESCRIPTION = """ # Launch NEST Automated Buybacks in treasury-only mode @@ -94,6 +110,7 @@ def get_dg_items() -> List[Tuple[str, str]]: new_token_rate_notifier = interface.TokenRateNotifierV2(NEW_TOKEN_RATE_NOTIFIER) lido_locator_proxy = interface.OssifiableProxy(LIDO_LOCATOR) stonks_topup_registry = interface.AllowedRecipientRegistry(STONKS_STETH_TOPUP_REGISTRY) + lol_stables_registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) return [ agent_forward([ @@ -117,6 +134,18 @@ def get_dg_items() -> List[Tuple[str, str]]: stonks_topup_registry.address, stonks_topup_registry.addRecipient.encode_input(BUYBACK_ALLOCATOR, "Buyback Allocator"), ), + # 1.5. Grant ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE to the EVMScriptExecutor on the LOL stablecoins registry + encode_oz_grant_role( + contract=lol_stables_registry, + role_name="ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE", + grant_to=EVM_SCRIPT_EXECUTOR, + ), + # 1.6. Grant REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE to the EVMScriptExecutor on the same registry + encode_oz_grant_role( + contract=lol_stables_registry, + role_name="REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE", + grant_to=EVM_SCRIPT_EXECUTOR, + ), ]), ] @@ -129,6 +158,7 @@ def get_vote_items() -> Tuple[List[str], List[Tuple[str, str]]]: NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY ) staking_router = interface.StakingRouter(STAKING_ROUTER) + lol_stables_registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) dg_items = get_dg_items() dg_call_script = submit_proposals([(dg_items, DG_PROPOSAL_METADATA)]) @@ -206,6 +236,30 @@ def get_vote_items() -> Tuple[List[str], List[Tuple[str, str]]]: + create_permissions(staking_router, "updateModuleShares")[2:], ), ), + ( + "12. Add an Easy Track EVM script factory 0xc72d4C3e86b681D7c9EE306D41193C64D709C303 for funding the " + "Liquidity Observation Lab multisig with stablecoins (AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89)", + add_evmscript_factory( + factory=LOL_STABLES_TOP_UP_FACTORY, + permissions=create_top_up_allowed_recipient_permission(registry_address=LOL_STABLES_REGISTRY), + ), + ), + ( + "13. Add an Easy Track EVM script factory 0xe24230619e9218C1eed3de3489a22f6BC3ce18FF for adding a recipient " + "to the Liquidity Observation Lab stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89", + add_evmscript_factory( + factory=LOL_STABLES_ADD_RECIPIENT_FACTORY, + permissions=create_permissions(lol_stables_registry, "addRecipient"), + ), + ), + ( + "14. Add an Easy Track EVM script factory 0xF4d5D97C85eD18f77F99B57f55E9E11d52992632 for removing a recipient " + "from the Liquidity Observation Lab stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89", + add_evmscript_factory( + factory=LOL_STABLES_REMOVE_RECIPIENT_FACTORY, + permissions=create_permissions(lol_stables_registry, "removeRecipient"), + ), + ), ) return vote_desc_items, call_script_items diff --git a/tests/regression/test_easy_track_factories.py b/tests/regression/test_easy_track_factories.py index b9555e17..368c83b6 100644 --- a/tests/regression/test_easy_track_factories.py +++ b/tests/regression/test_easy_track_factories.py @@ -11,7 +11,11 @@ from configs.config_mainnet import * from utils.balance import set_balance from utils.config import contracts, EASYTRACK_SIMPLE_DVT_TRUSTED_CALLER -from utils.test.easy_track_helpers import _encode_calldata, create_and_enact_motion +from utils.test.easy_track_helpers import ( + _encode_calldata, + assert_create_evm_script_reverts, + create_and_enact_motion, +) from utils.test.csm_helpers import csm_add_ics_node_operator, csm_add_node_operator from utils.test.helpers import ETH from utils.test.keys_helpers import random_pubkeys_batch, random_signatures_batch @@ -1232,15 +1236,6 @@ def _link_consolidation_pair(stranger, targets_count=1): return source_id, reward_address, sorted(target_ids) -def _assert_create_evm_script_reverts(factory, creator, calldata, reason): - try: - factory.createEVMScript(creator, calldata) - except VirtualMachineError as error: - assert reason in error.message, f"expected {reason}, got: {error.message}" - return - raise AssertionError(f"Expected {reason} revert") - - def test_allow_consolidation_pair_factory(): factory = interface.AllowConsolidationPair(EASYTRACK_ALLOW_CONSOLIDATION_PAIR_FACTORY) migrator = interface.ConsolidationMigrator(CONSOLIDATION_MIGRATOR) @@ -1268,7 +1263,7 @@ def test_allow_consolidation_pair_factory_input_validation(stranger): source_count = nor.getNodeOperatorsCount() # submitter must be non-zero - _assert_create_evm_script_reverts( + assert_create_evm_script_reverts( factory, reward_address, _encode_calldata(["address", "uint256", "uint256[]"], [ZERO_ADDRESS, source_id, [0]]), @@ -1276,7 +1271,7 @@ def test_allow_consolidation_pair_factory_input_validation(stranger): ) # source operator must exist in the curated module - _assert_create_evm_script_reverts( + assert_create_evm_script_reverts( factory, reward_address, _encode_calldata(["address", "uint256", "uint256[]"], [stranger.address, source_count + 1000, [0]]), @@ -1284,7 +1279,7 @@ def test_allow_consolidation_pair_factory_input_validation(stranger): ) # creator must be the source operator reward address (or its manager) - _assert_create_evm_script_reverts( + assert_create_evm_script_reverts( factory, stranger, _encode_calldata(["address", "uint256", "uint256[]"], [stranger.address, source_id, [0]]), @@ -1292,7 +1287,7 @@ def test_allow_consolidation_pair_factory_input_validation(stranger): ) # source operator is not linked to any target group in the MetaRegistry - _assert_create_evm_script_reverts( + assert_create_evm_script_reverts( factory, reward_address, _encode_calldata(["address", "uint256", "uint256[]"], [stranger.address, source_id, [0]]), diff --git a/tests/regression/test_lol_stables_easy_track.py b/tests/regression/test_lol_stables_easy_track.py new file mode 100644 index 00000000..bfcc2c61 --- /dev/null +++ b/tests/regression/test_lol_stables_easy_track.py @@ -0,0 +1,274 @@ +from brownie import chain, interface, reverts + +from utils.config import contracts, DAI_TOKEN +from utils.test.easy_track_helpers import ( + _encode_calldata, + assert_create_evm_script_reverts, + create_and_enact_payment_motion, + create_and_enact_add_recipient_motion, + create_and_enact_remove_recipient_motion, +) + +# LOL (Liquidity Observation Lab) stablecoins Easy Track setup, launched by the 2026-08-05 omnibus +LOL_STABLES_REGISTRY = "0x8d8b35cA51e7808098afF4918C21Ce428c943F89" +LOL_STABLES_TOP_UP_FACTORY = "0xc72d4C3e86b681D7c9EE306D41193C64D709C303" +LOL_STABLES_ADD_RECIPIENT_FACTORY = "0xe24230619e9218C1eed3de3489a22f6BC3ce18FF" +LOL_STABLES_REMOVE_RECIPIENT_FACTORY = "0xF4d5D97C85eD18f77F99B57f55E9E11d52992632" +LOL_TRUSTED_CALLER = "0x87D93d9B2C672bf9c9642d853a8682546a5012B5" # LOL multisig, the only allowed recipient +ALLOWED_TOKENS_REGISTRY = "0x4AC40c34f8992bb1e5E856A448792158022551ca" # shared across all stables setups +EVM_SCRIPT_EXECUTOR = "0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977" + +DAI_WARD = "0x9759A6Ac90977b93B58547b4A71c78317f391A28" # authorized DAI minter, funds the Agent on a fork +# ACL cap on a single newImmediatePayment by the EVMScriptExecutor +FINANCE_DAI_MAX_PER_CALL = 2_000_000 * 10**18 + +PAYMENT_CALLDATA_SIGNATURE = ["address", "address[]", "uint256[]"] + + +def _assert_setup_is_live(registry): + """What the omnibus set up: the factories are registered and the executor holds the registry roles.""" + factories = contracts.easy_track.getEVMScriptFactories() + for factory_address in ( + LOL_STABLES_TOP_UP_FACTORY, + LOL_STABLES_ADD_RECIPIENT_FACTORY, + LOL_STABLES_REMOVE_RECIPIENT_FACTORY, + ): + assert factory_address in factories, f"{factory_address} not registered in Easy Track" + assert registry.hasRole(registry.UPDATE_SPENT_AMOUNT_ROLE(), EVM_SCRIPT_EXECUTOR) + assert registry.hasRole(registry.ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE(), EVM_SCRIPT_EXECUTOR) + assert registry.hasRole(registry.REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE(), EVM_SCRIPT_EXECUTOR) + + +def _start_fresh_spending_period(registry): + """Move past the stored period so the next payment opens a fresh one with spent == 0. + + getPeriodState reports stored values, and every enacted motion advances the chain by the motion + duration — without this a run near a period boundary would measure a rollover as an under-spend. + """ + _, _, _, period_end = registry.getPeriodState() + chain.mine(1, max(chain.time(), period_end) + 1) + return period_end + + +def _fund_agent_with_dai(amount, accounts): + """Top the Agent up to `amount` DAI through a DAI ward.""" + if contracts.dai_token.balanceOf(contracts.agent) < amount: + interface.Dai(DAI_TOKEN).mint(contracts.agent, amount, {"from": accounts.at(DAI_WARD, force=True)}) + assert contracts.dai_token.balanceOf(contracts.agent) >= amount, "Insufficient DAI balance on Agent" + + +def test_lol_stables_motion_guards(stranger): + """The factories reject unauthorized and out-of-bounds motions at creation.""" + registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) + top_up_factory = interface.TopUpAllowedRecipients(LOL_STABLES_TOP_UP_FACTORY) + add_recipient_factory = interface.AddAllowedRecipient(LOL_STABLES_ADD_RECIPIENT_FACTORY) + remove_recipient_factory = interface.RemoveAllowedRecipient(LOL_STABLES_REMOVE_RECIPIENT_FACTORY) + _assert_setup_is_live(registry) + + # only the LOL multisig can create motions + assert_create_evm_script_reverts( + top_up_factory, + stranger, + _encode_calldata(PAYMENT_CALLDATA_SIGNATURE, [DAI_TOKEN, [LOL_TRUSTED_CALLER], [1]]), + "CALLER_IS_FORBIDDEN", + ) + assert_create_evm_script_reverts( + add_recipient_factory, + stranger, + _encode_calldata(["address", "string"], [stranger.address, "Stranger"]), + "CALLER_IS_FORBIDDEN", + ) + assert_create_evm_script_reverts( + remove_recipient_factory, + stranger, + _encode_calldata(["address"], [LOL_TRUSTED_CALLER]), + "CALLER_IS_FORBIDDEN", + ) + + # only tokens listed in the AllowedTokensRegistry + assert_create_evm_script_reverts( + top_up_factory, + LOL_TRUSTED_CALLER, + _encode_calldata(PAYMENT_CALLDATA_SIGNATURE, [contracts.lido.address, [LOL_TRUSTED_CALLER], [1]]), + "TOKEN_NOT_ALLOWED", + ) + # only recipients the registry allows + assert_create_evm_script_reverts( + top_up_factory, + LOL_TRUSTED_CALLER, + _encode_calldata(PAYMENT_CALLDATA_SIGNATURE, [DAI_TOKEN, [stranger.address], [1]]), + "RECIPIENT_NOT_ALLOWED", + ) + # never more than the period limit + limit, _ = registry.getLimitParameters() + assert_create_evm_script_reverts( + top_up_factory, + LOL_TRUSTED_CALLER, + _encode_calldata(PAYMENT_CALLDATA_SIGNATURE, [DAI_TOKEN, [LOL_TRUSTED_CALLER], [limit + 1]]), + "SUM_EXCEEDS_SPENDABLE_BALANCE", + ) + + # no duplicates in the recipients list, and nothing to remove that is not there + assert_create_evm_script_reverts( + add_recipient_factory, + LOL_TRUSTED_CALLER, + _encode_calldata(["address", "string"], [LOL_TRUSTED_CALLER, "LOL multisig once more"]), + "ALLOWED_RECIPIENT_ALREADY_ADDED", + ) + assert_create_evm_script_reverts( + remove_recipient_factory, + LOL_TRUSTED_CALLER, + _encode_calldata(["address"], [stranger.address]), + "ALLOWED_RECIPIENT_NOT_FOUND", + ) + + +def test_lol_stables_add_and_remove_recipient(accounts, stranger): + """Whitelist a recipient, pay it, drop it — all three factories in one flow.""" + registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) + multisig = accounts.at(LOL_TRUSTED_CALLER, force=True) + _assert_setup_is_live(registry) + + payment_amount = 1_000 * 10**18 + _fund_agent_with_dai(payment_amount, accounts) + recipients_before = registry.getAllowedRecipients() + _start_fresh_spending_period(registry) + + create_and_enact_add_recipient_motion( + contracts.easy_track, + multisig, + registry, + LOL_STABLES_ADD_RECIPIENT_FACTORY, + stranger, + "Stranger", + stranger, + ) + create_and_enact_payment_motion( + contracts.easy_track, + multisig, + LOL_STABLES_TOP_UP_FACTORY, + contracts.dai_token, + [stranger], + [payment_amount], + stranger, + ) + create_and_enact_remove_recipient_motion( + contracts.easy_track, + multisig, + registry, + LOL_STABLES_REMOVE_RECIPIENT_FACTORY, + stranger, + stranger, + ) + + spent_after, _, _, _ = registry.getPeriodState() + assert spent_after == payment_amount, "the payment was not counted against the period limit" + assert registry.getAllowedRecipients() == recipients_before, "the allowed recipients list was not restored" + + +def test_lol_stables_every_allowed_token_counts_against_the_limit(accounts, stranger): + """Each token the shared registry allows can be paid out, and counts normalized to 18 decimals. + + USDC and USDT hold 6 decimals, so a factory that skipped normalizeAmount would let them spend + orders of magnitude past the budget while the registry barely noticed. + """ + registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) + tokens_registry = interface.AllowedTokensRegistry(ALLOWED_TOKENS_REGISTRY) + multisig = accounts.at(LOL_TRUSTED_CALLER, force=True) + _assert_setup_is_live(registry) + + allowed_tokens = tokens_registry.getAllowedTokens() + assert len(allowed_tokens) > 0, "the shared tokens registry allows no tokens" + _start_fresh_spending_period(registry) + + expected_spent = 0 + for token_address in allowed_tokens: + token = interface.ERC20(token_address) + payment_amount = 1_000 * 10 ** token.decimals() + assert token.balanceOf(contracts.agent) >= payment_amount, f"Agent holds too little {token.symbol()}" + + create_and_enact_payment_motion( + contracts.easy_track, + multisig, + LOL_STABLES_TOP_UP_FACTORY, + token, + [multisig], + [payment_amount], + stranger, + ) + + expected_spent += tokens_registry.normalizeAmount(payment_amount, token_address) + spent, _, _, _ = registry.getPeriodState() + assert spent == expected_spent, f"{token.symbol()} was not counted normalized to 18 decimals" + + +def test_lol_stables_single_payment_capped_by_acl(accounts, stranger): + """A single newImmediatePayment cannot exceed the executor's ACL cap, even well under the limit.""" + registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) + multisig = accounts.at(LOL_TRUSTED_CALLER, force=True) + _assert_setup_is_live(registry) + + over_the_cap = FINANCE_DAI_MAX_PER_CALL + 1 + limit, _ = registry.getLimitParameters() + assert over_the_cap < limit, "the ACL cap must bite before the period limit does" + _fund_agent_with_dai(over_the_cap, accounts) + _start_fresh_spending_period(registry) + + with reverts("APP_AUTH_FAILED"): + create_and_enact_payment_motion( + contracts.easy_track, + multisig, + LOL_STABLES_TOP_UP_FACTORY, + contracts.dai_token, + [multisig], + [over_the_cap], + stranger, + ) + + +def test_lol_stables_period_limit(accounts, stranger): + """A whole period's limit can be spent — and not a wei more.""" + registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) + multisig = accounts.at(LOL_TRUSTED_CALLER, force=True) + _assert_setup_is_live(registry) + + limit, _ = registry.getLimitParameters() + _fund_agent_with_dai(limit, accounts) + period_end_before = _start_fresh_spending_period(registry) + + # the whole budget, as several payments batched into one motion, each within the ACL cap + recipients = [] + amounts = [] + to_spend = limit + while to_spend > 0: + chunk_amount = min(FINANCE_DAI_MAX_PER_CALL, to_spend) + recipients.append(multisig) + amounts.append(chunk_amount) + to_spend -= chunk_amount + + create_and_enact_payment_motion( + contracts.easy_track, + multisig, + LOL_STABLES_TOP_UP_FACTORY, + contracts.dai_token, + recipients, + amounts, + stranger, + ) + + spent_after, spendable_after, _, period_end_after = registry.getPeriodState() + assert period_end_after > period_end_before, "the spending period should have rolled over to a fresh one" + assert spent_after == limit + assert spendable_after == 0 + + # not a single wei beyond the limit + with reverts("SUM_EXCEEDS_SPENDABLE_BALANCE"): + create_and_enact_payment_motion( + contracts.easy_track, + multisig, + LOL_STABLES_TOP_UP_FACTORY, + contracts.dai_token, + [multisig], + [1], + stranger, + ) diff --git a/tests/test_2026_08_05.py b/tests/test_2026_08_05.py index e7220fde..9182f74f 100644 --- a/tests/test_2026_08_05.py +++ b/tests/test_2026_08_05.py @@ -24,6 +24,7 @@ validate_evmscript_factory_added_event, validate_evmscript_factory_removed_event, ) +from utils.allowed_recipients_registry import create_top_up_allowed_recipient_permission from utils.easy_track import create_permissions from utils.voting import find_metadata_by_vote_id @@ -60,6 +61,9 @@ EMERGENCY_PROTECTED_TIMELOCK = "0xCE0425301C85c5Ea2A0873A2dEe44d78E02D2316" DUAL_GOVERNANCE = "0xC1db28B3301331277e307FDCfF8DE28242A4486E" DUAL_GOVERNANCE_ADMIN_EXECUTOR = "0x23E0B465633FF5178808F4A75186E2F2F9537021" +EVM_SCRIPT_EXECUTOR = "0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977" +FINANCE = "0xB9E5CBB9CA5b0d659238807E84D0176930753d86" +ALLOWED_TOKENS_REGISTRY = "0x4AC40c34f8992bb1e5E856A448792158022551ca" # --- NEST contracts --- ORACLE_ROUTER = "0x79ef3a538200Fe4981D67E7e886bfb36D4Cb5a31" @@ -76,10 +80,32 @@ CSM_TRUSTED_CALLER = "0xC52fC3081123073078698F1EAc2f1Dc7Bd71880f" CSM_STAKING_MODULE_ID = 3 +# --- LOL (Liquidity Observation Lab) stablecoins Easy Track setup --- +LOL_STABLES_REGISTRY = "0x8d8b35cA51e7808098afF4918C21Ce428c943F89" +LOL_STABLES_TOP_UP_FACTORY = "0xc72d4C3e86b681D7c9EE306D41193C64D709C303" +LOL_STABLES_ADD_RECIPIENT_FACTORY = "0xe24230619e9218C1eed3de3489a22f6BC3ce18FF" +LOL_STABLES_REMOVE_RECIPIENT_FACTORY = "0xF4d5D97C85eD18f77F99B57f55E9E11d52992632" +LOL_STABLES_FACTORIES = [ + LOL_STABLES_TOP_UP_FACTORY, + LOL_STABLES_ADD_RECIPIENT_FACTORY, + LOL_STABLES_REMOVE_RECIPIENT_FACTORY, +] +LOL_TRUSTED_CALLER = "0x87D93d9B2C672bf9c9642d853a8682546a5012B5" # LOL multisig, also the only allowed recipient +LOL_STABLES_LIMIT = 8_000_000 * 10**18 +LOL_STABLES_PERIOD_DURATION_MONTHS = 6 +LOL_STABLES_PERIOD_START = 1782864000 # 2026-07-01 00:00:00 UTC +LOL_STABLES_PERIOD_END = 1798761600 # 2027-01-01 00:00:00 UTC + # --- Roles --- MANAGER_ROLE = "0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e" # keccak256("Buybacks.MANAGER_ROLE") ALLOCATOR_ROLE = "0x87905334ad07701d0cd9b21ea0599de1a0cab067e0ab49596d423d87159ac7f2" # keccak256("Buybacks.BuybackExecutor.ALLOCATOR_ROLE") EMERGENCY_ROLE = "0xc748c205190870b4e890036f373e30556929f7fbf3db8644c998a652c1996dbd" # keccak256("Buybacks.BuybackExecutor.EMERGENCY_ROLE") +# AllowedRecipientsRegistry roles — each hash is keccak256 of the constant's own name +ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE = "0xec20c52871c824e5437859e75ac830e83aaaaeb7b0ffd850de830ddd3e385276" +REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE = "0x491d7752c25cfca0f73715cde1130022a9b815373f91a996bbb1ba8943efc99b" +UPDATE_SPENT_AMOUNT_ROLE = "0xc5260260446719a726d11a6faece21d19daa48b4cbcca118345832d4cb71df99" +SET_PARAMETERS_ROLE = "0x260b83d52a26066d8e9db550fa70395df5f3f064b50ff9d8a94267d9f1fe1967" +DEFAULT_ADMIN_ROLE = "0x0000000000000000000000000000000000000000000000000000000000000000" # TokenRateNotifier.ObserverKind OBSERVER_KIND_NO_ARGS = 0 @@ -91,14 +117,18 @@ # not forwarded through the Agent, so each grant emits a single LogScriptCall followed by RoleGranted. DIRECT_GRANT_ROLE_EVENTS_CHAIN = ["LogScriptCall", "RoleGranted"] +# The LOL registry role grants (DG items 1.5-1.6) run inside the Agent-forwarded call script, and +# _group_agent_dg_events_from_receipt splits it per Agent LogScriptCall — one per group, like ObserverAdded. +AGENT_FORWARDED_GRANT_ROLE_EVENTS_CHAIN = ["LogScriptCall", "RoleGranted", "ScriptResult", "Executed"] + # ============================================================================ # ============================= Test params ================================== # ============================================================================ EXPECTED_VOTE_ID = 204 -EXPECTED_VOTE_ITEMS_COUNT = 11 -EXPECTED_VOTE_EVENTS_COUNT = 11 +EXPECTED_VOTE_ITEMS_COUNT = 14 +EXPECTED_VOTE_EVENTS_COUNT = 14 EXPECTED_DG_PROPOSAL_ID = 13 -EXPECTED_DG_EVENTS_FROM_AGENT = 4 +EXPECTED_DG_EVENTS_FROM_AGENT = 6 EXPECTED_DG_EVENTS_COUNT = 1 # TODO set once the IPFS description is uploaded. @@ -219,6 +249,14 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g + create_permissions(staking_router, "updateModuleShares")[2:] ) + lol_stables_registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) + lol_stables_top_up_factory = interface.TopUpAllowedRecipients(LOL_STABLES_TOP_UP_FACTORY) + lol_stables_add_recipient_factory = interface.AddAllowedRecipient(LOL_STABLES_ADD_RECIPIENT_FACTORY) + lol_stables_remove_recipient_factory = interface.RemoveAllowedRecipient(LOL_STABLES_REMOVE_RECIPIENT_FACTORY) + lol_stables_top_up_permissions = create_top_up_allowed_recipient_permission(registry_address=LOL_STABLES_REGISTRY) + lol_stables_add_recipient_permissions = create_permissions(lol_stables_registry, "addRecipient") + lol_stables_remove_recipient_permissions = create_permissions(lol_stables_registry, "removeRecipient") + # ========================================================================= # ======================== Identify or Create vote ======================== @@ -277,6 +315,34 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g assert new_update_staking_module_share_limits_factory.maxStakeShareLimitDecrease() == 50 assert new_update_staking_module_share_limits_factory.maxPriorityExitShareThresholdIncrease() == 60 assert new_update_staking_module_share_limits_factory.maxPriorityExitShareThresholdDecrease() == 60 + # vote items 12-14: none of the LOL stablecoins factories is registered yet + for factory_address in LOL_STABLES_FACTORIES: + assert factory_address not in initial_factories, f"{factory_address} already registered in Easy Track" + # vote items 12-14: the pre-configured registry state the factories will operate on + assert lol_stables_registry.getLimitParameters() == (LOL_STABLES_LIMIT, LOL_STABLES_PERIOD_DURATION_MONTHS) + assert lol_stables_registry.getPeriodState() == ( + 0, + LOL_STABLES_LIMIT, + LOL_STABLES_PERIOD_START, + LOL_STABLES_PERIOD_END, + ) + assert lol_stables_registry.getAllowedRecipients() == [LOL_TRUSTED_CALLER] + # vote item 12: the top up factory is wired to the LOL registry and the canonical Lido contracts + assert lol_stables_top_up_factory.trustedCaller() == LOL_TRUSTED_CALLER + assert lol_stables_top_up_factory.allowedRecipientsRegistry() == LOL_STABLES_REGISTRY + assert lol_stables_top_up_factory.allowedTokensRegistry() == ALLOWED_TOKENS_REGISTRY + assert lol_stables_top_up_factory.finance() == FINANCE + assert lol_stables_top_up_factory.easyTrack() == EASY_TRACK + # vote item 13 + assert lol_stables_add_recipient_factory.trustedCaller() == LOL_TRUSTED_CALLER + assert lol_stables_add_recipient_factory.allowedRecipientsRegistry() == LOL_STABLES_REGISTRY + # vote item 14 + assert lol_stables_remove_recipient_factory.trustedCaller() == LOL_TRUSTED_CALLER + assert lol_stables_remove_recipient_factory.allowedRecipientsRegistry() == LOL_STABLES_REGISTRY + # DG items 1.5-1.6: the EVMScriptExecutor can already update the spent amount, but not the recipients list + assert lol_stables_registry.hasRole(UPDATE_SPENT_AMOUNT_ROLE, EVM_SCRIPT_EXECUTOR) + assert not lol_stables_registry.hasRole(ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE, EVM_SCRIPT_EXECUTOR) + assert not lol_stables_registry.hasRole(REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE, EVM_SCRIPT_EXECUTOR) if IPFS_DESCRIPTION_HASH: assert get_lido_vote_cid_from_str(find_metadata_by_vote_id(vote_id)) == IPFS_DESCRIPTION_HASH @@ -314,6 +380,21 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g assert bytes(easy_track.evmScriptFactoryPermissions(NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY)) == bytes.fromhex( new_factory_permissions.removeprefix("0x") ) + # vote item 12 + assert LOL_STABLES_TOP_UP_FACTORY in updated_factories + assert bytes(easy_track.evmScriptFactoryPermissions(LOL_STABLES_TOP_UP_FACTORY)) == bytes.fromhex( + lol_stables_top_up_permissions.removeprefix("0x") + ) + # vote item 13 + assert LOL_STABLES_ADD_RECIPIENT_FACTORY in updated_factories + assert bytes(easy_track.evmScriptFactoryPermissions(LOL_STABLES_ADD_RECIPIENT_FACTORY)) == bytes.fromhex( + lol_stables_add_recipient_permissions.removeprefix("0x") + ) + # vote item 14 + assert LOL_STABLES_REMOVE_RECIPIENT_FACTORY in updated_factories + assert bytes(easy_track.evmScriptFactoryPermissions(LOL_STABLES_REMOVE_RECIPIENT_FACTORY)) == bytes.fromhex( + lol_stables_remove_recipient_permissions.removeprefix("0x") + ) # vote items 4-8: a non-admin (stranger) cannot grant the buyback roles with reverts(): @@ -387,6 +468,33 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g ), emitted_by=easy_track, ) + # vote item 12 + validate_evmscript_factory_added_event( + event=vote_events[11], + p=EVMScriptFactoryAdded( + factory_addr=LOL_STABLES_TOP_UP_FACTORY, + permissions=lol_stables_top_up_permissions, + ), + emitted_by=easy_track, + ) + # vote item 13 + validate_evmscript_factory_added_event( + event=vote_events[12], + p=EVMScriptFactoryAdded( + factory_addr=LOL_STABLES_ADD_RECIPIENT_FACTORY, + permissions=lol_stables_add_recipient_permissions, + ), + emitted_by=easy_track, + ) + # vote item 14 + validate_evmscript_factory_added_event( + event=vote_events[13], + p=EVMScriptFactoryAdded( + factory_addr=LOL_STABLES_REMOVE_RECIPIENT_FACTORY, + permissions=lol_stables_remove_recipient_permissions, + ), + emitted_by=easy_track, + ) # ========================================================================= @@ -414,6 +522,14 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g # DG item 1.4 assert not stonks_topup_registry.isRecipientAllowed(BUYBACK_ALLOCATOR), "Allocator already an allowed recipient" + # DG items 1.5-1.6 + assert not lol_stables_registry.hasRole( + ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE, EVM_SCRIPT_EXECUTOR + ), "Add recipient role already granted" + assert not lol_stables_registry.hasRole( + REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE, EVM_SCRIPT_EXECUTOR + ), "Remove recipient role already granted" + if details["status"] == PROPOSAL_STATUS["submitted"]: chain.sleep(timelock.getAfterSubmitDelay() + 1) @@ -454,6 +570,24 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g title="Buyback Allocator", emitted_by=STONKS_STETH_TOPUP_REGISTRY, ) + # DG item 1.5: ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE granted to the EVMScriptExecutor + validate_grant_role_event( + agent_dg_events[4], + role=ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE, + grant_to=EVM_SCRIPT_EXECUTOR, + sender=AGENT, + emitted_by=LOL_STABLES_REGISTRY, + event_chain=AGENT_FORWARDED_GRANT_ROLE_EVENTS_CHAIN, + ) + # DG item 1.6: REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE granted to the EVMScriptExecutor + validate_grant_role_event( + agent_dg_events[5], + role=REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE, + grant_to=EVM_SCRIPT_EXECUTOR, + sender=AGENT, + emitted_by=LOL_STABLES_REGISTRY, + event_chain=AGENT_FORWARDED_GRANT_ROLE_EVENTS_CHAIN, + ) # ========================================================================= @@ -492,3 +626,14 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g # DG item 1.4 assert stonks_topup_registry.isRecipientAllowed(BUYBACK_ALLOCATOR), "Allocator not an allowed recipient" + + # DG items 1.5-1.6: without these roles the Add/Remove factories would revert on enactment + assert lol_stables_registry.hasRole( + ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE, EVM_SCRIPT_EXECUTOR + ), "Add recipient role not granted to the EVMScriptExecutor" + assert lol_stables_registry.hasRole( + REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE, EVM_SCRIPT_EXECUTOR + ), "Remove recipient role not granted to the EVMScriptExecutor" + # the grants must not widen anything else on the registry + assert not lol_stables_registry.hasRole(SET_PARAMETERS_ROLE, EVM_SCRIPT_EXECUTOR) + assert not lol_stables_registry.hasRole(DEFAULT_ADMIN_ROLE, EVM_SCRIPT_EXECUTOR) diff --git a/utils/test/easy_track_helpers.py b/utils/test/easy_track_helpers.py index 96d154f3..fbab5416 100644 --- a/utils/test/easy_track_helpers.py +++ b/utils/test/easy_track_helpers.py @@ -1,4 +1,5 @@ from brownie import chain, accounts, interface +from brownie.exceptions import VirtualMachineError from eth_abi.abi import encode from utils.config import contracts from utils.agent import agent_forward @@ -16,6 +17,15 @@ def _encode_calldata(signature, values): return "0x" + encode(signature, values).hex() +def assert_create_evm_script_reverts(factory, creator, calldata, reason): + try: + factory.createEVMScript(creator, calldata) + except VirtualMachineError as error: + assert reason in error.message, f"expected {reason}, got: {error.message}" + return + raise AssertionError(f"Expected {reason} revert") + + def create_and_enact_motion(easy_track, trusted_caller, factory, calldata, stranger): motions_before = easy_track.getMotions() @@ -81,7 +91,7 @@ def create_and_enact_payment_motion( for i in range(len(recievers)): reciever_address = recievers[i].address recievers_total_amounts[reciever_address] = recievers_total_amounts.get(reciever_address, 0) + transfer_amounts[i] - + for i in range(len(recievers)): reciever_address = recievers[i].address assert almostEqWithDiff( @@ -303,7 +313,7 @@ def create_and_enact_remove_mev_boost_relay_motion( # If relay is not in the list, add it first, or else the motion will fail if relay_uri not in [x[0] for x in mev_boost_allowed_list.get_relays()]: check_and_add_mev_boost_relay_with_voting(mev_boost_allowed_list, TEST_RELAY, helpers, ldo_holder, dao_voting) - + # get the list of relays before the motion relays_before = mev_boost_allowed_list.get_relays() calldata = "0x" + encode(["string[]"], [[relay_uri]]).hex() @@ -348,7 +358,7 @@ def create_and_enact_edit_mev_boost_relay_motion( assert len(relays_after) == len(relays_before) assert relay in relays_after - # Last sanity check that relay is updated + # Last sanity check that relay is updated relay_from_list = mev_boost_allowed_list.get_relay_by_uri(relay[0]) assert relay_from_list[0] == relay[0] assert relay_from_list[1] == new_operator From ace2b55404539814467af61aaf45ee16c13ae122 Mon Sep 17 00:00:00 2001 From: phonktown Date: Wed, 29 Jul 2026 14:11:24 +0300 Subject: [PATCH 09/24] fix(Tests): move scenario tests to the main vote test file --- .../regression/test_lol_stables_easy_track.py | 274 ------------ .../test_nest_buyback_happy_path.py | 136 ------ tests/test_2026_08_05.py | 397 ++++++++++++++++++ 3 files changed, 397 insertions(+), 410 deletions(-) delete mode 100644 tests/regression/test_lol_stables_easy_track.py delete mode 100644 tests/regression/test_nest_buyback_happy_path.py diff --git a/tests/regression/test_lol_stables_easy_track.py b/tests/regression/test_lol_stables_easy_track.py deleted file mode 100644 index bfcc2c61..00000000 --- a/tests/regression/test_lol_stables_easy_track.py +++ /dev/null @@ -1,274 +0,0 @@ -from brownie import chain, interface, reverts - -from utils.config import contracts, DAI_TOKEN -from utils.test.easy_track_helpers import ( - _encode_calldata, - assert_create_evm_script_reverts, - create_and_enact_payment_motion, - create_and_enact_add_recipient_motion, - create_and_enact_remove_recipient_motion, -) - -# LOL (Liquidity Observation Lab) stablecoins Easy Track setup, launched by the 2026-08-05 omnibus -LOL_STABLES_REGISTRY = "0x8d8b35cA51e7808098afF4918C21Ce428c943F89" -LOL_STABLES_TOP_UP_FACTORY = "0xc72d4C3e86b681D7c9EE306D41193C64D709C303" -LOL_STABLES_ADD_RECIPIENT_FACTORY = "0xe24230619e9218C1eed3de3489a22f6BC3ce18FF" -LOL_STABLES_REMOVE_RECIPIENT_FACTORY = "0xF4d5D97C85eD18f77F99B57f55E9E11d52992632" -LOL_TRUSTED_CALLER = "0x87D93d9B2C672bf9c9642d853a8682546a5012B5" # LOL multisig, the only allowed recipient -ALLOWED_TOKENS_REGISTRY = "0x4AC40c34f8992bb1e5E856A448792158022551ca" # shared across all stables setups -EVM_SCRIPT_EXECUTOR = "0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977" - -DAI_WARD = "0x9759A6Ac90977b93B58547b4A71c78317f391A28" # authorized DAI minter, funds the Agent on a fork -# ACL cap on a single newImmediatePayment by the EVMScriptExecutor -FINANCE_DAI_MAX_PER_CALL = 2_000_000 * 10**18 - -PAYMENT_CALLDATA_SIGNATURE = ["address", "address[]", "uint256[]"] - - -def _assert_setup_is_live(registry): - """What the omnibus set up: the factories are registered and the executor holds the registry roles.""" - factories = contracts.easy_track.getEVMScriptFactories() - for factory_address in ( - LOL_STABLES_TOP_UP_FACTORY, - LOL_STABLES_ADD_RECIPIENT_FACTORY, - LOL_STABLES_REMOVE_RECIPIENT_FACTORY, - ): - assert factory_address in factories, f"{factory_address} not registered in Easy Track" - assert registry.hasRole(registry.UPDATE_SPENT_AMOUNT_ROLE(), EVM_SCRIPT_EXECUTOR) - assert registry.hasRole(registry.ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE(), EVM_SCRIPT_EXECUTOR) - assert registry.hasRole(registry.REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE(), EVM_SCRIPT_EXECUTOR) - - -def _start_fresh_spending_period(registry): - """Move past the stored period so the next payment opens a fresh one with spent == 0. - - getPeriodState reports stored values, and every enacted motion advances the chain by the motion - duration — without this a run near a period boundary would measure a rollover as an under-spend. - """ - _, _, _, period_end = registry.getPeriodState() - chain.mine(1, max(chain.time(), period_end) + 1) - return period_end - - -def _fund_agent_with_dai(amount, accounts): - """Top the Agent up to `amount` DAI through a DAI ward.""" - if contracts.dai_token.balanceOf(contracts.agent) < amount: - interface.Dai(DAI_TOKEN).mint(contracts.agent, amount, {"from": accounts.at(DAI_WARD, force=True)}) - assert contracts.dai_token.balanceOf(contracts.agent) >= amount, "Insufficient DAI balance on Agent" - - -def test_lol_stables_motion_guards(stranger): - """The factories reject unauthorized and out-of-bounds motions at creation.""" - registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) - top_up_factory = interface.TopUpAllowedRecipients(LOL_STABLES_TOP_UP_FACTORY) - add_recipient_factory = interface.AddAllowedRecipient(LOL_STABLES_ADD_RECIPIENT_FACTORY) - remove_recipient_factory = interface.RemoveAllowedRecipient(LOL_STABLES_REMOVE_RECIPIENT_FACTORY) - _assert_setup_is_live(registry) - - # only the LOL multisig can create motions - assert_create_evm_script_reverts( - top_up_factory, - stranger, - _encode_calldata(PAYMENT_CALLDATA_SIGNATURE, [DAI_TOKEN, [LOL_TRUSTED_CALLER], [1]]), - "CALLER_IS_FORBIDDEN", - ) - assert_create_evm_script_reverts( - add_recipient_factory, - stranger, - _encode_calldata(["address", "string"], [stranger.address, "Stranger"]), - "CALLER_IS_FORBIDDEN", - ) - assert_create_evm_script_reverts( - remove_recipient_factory, - stranger, - _encode_calldata(["address"], [LOL_TRUSTED_CALLER]), - "CALLER_IS_FORBIDDEN", - ) - - # only tokens listed in the AllowedTokensRegistry - assert_create_evm_script_reverts( - top_up_factory, - LOL_TRUSTED_CALLER, - _encode_calldata(PAYMENT_CALLDATA_SIGNATURE, [contracts.lido.address, [LOL_TRUSTED_CALLER], [1]]), - "TOKEN_NOT_ALLOWED", - ) - # only recipients the registry allows - assert_create_evm_script_reverts( - top_up_factory, - LOL_TRUSTED_CALLER, - _encode_calldata(PAYMENT_CALLDATA_SIGNATURE, [DAI_TOKEN, [stranger.address], [1]]), - "RECIPIENT_NOT_ALLOWED", - ) - # never more than the period limit - limit, _ = registry.getLimitParameters() - assert_create_evm_script_reverts( - top_up_factory, - LOL_TRUSTED_CALLER, - _encode_calldata(PAYMENT_CALLDATA_SIGNATURE, [DAI_TOKEN, [LOL_TRUSTED_CALLER], [limit + 1]]), - "SUM_EXCEEDS_SPENDABLE_BALANCE", - ) - - # no duplicates in the recipients list, and nothing to remove that is not there - assert_create_evm_script_reverts( - add_recipient_factory, - LOL_TRUSTED_CALLER, - _encode_calldata(["address", "string"], [LOL_TRUSTED_CALLER, "LOL multisig once more"]), - "ALLOWED_RECIPIENT_ALREADY_ADDED", - ) - assert_create_evm_script_reverts( - remove_recipient_factory, - LOL_TRUSTED_CALLER, - _encode_calldata(["address"], [stranger.address]), - "ALLOWED_RECIPIENT_NOT_FOUND", - ) - - -def test_lol_stables_add_and_remove_recipient(accounts, stranger): - """Whitelist a recipient, pay it, drop it — all three factories in one flow.""" - registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) - multisig = accounts.at(LOL_TRUSTED_CALLER, force=True) - _assert_setup_is_live(registry) - - payment_amount = 1_000 * 10**18 - _fund_agent_with_dai(payment_amount, accounts) - recipients_before = registry.getAllowedRecipients() - _start_fresh_spending_period(registry) - - create_and_enact_add_recipient_motion( - contracts.easy_track, - multisig, - registry, - LOL_STABLES_ADD_RECIPIENT_FACTORY, - stranger, - "Stranger", - stranger, - ) - create_and_enact_payment_motion( - contracts.easy_track, - multisig, - LOL_STABLES_TOP_UP_FACTORY, - contracts.dai_token, - [stranger], - [payment_amount], - stranger, - ) - create_and_enact_remove_recipient_motion( - contracts.easy_track, - multisig, - registry, - LOL_STABLES_REMOVE_RECIPIENT_FACTORY, - stranger, - stranger, - ) - - spent_after, _, _, _ = registry.getPeriodState() - assert spent_after == payment_amount, "the payment was not counted against the period limit" - assert registry.getAllowedRecipients() == recipients_before, "the allowed recipients list was not restored" - - -def test_lol_stables_every_allowed_token_counts_against_the_limit(accounts, stranger): - """Each token the shared registry allows can be paid out, and counts normalized to 18 decimals. - - USDC and USDT hold 6 decimals, so a factory that skipped normalizeAmount would let them spend - orders of magnitude past the budget while the registry barely noticed. - """ - registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) - tokens_registry = interface.AllowedTokensRegistry(ALLOWED_TOKENS_REGISTRY) - multisig = accounts.at(LOL_TRUSTED_CALLER, force=True) - _assert_setup_is_live(registry) - - allowed_tokens = tokens_registry.getAllowedTokens() - assert len(allowed_tokens) > 0, "the shared tokens registry allows no tokens" - _start_fresh_spending_period(registry) - - expected_spent = 0 - for token_address in allowed_tokens: - token = interface.ERC20(token_address) - payment_amount = 1_000 * 10 ** token.decimals() - assert token.balanceOf(contracts.agent) >= payment_amount, f"Agent holds too little {token.symbol()}" - - create_and_enact_payment_motion( - contracts.easy_track, - multisig, - LOL_STABLES_TOP_UP_FACTORY, - token, - [multisig], - [payment_amount], - stranger, - ) - - expected_spent += tokens_registry.normalizeAmount(payment_amount, token_address) - spent, _, _, _ = registry.getPeriodState() - assert spent == expected_spent, f"{token.symbol()} was not counted normalized to 18 decimals" - - -def test_lol_stables_single_payment_capped_by_acl(accounts, stranger): - """A single newImmediatePayment cannot exceed the executor's ACL cap, even well under the limit.""" - registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) - multisig = accounts.at(LOL_TRUSTED_CALLER, force=True) - _assert_setup_is_live(registry) - - over_the_cap = FINANCE_DAI_MAX_PER_CALL + 1 - limit, _ = registry.getLimitParameters() - assert over_the_cap < limit, "the ACL cap must bite before the period limit does" - _fund_agent_with_dai(over_the_cap, accounts) - _start_fresh_spending_period(registry) - - with reverts("APP_AUTH_FAILED"): - create_and_enact_payment_motion( - contracts.easy_track, - multisig, - LOL_STABLES_TOP_UP_FACTORY, - contracts.dai_token, - [multisig], - [over_the_cap], - stranger, - ) - - -def test_lol_stables_period_limit(accounts, stranger): - """A whole period's limit can be spent — and not a wei more.""" - registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) - multisig = accounts.at(LOL_TRUSTED_CALLER, force=True) - _assert_setup_is_live(registry) - - limit, _ = registry.getLimitParameters() - _fund_agent_with_dai(limit, accounts) - period_end_before = _start_fresh_spending_period(registry) - - # the whole budget, as several payments batched into one motion, each within the ACL cap - recipients = [] - amounts = [] - to_spend = limit - while to_spend > 0: - chunk_amount = min(FINANCE_DAI_MAX_PER_CALL, to_spend) - recipients.append(multisig) - amounts.append(chunk_amount) - to_spend -= chunk_amount - - create_and_enact_payment_motion( - contracts.easy_track, - multisig, - LOL_STABLES_TOP_UP_FACTORY, - contracts.dai_token, - recipients, - amounts, - stranger, - ) - - spent_after, spendable_after, _, period_end_after = registry.getPeriodState() - assert period_end_after > period_end_before, "the spending period should have rolled over to a fresh one" - assert spent_after == limit - assert spendable_after == 0 - - # not a single wei beyond the limit - with reverts("SUM_EXCEEDS_SPENDABLE_BALANCE"): - create_and_enact_payment_motion( - contracts.easy_track, - multisig, - LOL_STABLES_TOP_UP_FACTORY, - contracts.dai_token, - [multisig], - [1], - stranger, - ) diff --git a/tests/regression/test_nest_buyback_happy_path.py b/tests/regression/test_nest_buyback_happy_path.py deleted file mode 100644 index faccb76b..00000000 --- a/tests/regression/test_nest_buyback_happy_path.py +++ /dev/null @@ -1,136 +0,0 @@ -from brownie import convert, interface, ZERO_ADDRESS - -from utils.config import contracts -from utils.test.helpers import ETH -from utils.test.deposits_helpers import WEI_TOLERANCE -from utils.test.oracle_report_helpers import oracle_report - -TMC = "0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647" # Treasury Management Committee (OracleRouter manager) -ORACLE_ROUTER = "0x79ef3a538200Fe4981D67E7e886bfb36D4Cb5a31" -LIDO_LOCATOR = "0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb" -NEW_TOKEN_RATE_NOTIFIER = "0xbe05d12Fd10919F1881125006523452F6aFF791b" -STAKING_REVENUE_SOURCE = "0x6220212a33a87Ed7Cc386B67eB2c393974F28C38" -BUYBACK_EXECUTOR = "0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7" -BUYBACK_STONKS_TREASURY = "0xb368586CB980895E51e1D82102E63b3F69d3F151" -BUYBACK_ALLOCATOR = "0xAA568141c051f2D1132b110f8391F18D48E8D889" - -QUOTE_DENOMINATION_ETH = 1 -ONE_DAY = 86400 - -# Launch staleness (finance) is 1h ETH/USD bridge, 24h token feeds. Not usable on the fork: fast-forwarded -# DG timelock delays plus the oracle_report frame push the clock past the Chainlink feeds' frozen updatedAt, -# so 1h/24h trip OracleStale. We widen the bound to cover that gap for the fork run — quote (ETH), active -# flag and underlying feeds are identical to launch. -FORK_FEED_STALENESS_SECONDS = 30 * ONE_DAY - -TOTAL_BASIS_POINTS = 10_000 -SECONDS_PER_YEAR = 365 * ONE_DAY -NORMAL_CL_APR_BP = 300 # 3%, well under the oracle's ~10% annual CL-increase sanity limit - - -def _configure_oracle_router_feeds(oracle_router, tmc): - """Set the OracleRouter feeds as TMC — the operational step the vote leaves to TMC post-launch.""" - steth = contracts.lido.address - ldo = contracts.ldo_token.address - if oracle_router.ethUsdBridge()["aggregator"] == ZERO_ADDRESS: - oracle_router.setEthUsdBridge(FORK_FEED_STALENESS_SECONDS, {"from": tmc}) - if not oracle_router.tokenConfig(steth)["isActive"]: - oracle_router.setTokenFeed(steth, QUOTE_DENOMINATION_ETH, FORK_FEED_STALENESS_SECONDS, True, {"from": tmc}) - if not oracle_router.tokenConfig(ldo)["isActive"]: - oracle_router.setTokenFeed(ldo, QUOTE_DENOMINATION_ETH, FORK_FEED_STALENESS_SECONDS, True, {"from": tmc}) - - -def _production_rebase_cl_diff(): - """CL rewards a normal report carries: current CL balance at ~3% APR over the report's period.""" - consensus = contracts.hash_consensus_for_accounting_oracle - slots_per_epoch, seconds_per_slot, _ = consensus.getChainConfig() - _, epochs_per_frame, _ = consensus.getFrameConfig() - current_ref_slot, _ = consensus.getCurrentFrame() - last_processing_ref_slot = contracts.accounting_oracle.getLastProcessingRefSlot() - - next_ref_slot = current_ref_slot + epochs_per_frame * slots_per_epoch - elapsed_seconds = (next_ref_slot - last_processing_ref_slot) * seconds_per_slot - - cl_validators_balance, cl_pending_balance, *_ = contracts.lido.getBalanceStats() - cl_balance = cl_validators_balance + cl_pending_balance - return cl_balance * NORMAL_CL_APR_BP * elapsed_seconds // (TOTAL_BASIS_POINTS * SECONDS_PER_YEAR) - - -def test_nest_buyback_happy_path(accounts, stranger): - """Drive the launched NEST buyback end to end: rebase -> revenue -> allocate -> CoW order. - - The vote and its DG proposal are applied by the autouse fixtures in tests/regression/conftest.py. - """ - steth = contracts.lido - tmc = accounts.at(TMC, force=True) - - oracle_router = interface.OracleRouter(ORACLE_ROUTER) - buyback_executor = interface.BuybackExecutor(BUYBACK_EXECUTOR) - buyback_allocator = interface.BuybackAllocator(BUYBACK_ALLOCATOR) - staking_revenue_source = interface.StakingRevenueSource(STAKING_REVENUE_SOURCE) - notifier = interface.TokenRateNotifierV2(NEW_TOKEN_RATE_NOTIFIER) - observer_addresses = [notifier.observers(index)["observer"] for index in range(notifier.observersLength())] - - # Preconditions: the launch is in place - assert interface.LidoLocator(LIDO_LOCATOR).postTokenRebaseReceiver() == NEW_TOKEN_RATE_NOTIFIER - assert buyback_allocator.activationTS() > 0, "BuybackAllocator not activated" - assert buyback_executor.stonks() == BUYBACK_STONKS_TREASURY, "BuybackExecutor stonks not set" - assert buyback_allocator.STETH() == steth.address, "Allocator stETH handle mismatch" - - # Wiring the launch established: revenue source registered on the notifier and the allocator granted its role - assert STAKING_REVENUE_SOURCE in observer_addresses, "StakingRevenueSource not registered as a TokenRateNotifier observer" - assert STAKING_REVENUE_SOURCE in buyback_allocator.revenueSources(), "StakingRevenueSource not a revenue source on the allocator" - assert buyback_executor.hasRole(buyback_executor.ALLOCATOR_ROLE(), BUYBACK_ALLOCATOR), "allocator lacks ALLOCATOR_ROLE on the executor" - - _configure_oracle_router_feeds(oracle_router, tmc) - assert oracle_router.tokenConfig(steth.address)["isActive"], "stETH feed not configured on OracleRouter" - - # Rebase -> revenue accrues in the StakingRevenueSource, then convert it to USD - cumulative_revenue_before = staking_revenue_source.getCumulativeRevenueUSD() - oracle_report(cl_diff=_production_rebase_cl_diff(), silent=True) - assert staking_revenue_source.pendingRevenueStEth() > 0, "no fee shares reached the StakingRevenueSource" - - staking_revenue_source.convertPendingRevenueToUSD({"from": stranger}) - assert staking_revenue_source.getCumulativeRevenueUSD() > cumulative_revenue_before, "cumulative revenue did not grow" - assert staking_revenue_source.pendingRevenueStEth() == 0, "pending revenue not fully converted" - - # Fund the allocator with fresh stETH - steth_to_fund = ETH(1000) - # submit rounds minted shares down; mint a few extra wei so the exact transfer below can't revert - steth.submit(ZERO_ADDRESS, {"from": stranger, "value": steth_to_fund + WEI_TOLERANCE}) - steth.transfer(BUYBACK_ALLOCATOR, steth_to_fund, {"from": stranger}) - assert steth.balanceOf(BUYBACK_ALLOCATOR) >= steth_to_fund - WEI_TOLERANCE, "allocator not funded with stETH" - - spendable_status, _spendable_usd, spendable_steth = buyback_allocator.spendable() - assert spendable_steth > 0, f"allocator reports nothing spendable (status={spendable_status})" - - # allocate() spends and forwards stETH to Stonks - stonks_steth_before = steth.balanceOf(BUYBACK_STONKS_TREASURY) - allocate_tx = buyback_allocator.allocate({"from": stranger}) - assert "AllocationSkipped" not in allocate_tx.events, "allocate() skipped instead of spending" - allocated = allocate_tx.events["Allocated"] - assert allocated["spendStEth"] > 0 and allocated["spendUSD"] > 0, "allocate() spent nothing" - assert convert.to_address(allocated["executor"]) == convert.to_address(BUYBACK_EXECUTOR), "wrong executor" - processed = allocate_tx.events["AllocationProcessed"] - assert convert.to_address(processed["stonks"]) == convert.to_address(BUYBACK_STONKS_TREASURY), "wrong stonks" - assert processed["forwardedToStonks"] > 0, "nothing forwarded to Stonks" - assert steth.balanceOf(BUYBACK_STONKS_TREASURY) > stonks_steth_before, "Stonks stETH balance did not grow" - - # placeOrder() creates the Stonks/CoW order (stop before off-chain settlement) - min_order_steth = buyback_executor.minAllowedOrderAmount() - placement = buyback_executor.getPlacementStatus() - assert placement["canPlace"], "executor cannot place an order" - assert not placement["isStonksCreationPaused"] and not placement["isStonksKilled"], "Stonks creation blocked" - assert placement["sellAmount"] >= min_order_steth, "sell amount below the minimum order amount" - - place_order_tx = buyback_executor.placeOrder({"from": stranger}) - order = place_order_tx.return_value - assert order is not None, "placeOrder returned no order" - assert order != ZERO_ADDRESS, "placeOrder returned the zero address" - order_placed = place_order_tx.events["OrderPlaced"] - assert convert.to_address(order_placed["order"]) == convert.to_address(order), "OrderPlaced order mismatch" - assert order_placed["sellAmount"] > 0 and order_placed["minBuyAmount"] > 0, "order has zero sell/buy amount" - assert buyback_executor.lastOrderAddress() == order, "lastOrderAddress not updated" - expected_valid_to = place_order_tx.timestamp + buyback_executor.stonksOrderDurationSeconds() - assert buyback_executor.lastOrderValidTo() == expected_valid_to, "lastOrderValidTo not placement time + order duration" - assert steth.balanceOf(order) >= order_placed["sellAmount"] - WEI_TOLERANCE, "order does not hold the sold stETH" diff --git a/tests/test_2026_08_05.py b/tests/test_2026_08_05.py index 9182f74f..2d094439 100644 --- a/tests/test_2026_08_05.py +++ b/tests/test_2026_08_05.py @@ -27,6 +27,19 @@ from utils.allowed_recipients_registry import create_top_up_allowed_recipient_permission from utils.easy_track import create_permissions +from utils.config import contracts, DAI_TOKEN +from utils.test.helpers import ETH +from utils.test.deposits_helpers import WEI_TOLERANCE +from utils.test.governance_helpers import execute_vote_and_process_dg_proposals +from utils.test.oracle_report_helpers import oracle_report +from utils.test.easy_track_helpers import ( + _encode_calldata, + assert_create_evm_script_reverts, + create_and_enact_payment_motion, + create_and_enact_add_recipient_motion, + create_and_enact_remove_recipient_motion, +) + from utils.voting import find_metadata_by_vote_id from utils.ipfs import get_lido_vote_cid_from_str @@ -113,6 +126,24 @@ ONE_DAY = 86400 +# --- NEST buyback scenario params --- +QUOTE_DENOMINATION_ETH = 1 +TOTAL_BASIS_POINTS = 10_000 +SECONDS_PER_YEAR = 365 * ONE_DAY +NORMAL_CL_APR_BP = 300 # 3%, well under the oracle's ~10% annual CL-increase sanity limit + +# Launch staleness (finance) is 1h ETH/USD bridge, 24h token feeds. Not usable on the fork: fast-forwarded +# DG timelock delays plus the oracle_report frame push the clock past the Chainlink feeds' frozen updatedAt, +# so 1h/24h trip OracleStale. We widen the bound to cover that gap for the fork run — quote (ETH), active +# flag and underlying feeds are identical to launch. +FORK_FEED_STALENESS_SECONDS = 30 * ONE_DAY + +# --- LOL stablecoins scenario params --- +DAI_WARD = "0x9759A6Ac90977b93B58547b4A71c78317f391A28" # authorized DAI minter, funds the Agent on a fork +# ACL cap on a single newImmediatePayment by the EVMScriptExecutor +FINANCE_DAI_MAX_PER_CALL = 2_000_000 * 10**18 +PAYMENT_CALLDATA_SIGNATURE = ["address", "address[]", "uint256[]"] + # The Buybacks role grants (vote items 4-8) are executed directly by Aragon Voting, # not forwarded through the Agent, so each grant emits a single LogScriptCall followed by RoleGranted. DIRECT_GRANT_ROLE_EVENTS_CHAIN = ["LogScriptCall", "RoleGranted"] @@ -219,6 +250,16 @@ def dual_governance_proposal_calls(): return proposal_calls +@pytest.fixture(scope="module") +def vote_applied(module_isolation, helpers, vote_ids_from_env, dg_proposal_ids_from_env): + """Post-launch state for the scenario tests below: the omnibus and its DG proposal are applied. + + `test_vote` applies them itself, but function-level isolation rolls that back, so the scenarios + take a module-scoped snapshot of their own. + """ + execute_vote_and_process_dg_proposals(helpers, vote_ids_from_env, dg_proposal_ids_from_env) + + def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_governance_proposal_calls): # ======================================================================= @@ -637,3 +678,359 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g # the grants must not widen anything else on the registry assert not lol_stables_registry.hasRole(SET_PARAMETERS_ROLE, EVM_SCRIPT_EXECUTOR) assert not lol_stables_registry.hasRole(DEFAULT_ADMIN_ROLE, EVM_SCRIPT_EXECUTOR) + + +# ============================================================================ +# ======================= NEST buyback happy path ============================ +# ============================================================================ +def _configure_oracle_router_feeds(oracle_router, tmc): + """Set the OracleRouter feeds as TMC — the operational step the vote leaves to TMC post-launch.""" + steth = contracts.lido.address + ldo = contracts.ldo_token.address + if oracle_router.ethUsdBridge()["aggregator"] == ZERO_ADDRESS: + oracle_router.setEthUsdBridge(FORK_FEED_STALENESS_SECONDS, {"from": tmc}) + if not oracle_router.tokenConfig(steth)["isActive"]: + oracle_router.setTokenFeed(steth, QUOTE_DENOMINATION_ETH, FORK_FEED_STALENESS_SECONDS, True, {"from": tmc}) + if not oracle_router.tokenConfig(ldo)["isActive"]: + oracle_router.setTokenFeed(ldo, QUOTE_DENOMINATION_ETH, FORK_FEED_STALENESS_SECONDS, True, {"from": tmc}) + + +def _production_rebase_cl_diff(): + """CL rewards a normal report carries: current CL balance at ~3% APR over the report's period.""" + consensus = contracts.hash_consensus_for_accounting_oracle + slots_per_epoch, seconds_per_slot, _ = consensus.getChainConfig() + _, epochs_per_frame, _ = consensus.getFrameConfig() + current_ref_slot, _ = consensus.getCurrentFrame() + last_processing_ref_slot = contracts.accounting_oracle.getLastProcessingRefSlot() + + next_ref_slot = current_ref_slot + epochs_per_frame * slots_per_epoch + elapsed_seconds = (next_ref_slot - last_processing_ref_slot) * seconds_per_slot + + cl_validators_balance, cl_pending_balance, *_ = contracts.lido.getBalanceStats() + cl_balance = cl_validators_balance + cl_pending_balance + return cl_balance * NORMAL_CL_APR_BP * elapsed_seconds // (TOTAL_BASIS_POINTS * SECONDS_PER_YEAR) + + +def test_nest_buyback_happy_path(vote_applied, accounts, stranger): + """Drive the launched NEST buyback end to end: rebase -> revenue -> allocate -> CoW order.""" + steth = contracts.lido + tmc = accounts.at(TMC, force=True) + + oracle_router = interface.OracleRouter(ORACLE_ROUTER) + buyback_executor = interface.BuybackExecutor(BUYBACK_EXECUTOR) + buyback_allocator = interface.BuybackAllocator(BUYBACK_ALLOCATOR) + staking_revenue_source = interface.StakingRevenueSource(STAKING_REVENUE_SOURCE) + notifier = interface.TokenRateNotifierV2(NEW_TOKEN_RATE_NOTIFIER) + observer_addresses = [notifier.observers(index)["observer"] for index in range(notifier.observersLength())] + + # Preconditions: the launch is in place + assert interface.LidoLocator(LIDO_LOCATOR).postTokenRebaseReceiver() == NEW_TOKEN_RATE_NOTIFIER + assert buyback_allocator.activationTS() > 0, "BuybackAllocator not activated" + assert buyback_executor.stonks() == BUYBACK_STONKS_TREASURY, "BuybackExecutor stonks not set" + assert buyback_allocator.STETH() == steth.address, "Allocator stETH handle mismatch" + + # Wiring the launch established: revenue source registered on the notifier and the allocator granted its role + assert STAKING_REVENUE_SOURCE in observer_addresses, "StakingRevenueSource not registered as a TokenRateNotifier observer" + assert STAKING_REVENUE_SOURCE in buyback_allocator.revenueSources(), "StakingRevenueSource not a revenue source on the allocator" + assert buyback_executor.hasRole(buyback_executor.ALLOCATOR_ROLE(), BUYBACK_ALLOCATOR), "allocator lacks ALLOCATOR_ROLE on the executor" + + _configure_oracle_router_feeds(oracle_router, tmc) + assert oracle_router.tokenConfig(steth.address)["isActive"], "stETH feed not configured on OracleRouter" + + # Rebase -> revenue accrues in the StakingRevenueSource, then convert it to USD + cumulative_revenue_before = staking_revenue_source.getCumulativeRevenueUSD() + oracle_report(cl_diff=_production_rebase_cl_diff(), silent=True) + assert staking_revenue_source.pendingRevenueStEth() > 0, "no fee shares reached the StakingRevenueSource" + + staking_revenue_source.convertPendingRevenueToUSD({"from": stranger}) + assert staking_revenue_source.getCumulativeRevenueUSD() > cumulative_revenue_before, "cumulative revenue did not grow" + assert staking_revenue_source.pendingRevenueStEth() == 0, "pending revenue not fully converted" + + # Fund the allocator with fresh stETH + steth_to_fund = ETH(1000) + # submit rounds minted shares down; mint a few extra wei so the exact transfer below can't revert + steth.submit(ZERO_ADDRESS, {"from": stranger, "value": steth_to_fund + WEI_TOLERANCE}) + steth.transfer(BUYBACK_ALLOCATOR, steth_to_fund, {"from": stranger}) + assert steth.balanceOf(BUYBACK_ALLOCATOR) >= steth_to_fund - WEI_TOLERANCE, "allocator not funded with stETH" + + spendable_status, _spendable_usd, spendable_steth = buyback_allocator.spendable() + assert spendable_steth > 0, f"allocator reports nothing spendable (status={spendable_status})" + + # allocate() spends and forwards stETH to Stonks + stonks_steth_before = steth.balanceOf(BUYBACK_STONKS_TREASURY) + allocate_tx = buyback_allocator.allocate({"from": stranger}) + assert "AllocationSkipped" not in allocate_tx.events, "allocate() skipped instead of spending" + allocated = allocate_tx.events["Allocated"] + assert allocated["spendStEth"] > 0 and allocated["spendUSD"] > 0, "allocate() spent nothing" + assert convert.to_address(allocated["executor"]) == convert.to_address(BUYBACK_EXECUTOR), "wrong executor" + processed = allocate_tx.events["AllocationProcessed"] + assert convert.to_address(processed["stonks"]) == convert.to_address(BUYBACK_STONKS_TREASURY), "wrong stonks" + assert processed["forwardedToStonks"] > 0, "nothing forwarded to Stonks" + assert steth.balanceOf(BUYBACK_STONKS_TREASURY) > stonks_steth_before, "Stonks stETH balance did not grow" + + # placeOrder() creates the Stonks/CoW order (stop before off-chain settlement) + min_order_steth = buyback_executor.minAllowedOrderAmount() + placement = buyback_executor.getPlacementStatus() + assert placement["canPlace"], "executor cannot place an order" + assert not placement["isStonksCreationPaused"] and not placement["isStonksKilled"], "Stonks creation blocked" + assert placement["sellAmount"] >= min_order_steth, "sell amount below the minimum order amount" + + place_order_tx = buyback_executor.placeOrder({"from": stranger}) + order = place_order_tx.return_value + assert order is not None, "placeOrder returned no order" + assert order != ZERO_ADDRESS, "placeOrder returned the zero address" + order_placed = place_order_tx.events["OrderPlaced"] + assert convert.to_address(order_placed["order"]) == convert.to_address(order), "OrderPlaced order mismatch" + assert order_placed["sellAmount"] > 0 and order_placed["minBuyAmount"] > 0, "order has zero sell/buy amount" + assert buyback_executor.lastOrderAddress() == order, "lastOrderAddress not updated" + expected_valid_to = place_order_tx.timestamp + buyback_executor.stonksOrderDurationSeconds() + assert buyback_executor.lastOrderValidTo() == expected_valid_to, "lastOrderValidTo not placement time + order duration" + assert steth.balanceOf(order) >= order_placed["sellAmount"] - WEI_TOLERANCE, "order does not hold the sold stETH" + + +# ============================================================================ +# ==================== LOL stablecoins Easy Track motions ==================== +# ============================================================================ +def _assert_setup_is_live(registry): + """What the omnibus set up: the factories are registered and the executor holds the registry roles.""" + factories = contracts.easy_track.getEVMScriptFactories() + for factory_address in LOL_STABLES_FACTORIES: + assert factory_address in factories, f"{factory_address} not registered in Easy Track" + assert registry.hasRole(UPDATE_SPENT_AMOUNT_ROLE, EVM_SCRIPT_EXECUTOR) + assert registry.hasRole(ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE, EVM_SCRIPT_EXECUTOR) + assert registry.hasRole(REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE, EVM_SCRIPT_EXECUTOR) + + +def _start_fresh_spending_period(registry): + """Move past the stored period so the next payment opens a fresh one with spent == 0. + + getPeriodState reports stored values, and every enacted motion advances the chain by the motion + duration — without this a run near a period boundary would measure a rollover as an under-spend. + """ + _, _, _, period_end = registry.getPeriodState() + chain.mine(1, max(chain.time(), period_end) + 1) + return period_end + + +def _fund_agent_with_dai(amount, accounts): + """Top the Agent up to `amount` DAI through a DAI ward.""" + if contracts.dai_token.balanceOf(contracts.agent) < amount: + interface.Dai(DAI_TOKEN).mint(contracts.agent, amount, {"from": accounts.at(DAI_WARD, force=True)}) + assert contracts.dai_token.balanceOf(contracts.agent) >= amount, "Insufficient DAI balance on Agent" + + +def test_lol_stables_motion_guards(vote_applied, stranger): + """The factories reject unauthorized and out-of-bounds motions at creation.""" + registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) + top_up_factory = interface.TopUpAllowedRecipients(LOL_STABLES_TOP_UP_FACTORY) + add_recipient_factory = interface.AddAllowedRecipient(LOL_STABLES_ADD_RECIPIENT_FACTORY) + remove_recipient_factory = interface.RemoveAllowedRecipient(LOL_STABLES_REMOVE_RECIPIENT_FACTORY) + _assert_setup_is_live(registry) + + # only the LOL multisig can create motions + assert_create_evm_script_reverts( + top_up_factory, + stranger, + _encode_calldata(PAYMENT_CALLDATA_SIGNATURE, [DAI_TOKEN, [LOL_TRUSTED_CALLER], [1]]), + "CALLER_IS_FORBIDDEN", + ) + assert_create_evm_script_reverts( + add_recipient_factory, + stranger, + _encode_calldata(["address", "string"], [stranger.address, "Stranger"]), + "CALLER_IS_FORBIDDEN", + ) + assert_create_evm_script_reverts( + remove_recipient_factory, + stranger, + _encode_calldata(["address"], [LOL_TRUSTED_CALLER]), + "CALLER_IS_FORBIDDEN", + ) + + # only tokens listed in the AllowedTokensRegistry + assert_create_evm_script_reverts( + top_up_factory, + LOL_TRUSTED_CALLER, + _encode_calldata(PAYMENT_CALLDATA_SIGNATURE, [contracts.lido.address, [LOL_TRUSTED_CALLER], [1]]), + "TOKEN_NOT_ALLOWED", + ) + # only recipients the registry allows + assert_create_evm_script_reverts( + top_up_factory, + LOL_TRUSTED_CALLER, + _encode_calldata(PAYMENT_CALLDATA_SIGNATURE, [DAI_TOKEN, [stranger.address], [1]]), + "RECIPIENT_NOT_ALLOWED", + ) + # never more than the period limit + limit, _ = registry.getLimitParameters() + assert_create_evm_script_reverts( + top_up_factory, + LOL_TRUSTED_CALLER, + _encode_calldata(PAYMENT_CALLDATA_SIGNATURE, [DAI_TOKEN, [LOL_TRUSTED_CALLER], [limit + 1]]), + "SUM_EXCEEDS_SPENDABLE_BALANCE", + ) + + # no duplicates in the recipients list, and nothing to remove that is not there + assert_create_evm_script_reverts( + add_recipient_factory, + LOL_TRUSTED_CALLER, + _encode_calldata(["address", "string"], [LOL_TRUSTED_CALLER, "LOL multisig once more"]), + "ALLOWED_RECIPIENT_ALREADY_ADDED", + ) + assert_create_evm_script_reverts( + remove_recipient_factory, + LOL_TRUSTED_CALLER, + _encode_calldata(["address"], [stranger.address]), + "ALLOWED_RECIPIENT_NOT_FOUND", + ) + + +def test_lol_stables_add_and_remove_recipient(vote_applied, accounts, stranger): + """Whitelist a recipient, pay it, drop it — all three factories in one flow.""" + registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) + multisig = accounts.at(LOL_TRUSTED_CALLER, force=True) + _assert_setup_is_live(registry) + + payment_amount = 1_000 * 10**18 + _fund_agent_with_dai(payment_amount, accounts) + recipients_before = registry.getAllowedRecipients() + _start_fresh_spending_period(registry) + + create_and_enact_add_recipient_motion( + contracts.easy_track, + multisig, + registry, + LOL_STABLES_ADD_RECIPIENT_FACTORY, + stranger, + "Stranger", + stranger, + ) + create_and_enact_payment_motion( + contracts.easy_track, + multisig, + LOL_STABLES_TOP_UP_FACTORY, + contracts.dai_token, + [stranger], + [payment_amount], + stranger, + ) + create_and_enact_remove_recipient_motion( + contracts.easy_track, + multisig, + registry, + LOL_STABLES_REMOVE_RECIPIENT_FACTORY, + stranger, + stranger, + ) + + spent_after, _, _, _ = registry.getPeriodState() + assert spent_after == payment_amount, "the payment was not counted against the period limit" + assert registry.getAllowedRecipients() == recipients_before, "the allowed recipients list was not restored" + + +def test_lol_stables_every_allowed_token_counts_against_the_limit(vote_applied, accounts, stranger): + """Each token the shared registry allows can be paid out, and counts normalized to 18 decimals. + + USDC and USDT hold 6 decimals, so a factory that skipped normalizeAmount would let them spend + orders of magnitude past the budget while the registry barely noticed. + """ + registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) + tokens_registry = interface.AllowedTokensRegistry(ALLOWED_TOKENS_REGISTRY) + multisig = accounts.at(LOL_TRUSTED_CALLER, force=True) + _assert_setup_is_live(registry) + + allowed_tokens = tokens_registry.getAllowedTokens() + assert len(allowed_tokens) > 0, "the shared tokens registry allows no tokens" + _start_fresh_spending_period(registry) + + expected_spent = 0 + for token_address in allowed_tokens: + token = interface.ERC20(token_address) + payment_amount = 1_000 * 10 ** token.decimals() + assert token.balanceOf(contracts.agent) >= payment_amount, f"Agent holds too little {token.symbol()}" + + create_and_enact_payment_motion( + contracts.easy_track, + multisig, + LOL_STABLES_TOP_UP_FACTORY, + token, + [multisig], + [payment_amount], + stranger, + ) + + expected_spent += tokens_registry.normalizeAmount(payment_amount, token_address) + spent, _, _, _ = registry.getPeriodState() + assert spent == expected_spent, f"{token.symbol()} was not counted normalized to 18 decimals" + + +def test_lol_stables_single_payment_capped_by_acl(vote_applied, accounts, stranger): + """A single newImmediatePayment cannot exceed the executor's ACL cap, even well under the limit.""" + registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) + multisig = accounts.at(LOL_TRUSTED_CALLER, force=True) + _assert_setup_is_live(registry) + + over_the_cap = FINANCE_DAI_MAX_PER_CALL + 1 + limit, _ = registry.getLimitParameters() + assert over_the_cap < limit, "the ACL cap must bite before the period limit does" + _fund_agent_with_dai(over_the_cap, accounts) + _start_fresh_spending_period(registry) + + with reverts("APP_AUTH_FAILED"): + create_and_enact_payment_motion( + contracts.easy_track, + multisig, + LOL_STABLES_TOP_UP_FACTORY, + contracts.dai_token, + [multisig], + [over_the_cap], + stranger, + ) + + +def test_lol_stables_period_limit(vote_applied, accounts, stranger): + """A whole period's limit can be spent — and not a wei more.""" + registry = interface.AllowedRecipientRegistry(LOL_STABLES_REGISTRY) + multisig = accounts.at(LOL_TRUSTED_CALLER, force=True) + _assert_setup_is_live(registry) + + limit, _ = registry.getLimitParameters() + _fund_agent_with_dai(limit, accounts) + period_end_before = _start_fresh_spending_period(registry) + + # the whole budget, as several payments batched into one motion, each within the ACL cap + recipients = [] + amounts = [] + to_spend = limit + while to_spend > 0: + chunk_amount = min(FINANCE_DAI_MAX_PER_CALL, to_spend) + recipients.append(multisig) + amounts.append(chunk_amount) + to_spend -= chunk_amount + + create_and_enact_payment_motion( + contracts.easy_track, + multisig, + LOL_STABLES_TOP_UP_FACTORY, + contracts.dai_token, + recipients, + amounts, + stranger, + ) + + spent_after, spendable_after, _, period_end_after = registry.getPeriodState() + assert period_end_after > period_end_before, "the spending period should have rolled over to a fresh one" + assert spent_after == limit + assert spendable_after == 0 + + # not a single wei beyond the limit + with reverts("SUM_EXCEEDS_SPENDABLE_BALANCE"): + create_and_enact_payment_motion( + contracts.easy_track, + multisig, + LOL_STABLES_TOP_UP_FACTORY, + contracts.dai_token, + [multisig], + [1], + stranger, + ) From d5c15d9a242d3ec5749448cbccee0d4752647f75 Mon Sep 17 00:00:00 2001 From: phonktown Date: Wed, 29 Jul 2026 16:46:28 +0300 Subject: [PATCH 10/24] feat(Vote): adjust descriptions --- scripts/upgrade_2026_08_05.py | 112 +++++++++++++++++++++------------- tests/test_2026_08_05.py | 8 ++- 2 files changed, 76 insertions(+), 44 deletions(-) diff --git a/scripts/upgrade_2026_08_05.py b/scripts/upgrade_2026_08_05.py index 7f187bbc..f1d61c5c 100644 --- a/scripts/upgrade_2026_08_05.py +++ b/scripts/upgrade_2026_08_05.py @@ -1,30 +1,32 @@ """ Vote 2026_08_05 -Launch of the NEST Automated Buybacks system in treasury-only mode (LIP-36). +1. Submit a Dual Governance proposal containing a single Aragon Agent 0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c forward call to Dual Governance 0xC1db28B3301331277e307FDCfF8DE28242A4486E -=== 1. DG PROPOSAL === -1.1. Add OpStackTokenRatePusher 0xd54c1c6413caac3477ac14b2a80d5398e3c32ffe as NoArgs (kind = 0) to the new TokenRateNotifier 0xbe05d12Fd10919F1881125006523452F6aFF791b -1.2. Add StakingRevenueSource 0x6220212a33a87Ed7Cc386B67eB2c393974F28C38 as WithArgs (kind = 1) to the new TokenRateNotifier 0xbe05d12Fd10919F1881125006523452F6aFF791b -1.3. Upgrade LidoLocator proxy 0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb implementation to 0xF2Ffb952e129a63F0614Ff87126E1d4a494A2313 -1.4. Add BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 as allowed recipient "Buyback Allocator" on the Stonks stETH top-up AllowedRecipientsRegistry 0x1a7cFA9EFB4D5BfFDE87B0FaEb1fC65d653868C0 -1.5. Grant ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE 0xec20c52871c824e5437859e75ac830e83aaaaeb7b0ffd850de830ddd3e385276 to EVMScriptExecutor 0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977 on the LOL stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 -1.6. Grant REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE 0x491d7752c25cfca0f73715cde1130022a9b815373f91a996bbb1ba8943efc99b to EVMScriptExecutor 0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977 on the LOL stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 - -=== NON-DG ITEMS === +I. NEST Activation +1.1. Add OpStackTokenRatePusher 0xd54c1c6413caac3477AC14b2a80D5398E3c32FfE as NoArgs observer kind 0 to TokenRateNotifier 0xbe05d12Fd10919F1881125006523452F6aFF791b +1.2. Add StakingRevenueSource 0x6220212a33a87Ed7Cc386B67eB2c393974F28C38 as WithArgs observer kind 1 to TokenRateNotifier 0xbe05d12Fd10919F1881125006523452F6aFF791b +1.3. Upgrade Lido Locator 0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb to implementation 0xF2Ffb952e129a63F0614Ff87126E1d4a494A2313 +1.4. Add BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 as allowed recipient with name Buyback Allocator on Stonks stETH AllowedRecipientsRegistry 0x1a7cFA9EFB4D5BfFDE87B0FaEb1fC65d653868C0 2. Set Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 as manager on OracleRouter 0x79ef3a538200Fe4981D67E7e886bfb36D4Cb5a31 3. Set treasury-mode Stonks 0xb368586CB980895E51e1D82102E63b3F69d3F151 on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7 4. Grant Buybacks.BuybackExecutor.ALLOCATOR_ROLE 0x87905334ad07701d0cd9b21ea0599de1a0cab067e0ab49596d423d87159ac7f2 to BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7 5. Grant Buybacks.MANAGER_ROLE 0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e to Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7 6. Grant Buybacks.BuybackExecutor.EMERGENCY_ROLE 0xc748c205190870b4e890036f373e30556929f7fbf3db8644c998a652c1996dbd to Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7 -7. Grant Buybacks.BuybackExecutor.EMERGENCY_ROLE 0xc748c205190870b4e890036f373e30556929f7fbf3db8644c998a652c1996dbd to Emergency Committee 0x73b047fe6337183A454c5217241D780a932777bD on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7 +7. Grant Buybacks.BuybackExecutor.EMERGENCY_ROLE 0xc748c205190870b4e890036f373e30556929f7fbf3db8644c998a652c1996dbd to Ethereum Emergency Brakes multisig 0x73b047fe6337183A454c5217241D780a932777bD on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7 8. Grant Buybacks.MANAGER_ROLE 0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e to Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 on BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 9. Call activate() on BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 -10. Remove UpdateStakingModuleShareLimits EVM script factory 0x0C6703F1d8D9DdfB6c6e5F57b4f7432a6500D6D8 -11. Add replacement UpdateStakingModuleShareLimits EVM script factory 0xde3e46E3129fA4e4e3f66c9024B0A3Ad509b27a1 -12. Add LOL stablecoins TopUpAllowedRecipients EVM script factory 0xc72d4C3e86b681D7c9EE306D41193C64D709C303 (AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89) -13. Add LOL stablecoins AddAllowedRecipient EVM script factory 0xe24230619e9218C1eed3de3489a22f6BC3ce18FF -14. Add LOL stablecoins RemoveAllowedRecipient EVM script factory 0xF4d5D97C85eD18f77F99B57f55E9E11d52992632 + +II. LOL ET stablecoins payment factories activation +1.5. Grant ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE 0xec20c52871c824e5437859e75ac830e83aaaaeb7b0ffd850de830ddd3e385276 to EVMScriptExecutor 0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977 on LOL stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 +1.6. Grant REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE 0x491d7752c25cfca0f73715cde1130022a9b815373f91a996bbb1ba8943efc99b to EVMScriptExecutor 0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977 on LOL stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 +12. Add LOL stablecoins TopUpAllowedRecipients EVM script factory 0xc72d4C3e86b681D7c9EE306D41193C64D709C303 with newImmediatePayment permission on Aragon Finance 0xB9E5CBB9CA5b0d659238807E84D0176930753d86 and updateSpentAmount permission on LOL stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 to EasyTrack 0xF0211b7660680B49De1A7E9f25C65660F0a13Fea +13. Add LOL stablecoins AddAllowedRecipient EVM script factory 0xe24230619e9218C1eed3de3489a22f6BC3ce18FF with addRecipient permission on LOL stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 to EasyTrack 0xF0211b7660680B49De1A7E9f25C65660F0a13Fea +14. Add LOL stablecoins RemoveAllowedRecipient EVM script factory 0xF4d5D97C85eD18f77F99B57f55E9E11d52992632 with removeRecipient permission on LOL stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 to EasyTrack 0xF0211b7660680B49De1A7E9f25C65660F0a13Fea + +III. ET factory for CSM share limit updates replacement +10. Remove UpdateStakingModuleShareLimits EVM script factory 0x0C6703F1d8D9DdfB6c6e5F57b4f7432a6500D6D8 from EasyTrack 0xF0211b7660680B49De1A7E9f25C65660F0a13Fea +11. Add UpdateStakingModuleShareLimits EVM script factory 0xde3e46E3129fA4e4e3f66c9024B0A3Ad509b27a1 with validateParams permission on itself and updateModuleShares permission on Staking Router 0xFdDf38947aFB03C621C71b06C9C70bce73f12999 to EasyTrack 0xF0211b7660680B49De1A7E9f25C65660F0a13Fea TODO (after vote) Vote #{vote number} passed & executed on {date+time}, block {blockNumber}. """ @@ -45,12 +47,11 @@ # ============================== Addresses =================================== -ARAGON_AGENT = "0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c" TMC = "0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647" # Treasury Management Committee (MANAGER_ROLE) -EMERGENCY_COMMITTEE = "0x73b047fe6337183A454c5217241D780a932777bD" # Emergency Brakes (EMERGENCY_ROLE) +EMERGENCY_COMMITTEE = "0x73b047fe6337183A454c5217241D780a932777bD" # Ethereum Emergency Brakes multisig (EMERGENCY_ROLE) ORACLE_ROUTER = "0x79ef3a538200Fe4981D67E7e886bfb36D4Cb5a31" LIDO_LOCATOR = "0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb" -OP_STACK_TOKEN_RATE_PUSHER = "0xd54c1c6413caac3477ac14b2a80d5398e3c32ffe" +OP_STACK_TOKEN_RATE_PUSHER = "0xd54c1c6413caac3477AC14b2a80D5398E3c32FfE" STONKS_STETH_TOPUP_REGISTRY = "0x1a7cFA9EFB4D5BfFDE87B0FaEb1fC65d653868C0" STAKING_ROUTER = "0xFdDf38947aFB03C621C71b06C9C70bce73f12999" EVM_SCRIPT_EXECUTOR = "0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977" @@ -114,33 +115,33 @@ def get_dg_items() -> List[Tuple[str, str]]: return [ agent_forward([ - # 1.1. Add OpStackTokenRatePusher as NoArgs (kind = 0) to the new TokenRateNotifier + # 1.1. Add OpStackTokenRatePusher 0xd54c1c6413caac3477AC14b2a80D5398E3c32FfE as NoArgs observer kind 0 to TokenRateNotifier 0xbe05d12Fd10919F1881125006523452F6aFF791b ( new_token_rate_notifier.address, new_token_rate_notifier.addObserver.encode_input(OP_STACK_TOKEN_RATE_PUSHER, OBSERVER_KIND_NO_ARGS), ), - # 1.2. Add StakingRevenueSource as WithArgs (kind = 1) to the new TokenRateNotifier + # 1.2. Add StakingRevenueSource 0x6220212a33a87Ed7Cc386B67eB2c393974F28C38 as WithArgs observer kind 1 to TokenRateNotifier 0xbe05d12Fd10919F1881125006523452F6aFF791b ( new_token_rate_notifier.address, new_token_rate_notifier.addObserver.encode_input(STAKING_REVENUE_SOURCE, OBSERVER_KIND_WITH_ARGS), ), - # 1.3. Upgrade LidoLocator proxy implementation to the new implementation + # 1.3. Upgrade Lido Locator 0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb to implementation 0xF2Ffb952e129a63F0614Ff87126E1d4a494A2313 ( lido_locator_proxy.address, lido_locator_proxy.proxy__upgradeTo.encode_input(NEW_LIDO_LOCATOR_IMPL), ), - # 1.4. Add BuybackAllocator as allowed recipient "Buyback Allocator" on the Stonks stETH top-up AllowedRecipientsRegistry + # 1.4. Add BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 as allowed recipient with name Buyback Allocator on Stonks stETH AllowedRecipientsRegistry 0x1a7cFA9EFB4D5BfFDE87B0FaEb1fC65d653868C0 ( stonks_topup_registry.address, stonks_topup_registry.addRecipient.encode_input(BUYBACK_ALLOCATOR, "Buyback Allocator"), ), - # 1.5. Grant ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE to the EVMScriptExecutor on the LOL stablecoins registry + # 1.5. Grant ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE 0xec20c52871c824e5437859e75ac830e83aaaaeb7b0ffd850de830ddd3e385276 to EVMScriptExecutor 0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977 on LOL stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 encode_oz_grant_role( contract=lol_stables_registry, role_name="ADD_RECIPIENT_TO_ALLOWED_LIST_ROLE", grant_to=EVM_SCRIPT_EXECUTOR, ), - # 1.6. Grant REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE to the EVMScriptExecutor on the same registry + # 1.6. Grant REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE 0x491d7752c25cfca0f73715cde1130022a9b815373f91a996bbb1ba8943efc99b to EVMScriptExecutor 0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977 on LOL stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 encode_oz_grant_role( contract=lol_stables_registry, role_name="REMOVE_RECIPIENT_FROM_ALLOWED_LIST_ROLE", @@ -165,71 +166,91 @@ def get_vote_items() -> Tuple[List[str], List[Tuple[str, str]]]: vote_desc_items, call_script_items = zip( ( - "1. Submit a Dual Governance proposal to register the StakingRevenueSource on the new TokenRateNotifier and enable NEST", + "1. Submit a Dual Governance proposal containing a single Aragon Agent " + "0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c forward call to Dual Governance " + "0xC1db28B3301331277e307FDCfF8DE28242A4486E", dg_call_script[0], ), ( - "2. Set the Treasury Management Committee as manager on the OracleRouter", + "2. Set Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 as manager " + "on OracleRouter 0x79ef3a538200Fe4981D67E7e886bfb36D4Cb5a31", ( oracle_router.address, oracle_router.setManager.encode_input(TMC), ), ), ( - "3. Set the treasury-mode Stonks on the BuybackExecutor", + "3. Set treasury-mode Stonks 0xb368586CB980895E51e1D82102E63b3F69d3F151 " + "on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7", ( buyback_executor.address, buyback_executor.setStonks.encode_input(BUYBACK_STONKS_TREASURY), ), ), ( - "4. Grant Buybacks.BuybackExecutor.ALLOCATOR_ROLE to the BuybackAllocator on the BuybackExecutor", + "4. Grant Buybacks.BuybackExecutor.ALLOCATOR_ROLE " + "0x87905334ad07701d0cd9b21ea0599de1a0cab067e0ab49596d423d87159ac7f2 " + "to BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889 " + "on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7", ( buyback_executor.address, buyback_executor.grantRole.encode_input(ALLOCATOR_ROLE, BUYBACK_ALLOCATOR), ), ), ( - "5. Grant Buybacks.MANAGER_ROLE to the Treasury Management Committee on the BuybackExecutor", + "5. Grant Buybacks.MANAGER_ROLE 0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e " + "to Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 " + "on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7", ( buyback_executor.address, buyback_executor.grantRole.encode_input(MANAGER_ROLE, TMC), ), ), ( - "6. Grant Buybacks.BuybackExecutor.EMERGENCY_ROLE to the Treasury Management Committee on the BuybackExecutor", + "6. Grant Buybacks.BuybackExecutor.EMERGENCY_ROLE " + "0xc748c205190870b4e890036f373e30556929f7fbf3db8644c998a652c1996dbd " + "to Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 " + "on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7", ( buyback_executor.address, buyback_executor.grantRole.encode_input(EMERGENCY_ROLE, TMC), ), ), ( - "7. Grant Buybacks.BuybackExecutor.EMERGENCY_ROLE to the Emergency Committee on the BuybackExecutor", + "7. Grant Buybacks.BuybackExecutor.EMERGENCY_ROLE " + "0xc748c205190870b4e890036f373e30556929f7fbf3db8644c998a652c1996dbd " + "to Ethereum Emergency Brakes multisig 0x73b047fe6337183A454c5217241D780a932777bD " + "on BuybackExecutor 0x6c213ca5A10Cc26548C742229569B4AeD2A9C9B7", ( buyback_executor.address, buyback_executor.grantRole.encode_input(EMERGENCY_ROLE, EMERGENCY_COMMITTEE), ), ), ( - "8. Grant Buybacks.MANAGER_ROLE to the Treasury Management Committee on the BuybackAllocator", + "8. Grant Buybacks.MANAGER_ROLE 0x24bec1f1283f989ed510b4d89bc7ef5002f20db1b60c1b3192336791c868543e " + "to Treasury Management Committee 0xa02FC823cCE0D016bD7e17ac684c9abAb2d6D647 " + "on BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889", ( buyback_allocator.address, buyback_allocator.grantRole.encode_input(MANAGER_ROLE, TMC), ), ), ( - "9. Call activate() on the BuybackAllocator", + "9. Call activate() on BuybackAllocator 0xAA568141c051f2D1132b110f8391F18D48E8D889", ( buyback_allocator.address, buyback_allocator.activate.encode_input(), ), ), ( - "10. Remove the UpdateStakingModuleShareLimits EVM script factory from Easy Track", + "10. Remove UpdateStakingModuleShareLimits EVM script factory " + "0x0C6703F1d8D9DdfB6c6e5F57b4f7432a6500D6D8 from EasyTrack 0xF0211b7660680B49De1A7E9f25C65660F0a13Fea", remove_evmscript_factory(OLD_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY), ), ( - "11. Add the replacement UpdateStakingModuleShareLimits EVM script factory to Easy Track", + "11. Add UpdateStakingModuleShareLimits EVM script factory 0xde3e46E3129fA4e4e3f66c9024B0A3Ad509b27a1 " + "with validateParams permission on itself and updateModuleShares permission on Staking Router " + "0xFdDf38947aFB03C621C71b06C9C70bce73f12999 to EasyTrack 0xF0211b7660680B49De1A7E9f25C65660F0a13Fea", add_evmscript_factory( NEW_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY, create_permissions(new_update_staking_module_share_limits_factory, "validateParams") @@ -237,24 +258,31 @@ def get_vote_items() -> Tuple[List[str], List[Tuple[str, str]]]: ), ), ( - "12. Add an Easy Track EVM script factory 0xc72d4C3e86b681D7c9EE306D41193C64D709C303 for funding the " - "Liquidity Observation Lab multisig with stablecoins (AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89)", + "12. Add LOL stablecoins TopUpAllowedRecipients EVM script factory " + "0xc72d4C3e86b681D7c9EE306D41193C64D709C303 with newImmediatePayment permission on Aragon Finance " + "0xB9E5CBB9CA5b0d659238807E84D0176930753d86 and updateSpentAmount permission on LOL stablecoins " + "AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 " + "to EasyTrack 0xF0211b7660680B49De1A7E9f25C65660F0a13Fea", add_evmscript_factory( factory=LOL_STABLES_TOP_UP_FACTORY, permissions=create_top_up_allowed_recipient_permission(registry_address=LOL_STABLES_REGISTRY), ), ), ( - "13. Add an Easy Track EVM script factory 0xe24230619e9218C1eed3de3489a22f6BC3ce18FF for adding a recipient " - "to the Liquidity Observation Lab stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89", + "13. Add LOL stablecoins AddAllowedRecipient EVM script factory " + "0xe24230619e9218C1eed3de3489a22f6BC3ce18FF with addRecipient permission on LOL stablecoins " + "AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 " + "to EasyTrack 0xF0211b7660680B49De1A7E9f25C65660F0a13Fea", add_evmscript_factory( factory=LOL_STABLES_ADD_RECIPIENT_FACTORY, permissions=create_permissions(lol_stables_registry, "addRecipient"), ), ), ( - "14. Add an Easy Track EVM script factory 0xF4d5D97C85eD18f77F99B57f55E9E11d52992632 for removing a recipient " - "from the Liquidity Observation Lab stablecoins AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89", + "14. Add LOL stablecoins RemoveAllowedRecipient EVM script factory " + "0xF4d5D97C85eD18f77F99B57f55E9E11d52992632 with removeRecipient permission on LOL stablecoins " + "AllowedRecipientsRegistry 0x8d8b35cA51e7808098afF4918C21Ce428c943F89 " + "to EasyTrack 0xF0211b7660680B49De1A7E9f25C65660F0a13Fea", add_evmscript_factory( factory=LOL_STABLES_REMOVE_RECIPIENT_FACTORY, permissions=create_permissions(lol_stables_registry, "removeRecipient"), diff --git a/tests/test_2026_08_05.py b/tests/test_2026_08_05.py index 2d094439..89964ca8 100644 --- a/tests/test_2026_08_05.py +++ b/tests/test_2026_08_05.py @@ -64,7 +64,7 @@ # --- Lido contracts --- LIDO_LOCATOR = "0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb" -OP_STACK_TOKEN_RATE_PUSHER = "0xd54c1c6413caac3477ac14b2a80d5398e3c32ffe" +OP_STACK_TOKEN_RATE_PUSHER = "0xd54c1c6413caac3477AC14b2a80D5398E3c32FfE" STONKS_STETH_TOPUP_REGISTRY = "0x1a7cFA9EFB4D5BfFDE87B0FaEb1fC65d653868C0" EASY_TRACK = "0xF0211b7660680B49De1A7E9f25C65660F0a13Fea" STAKING_ROUTER = "0xFdDf38947aFB03C621C71b06C9C70bce73f12999" @@ -451,6 +451,7 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g assert len(vote_events) == EXPECTED_VOTE_EVENTS_COUNT assert count_vote_items_by_events(vote_tx, voting.address) == EXPECTED_VOTE_ITEMS_COUNT + # vote item 1 if EXPECTED_DG_PROPOSAL_ID is not None: assert EXPECTED_DG_PROPOSAL_ID == timelock.getProposalsCount() @@ -547,11 +548,14 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g # ========================================================================= # ================== DG before proposal executed checks =================== # ========================================================================= - # DG item 1.2 + # DG items 1.1-1.2: neither observer is on the new TokenRateNotifier yet new_notifier_observers_before = [ str(new_token_rate_notifier.observers(i)[0]).lower() for i in range(new_token_rate_notifier.observersLength()) ] + # DG item 1.1 + assert str(OP_STACK_TOKEN_RATE_PUSHER).lower() not in new_notifier_observers_before, "OpStack rate pusher already registered" + # DG item 1.2 assert str(STAKING_REVENUE_SOURCE).lower() not in new_notifier_observers_before, "Revenue source already registered" # DG item 1.3: LidoLocator still uses the old implementation and still resolves to the old postTokenRebaseReceiver From e1524bbf9166acdd2b120d27506a2e881cdf3f02 Mon Sep 17 00:00:00 2001 From: phonktown Date: Thu, 30 Jul 2026 19:05:30 +0300 Subject: [PATCH 11/24] fix(Tests): fix CI tests --- configs/config_mainnet.py | 8 ++++---- tests/regression/test_oracle_report_with_notifier.py | 11 ++++++++--- tests/snapshot/test_first_slots.py | 10 ++++++++-- utils/config.py | 4 ++-- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/configs/config_mainnet.py b/configs/config_mainnet.py index d1faff71..3e4869b7 100644 --- a/configs/config_mainnet.py +++ b/configs/config_mainnet.py @@ -125,7 +125,7 @@ # LidoLocator LIDO_LOCATOR = "0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb" -LIDO_LOCATOR_IMPL = "0x0360002bf51DCae1c0267aE0AFDaBacAF7De686b" # SRV3 +LIDO_LOCATOR_IMPL = "0xF2Ffb952e129a63F0614Ff87126E1d4a494A2313" # NEST # Other upgrade addresses LIDO_V2_UPGRADE_TEMPLATE = "0xa818fF9EC93122Bf9401ab4340C42De638CD600a" @@ -237,8 +237,8 @@ CS_MODULE_NAME = "Community Staking" CS_MODULE_MODULE_FEE_BP = 600 CS_MODULE_TREASURY_FEE_BP = 400 -CS_MODULE_TARGET_SHARE_BP = 850 -CS_MODULE_PRIORITY_EXIT_SHARE_THRESHOLD = 1020 +CS_MODULE_TARGET_SHARE_BP = 900 # Updated from 850 to 900 by an Easy Track motion, block 25632046 +CS_MODULE_PRIORITY_EXIT_SHARE_THRESHOLD = 1080 # Updated from 1020 to 1080 by the same motion CS_MODULE_MAX_DEPOSITS_PER_BLOCK = 30 CS_MODULE_MIN_DEPOSIT_BLOCK_DISTANCE = 25 @@ -485,7 +485,7 @@ OBOL_LIDO_SPLIT_IMPL = "0x2fB59065F049e0D0E3180C6312FA0FeB5Bbf0FE3" # Ethereum Part for Optimism bridge -L1_TOKEN_RATE_NOTIFIER = "0x25e35855783bec3E49355a29e110f02Ed8b05ba9" +L1_TOKEN_RATE_NOTIFIER = "0xbe05d12Fd10919F1881125006523452F6aFF791b" # NEST L1_OPTIMISM_TOKEN_RATE_PUSHER = "0xd54c1c6413caac3477AC14b2a80D5398E3c32FfE" L1_OPTIMISM_TOKENS_BRIDGE = "0x76943C0D61395d8F2edF9060e1533529cAe05dE6" L1_OPTIMISM_TOKENS_BRIDGE_IMPL = "0x29C5c51A031165CE62F964966A6399b81165EFA4" diff --git a/tests/regression/test_oracle_report_with_notifier.py b/tests/regression/test_oracle_report_with_notifier.py index 4e1480b7..b342f3b3 100644 --- a/tests/regression/test_oracle_report_with_notifier.py +++ b/tests/regression/test_oracle_report_with_notifier.py @@ -17,6 +17,9 @@ # Use as mock for L2 TokenRateOracle L2_TOKEN_RATE_ORACLE = WSTETH_TOKEN +# TokenRateNotifier.ObserverKind +OBSERVER_KIND_NO_ARGS = 0 + @pytest.fixture(scope="module") def accounting_oracle() -> Contract: @@ -40,7 +43,7 @@ def withdrawal_queue() -> Contract: def test_oracle_report_revert(): """Test oracle report reverts when messenger is empty""" - interface.TokenRateNotifier(L1_TOKEN_RATE_NOTIFIER) # load TokenRateNotifier contract ABI to catch correct error + interface.TokenRateNotifierV2(L1_TOKEN_RATE_NOTIFIER) web3.provider.make_request("hardhat_setCode", [L1_OPTIMISM_CROSS_DOMAIN_MESSENGER, "0x"]) web3.provider.make_request("evm_setAccountCode", [L1_OPTIMISM_CROSS_DOMAIN_MESSENGER, "0x"]) @@ -101,9 +104,11 @@ def test_oracle_report_success_when_observer_reverts(accounting_oracle: Contract opStackTokenRatePusher = OpStackTokenRatePusherWithSomeErrorStub.deploy({"from": get_deployer_account()}) - tokenRateNotifier = interface.TokenRateNotifier(L1_TOKEN_RATE_NOTIFIER) + tokenRateNotifier = interface.TokenRateNotifierV2(L1_TOKEN_RATE_NOTIFIER) tokenRateNotifierOwner = tokenRateNotifier.owner() - tokenRateNotifier.addObserver(opStackTokenRatePusher, {"from": tokenRateNotifierOwner}) + tokenRateNotifier.addObserver( + opStackTokenRatePusher, OBSERVER_KIND_NO_ARGS, {"from": tokenRateNotifierOwner} + ) accounts[0].transfer(el_vault.address, 10**18) block_before_report = chain.height diff --git a/tests/snapshot/test_first_slots.py b/tests/snapshot/test_first_slots.py index 9e6a4385..2cebc334 100644 --- a/tests/snapshot/test_first_slots.py +++ b/tests/snapshot/test_first_slots.py @@ -47,8 +47,14 @@ def test_first_slots(sandwich_upgrade: SandwichFn): @pytest.fixture(scope="module") def skip_slots() -> Sequence[tuple[str, int]]: - """Slots that are not checked for equality""" - return [] + """Slots intentionally modified by the vote""" + return [ + ( + # EasyTrack factories array: the vote removes one factory and adds four. + contracts.easy_track.address, + 0x05, + ), + ] @pytest.fixture(scope="module") diff --git a/utils/config.py b/utils/config.py index 56749596..cec055ad 100644 --- a/utils/config.py +++ b/utils/config.py @@ -495,8 +495,8 @@ def trp_escrow_factory(self) -> interface.VestingEscrowFactory: return interface.VestingEscrowFactory(TRP_VESTING_ESCROW_FACTORY) @property - def token_rate_notifier(self) -> interface.TokenRateNotifier: - return interface.TokenRateNotifier(L1_TOKEN_RATE_NOTIFIER) + def token_rate_notifier(self) -> interface.TokenRateNotifierV2: + return interface.TokenRateNotifierV2(L1_TOKEN_RATE_NOTIFIER) @property def validator_exit_verifier(self) -> interface.ValidatorsExitBusOracle: From 4d9d360b60cbc3d35e2c65f0ab4097822ee07dcd Mon Sep 17 00:00:00 2001 From: phonktown Date: Thu, 30 Jul 2026 19:09:07 +0300 Subject: [PATCH 12/24] feat(Tests): update comment --- tests/snapshot/test_first_slots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/snapshot/test_first_slots.py b/tests/snapshot/test_first_slots.py index 2cebc334..b51bf9c6 100644 --- a/tests/snapshot/test_first_slots.py +++ b/tests/snapshot/test_first_slots.py @@ -47,7 +47,7 @@ def test_first_slots(sandwich_upgrade: SandwichFn): @pytest.fixture(scope="module") def skip_slots() -> Sequence[tuple[str, int]]: - """Slots intentionally modified by the vote""" + """Slots intentionally modified by the NEST 2026-08-05 upgrade.""" return [ ( # EasyTrack factories array: the vote removes one factory and adds four. From 23d448817a05014aa637167ab85fccdf4bcc7e32 Mon Sep 17 00:00:00 2001 From: Sergey K Date: Mon, 3 Aug 2026 16:07:26 +0500 Subject: [PATCH 13/24] fix(ci): remove unsupported vote_prev_block input --- .github/workflows/sm-tests.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/sm-tests.yml b/.github/workflows/sm-tests.yml index d9c26730..657293e2 100644 --- a/.github/workflows/sm-tests.yml +++ b/.github/workflows/sm-tests.yml @@ -89,14 +89,6 @@ jobs: run: poetry run brownie networks import network-config.yaml True working-directory: scripts - - name: Capture pre-vote block for staking modules regression - id: modules-regression-vote-block - shell: bash - run: | - vote_prev_block=$(make --no-print-directory __get_rpc_latest_block_number CORE_TESTS_TARGET_RPC_URL=${{ env.HARDHAT_NODE_URL }}) - echo "vote_prev_block=$vote_prev_block" >> "$GITHUB_OUTPUT" - working-directory: scripts - - name: Prepare test environment with Brownie run: PYTHONPATH=$PWD poetry run brownie run scripts/ci/prepare_environment --network mfh-1 working-directory: scripts @@ -111,4 +103,3 @@ jobs: ref: develop path: staking-modules rpc_url: ${{ env.HARDHAT_NODE_URL }} - vote_prev_block: ${{ steps.modules-regression-vote-block.outputs.vote_prev_block }} From 7f0ac81ebc79c02ba2486f2e6aff68ec4d169c44 Mon Sep 17 00:00:00 2001 From: phonktown Date: Mon, 3 Aug 2026 18:19:41 +0300 Subject: [PATCH 14/24] fix(Stonks): split stonks test into 2 parts --- .../workflows/stonks_integration_tests.yml | 37 ++++++++++++++++++- Makefile | 16 +++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.github/workflows/stonks_integration_tests.yml b/.github/workflows/stonks_integration_tests.yml index 9d494ac1..48d17e1c 100644 --- a/.github/workflows/stonks_integration_tests.yml +++ b/.github/workflows/stonks_integration_tests.yml @@ -66,8 +66,41 @@ jobs: echo "❌ Fork was not ready after 30 seconds" >&2 exit 1 - - name: Enact vote and run stonks integration tests - run: docker exec -e ETH_RPC_URL -e ETHERSCAN_TOKEN -e NODE_OPTIONS tests-runner bash -c 'make test-stonks-integration STONKS_DIR=stonks' + # Suites that price tokens through the real Chainlink FeedRegistry. They run before the vote + # because enacting it warps the fork clock past the feeds' updatedAt — see the comment above + # STONKS_POSTVOTE_SUITES in the Makefile. + - name: Run stonks suites that need a clean fork + run: docker exec -e ETH_RPC_URL -e NODE_OPTIONS tests-runner bash -c 'make test-stonks-integration-prevote STONKS_DIR=stonks' + env: + ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }} + + # The pass above deploys contracts, impersonates the Aragon Agent and moves LDO around, so the + # vote is enacted on a fresh fork rather than on top of that state. + - name: Restart the fork node + run: | + docker exec tests-runner bash -c 'pkill -f "hardhat node"' || true + docker exec -e ETH_RPC_URL -e NODE_OPTIONS --detach tests-runner bash -c 'NODE_PORT=8545 make node' + env: + ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }} + + - name: Check that the restarted fork is ready + shell: bash + run: | + echo "Waiting for fork node on 127.0.0.1:8545..." + sleep 10 + for i in {1..30}; do + if (echo > /dev/tcp/127.0.0.1/8545) >/dev/null 2>&1; then + echo "Fork is ready ✅" + exit 0 + fi + echo "Not ready yet... ($i/30)" + sleep 1 + done + echo "❌ Fork was not ready after 30 seconds" >&2 + exit 1 + + - name: Enact vote and run stonks suites that need the NEST notifier + run: docker exec -e ETH_RPC_URL -e ETHERSCAN_TOKEN -e NODE_OPTIONS tests-runner bash -c 'make test-stonks-integration-postvote STONKS_DIR=stonks' env: ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }} ETHERSCAN_TOKEN: ${{ secrets.ETHERSCAN_TOKEN }} diff --git a/Makefile b/Makefile index 49c1a0c6..3068fd80 100644 --- a/Makefile +++ b/Makefile @@ -95,10 +95,22 @@ test-core: FORKING_BLOCK_NUMBER=$$LATEST_BLOCK_NUMBER \ yarn test:integration -test-stonks-integration: ci-prepare-environment +# Two passes, because enacting the vote warps the fork clock ~9 days ahead of the Chainlink updatedAt: +# suites pricing through the real FeedRegistry hit OracleStale after that, so they run on a clean fork. +# The suites below need the opposite — the post-vote NEST TokenRateNotifier — and price through a stub +# registry stamped with the current block timestamp, so the warp does not affect them. +STONKS_POSTVOTE_SUITES ?= staking-revenue-source buyback-happy-path + +test-stonks-integration-prevote: + cd $(STONKS_DIR) && \ + { [ -d node_modules ] || npm ci; } && \ + POSTVOTE_PATTERN=$$(echo "$(STONKS_POSTVOTE_SUITES)" | tr ' ' '|') && \ + RPC_URL=$(ETH_RPC_URL) npx hardhat test $$(ls test/integration/*.ts | grep -vE "$$POSTVOTE_PATTERN") --network localhost + +test-stonks-integration-postvote: ci-prepare-environment cd $(STONKS_DIR) && \ { [ -d node_modules ] || npm ci; } && \ - RPC_URL=$(ETH_RPC_URL) npm run test:integration + RPC_URL=$(ETH_RPC_URL) npx hardhat test $(patsubst %,test/integration/%.ts,$(STONKS_POSTVOTE_SUITES)) --network localhost slots: @echo "Input https://github.com/lidofinance/protocol-onchain-mon-bots/blob/main/bots/ethereum-steth-v2/src/utils/constants.mainnet.ts file content (end with Enter and Ctrl+D):" From b0a0266a10e11be7f99ac9de35fd96335b94d502 Mon Sep 17 00:00:00 2001 From: phonktown Date: Mon, 3 Aug 2026 20:21:29 +0300 Subject: [PATCH 15/24] fix(CI): fix core tests CI --- .github/workflows/core_tests.yml | 7 +++++++ Makefile | 5 +++++ scripts/ci/refresh_vaults_report.py | 31 +++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 scripts/ci/refresh_vaults_report.py diff --git a/.github/workflows/core_tests.yml b/.github/workflows/core_tests.yml index c03bc0d8..e08a5dd1 100644 --- a/.github/workflows/core_tests.yml +++ b/.github/workflows/core_tests.yml @@ -66,6 +66,13 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ETHERSCAN_TOKEN: ${{ secrets.ETHERSCAN_TOKEN }} + # The step above enacted the vote, which moved the fork clock past the vaults report. + - name: Refresh the vaults report + run: docker exec -e ETH_RPC_URL -e ETHERSCAN_TOKEN tests-runner bash -c 'make ci-refresh-vaults-report' + env: + ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }} + ETHERSCAN_TOKEN: ${{ secrets.ETHERSCAN_TOKEN }} + - name: Run core tests run: docker exec -e CORE_TESTS_TARGET_RPC_URL -e NODE_OPTIONS -e DG_PROPOSAL_IDS tests-runner bash -c 'make test-core' env: diff --git a/Makefile b/Makefile index 3068fd80..bf60093a 100644 --- a/Makefile +++ b/Makefile @@ -135,5 +135,10 @@ slots: ci-prepare-environment: poetry run brownie run scripts/ci/prepare_environment --network mfh-1 +# Core tests operate on vaults, and VaultHub rejects a vaults report older than 2 days, while +# enacting the vote leaves the fork clock ~9 days past the last one. +ci-refresh-vaults-report: + poetry run brownie run scripts/ci/refresh_vaults_report --network mfh-1 + enact-fork: poetry run brownie run $(vote) start_and_execute_vote_on_fork_manual --network=mfh-1 diff --git a/scripts/ci/refresh_vaults_report.py b/scripts/ci/refresh_vaults_report.py new file mode 100644 index 00000000..1394c9b7 --- /dev/null +++ b/scripts/ci/refresh_vaults_report.py @@ -0,0 +1,31 @@ +from brownie import web3 + +from utils.config import contracts +from utils.test.oracle_report_helpers import oracle_report + +def get_vaults_report_age() -> int: + # Not chain.time(): it is the wall clock plus the offset this very process slept, so it does not + # see the warp done by the vote enactment. VaultHub compares the report against block.timestamp. + return web3.eth.get_block("latest").timestamp - contracts.lazy_oracle.latestReportTimestamp() + +def main(): + """Bring a fresh AccountingOracle report so that VaultHub stops treating vaults as stale. + + Enacting a vote moves the fork clock ~9 days ahead while the vaults report stays at the fork + block, and VaultHub reverts with VaultReportStale on a report older than REPORT_FRESHNESS_DELTA. + """ + freshness_delta = contracts.vault_hub.REPORT_FRESHNESS_DELTA() + report_age = get_vaults_report_age() + print(f"Vaults report age: {report_age}s, freshness delta: {freshness_delta}s") + + if report_age < freshness_delta: + print("Vaults report is fresh, skipping the oracle report.") + return + + # The report is only needed to move the timestamp, so keep the rebase itself at zero. + oracle_report(cl_diff=0, exclude_vaults_balances=True, skip_withdrawals=True, silent=True) + + report_age = get_vaults_report_age() + print(f"Vaults report age after the oracle report: {report_age}s") + + assert report_age < freshness_delta, "Vaults report is still stale after the oracle report" \ No newline at end of file From 8d54da80aba68da599bd0f895a55bbaf14450cde Mon Sep 17 00:00:00 2001 From: phonktown Date: Mon, 3 Aug 2026 20:45:37 +0300 Subject: [PATCH 16/24] fix(Interface): update interface --- interfaces/TokenRateNotifierV2.json | 114 +++++++++++++++++++++++++++- tests/test_2026_08_05.py | 2 +- 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/interfaces/TokenRateNotifierV2.json b/interfaces/TokenRateNotifierV2.json index 3640c7a2..c9786061 100644 --- a/interfaces/TokenRateNotifierV2.json +++ b/interfaces/TokenRateNotifierV2.json @@ -1,4 +1,20 @@ [ + { + "inputs": [ + { + "internalType": "address", + "name": "initialOwner_", + "type": "address" + }, + { + "internalType": "address", + "name": "tokenRateProvider_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, { "inputs": [], "name": "ErrorAddExistedObserver", @@ -52,6 +68,12 @@ "internalType": "address", "name": "observer", "type": "address" + }, + { + "indexed": true, + "internalType": "enum TokenRateNotifier.ObserverKind", + "name": "kind", + "type": "uint8" } ], "name": "ObserverAdded", @@ -65,11 +87,36 @@ "internalType": "address", "name": "observer", "type": "address" + }, + { + "indexed": true, + "internalType": "enum TokenRateNotifier.ObserverKind", + "name": "kind", + "type": "uint8" } ], "name": "ObserverRemoved", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -89,6 +136,19 @@ "name": "PushTokenRateFailed", "type": "event" }, + { + "inputs": [], + "name": "INDEX_NOT_FOUND", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "MAX_OBSERVERS_COUNT", @@ -102,6 +162,32 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "REQUIRED_INTERFACE_NO_ARGS", + "outputs": [ + { + "internalType": "bytes4", + "name": "", + "type": "bytes4" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "REQUIRED_INTERFACE_WITH_ARGS", + "outputs": [ + { + "internalType": "bytes4", + "name": "", + "type": "bytes4" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "TOKEN_RATE_PROVIDER", @@ -123,7 +209,7 @@ "type": "address" }, { - "internalType": "enum ITokenRateNotifier.ObserverKind", + "internalType": "enum TokenRateNotifier.ObserverKind", "name": "kind_", "type": "uint8" } @@ -180,7 +266,7 @@ "inputs": [ { "internalType": "uint256", - "name": "index", + "name": "", "type": "uint256" } ], @@ -188,11 +274,11 @@ "outputs": [ { "internalType": "address", - "name": "observer", + "name": "addr", "type": "address" }, { - "internalType": "enum ITokenRateNotifier.ObserverKind", + "internalType": "enum TokenRateNotifier.ObserverKind", "name": "kind", "type": "uint8" } @@ -238,5 +324,25 @@ "outputs": [], "stateMutability": "nonpayable", "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" } ] diff --git a/tests/test_2026_08_05.py b/tests/test_2026_08_05.py index 89964ca8..eea84017 100644 --- a/tests/test_2026_08_05.py +++ b/tests/test_2026_08_05.py @@ -725,7 +725,7 @@ def test_nest_buyback_happy_path(vote_applied, accounts, stranger): buyback_allocator = interface.BuybackAllocator(BUYBACK_ALLOCATOR) staking_revenue_source = interface.StakingRevenueSource(STAKING_REVENUE_SOURCE) notifier = interface.TokenRateNotifierV2(NEW_TOKEN_RATE_NOTIFIER) - observer_addresses = [notifier.observers(index)["observer"] for index in range(notifier.observersLength())] + observer_addresses = [notifier.observers(index)[0] for index in range(notifier.observersLength())] # Preconditions: the launch is in place assert interface.LidoLocator(LIDO_LOCATOR).postTokenRebaseReceiver() == NEW_TOKEN_RATE_NOTIFIER From 60683b5ab0d31b3e6d1189d2eb75bcae2705e130 Mon Sep 17 00:00:00 2001 From: phonktown Date: Mon, 3 Aug 2026 21:06:34 +0300 Subject: [PATCH 17/24] feat(Tests): adjust vote tests --- tests/test_2026_08_05.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_2026_08_05.py b/tests/test_2026_08_05.py index eea84017..4c5e662c 100644 --- a/tests/test_2026_08_05.py +++ b/tests/test_2026_08_05.py @@ -330,14 +330,19 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g assert buyback_executor.stonks() == ZERO_ADDRESS, "BuybackExecutor stonks already set" # vote item 4 assert not buyback_executor.hasRole(ALLOCATOR_ROLE, BUYBACK_ALLOCATOR) + assert buyback_executor.getRoleMemberCount(ALLOCATOR_ROLE) == 0 # vote item 5 assert not buyback_executor.hasRole(MANAGER_ROLE, TMC) + assert buyback_executor.getRoleMemberCount(MANAGER_ROLE) == 0 # vote item 6 assert not buyback_executor.hasRole(EMERGENCY_ROLE, TMC) # vote item 7 assert not buyback_executor.hasRole(EMERGENCY_ROLE, EMERGENCY_COMMITTEE) + # vote items 6-7 grant the same role, so its holders are counted once for both + assert buyback_executor.getRoleMemberCount(EMERGENCY_ROLE) == 0 # vote item 8 assert not buyback_allocator.hasRole(MANAGER_ROLE, TMC) + assert buyback_allocator.getRoleMemberCount(MANAGER_ROLE) == 0 # vote item 9 assert buyback_allocator.activationTS() == 0, "BuybackAllocator already activated" # vote items 10-11 @@ -402,14 +407,24 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g assert buyback_executor.stonks() == BUYBACK_STONKS_TREASURY, "BuybackExecutor stonks not set" # vote item 4 assert buyback_executor.hasRole(ALLOCATOR_ROLE, BUYBACK_ALLOCATOR), "ALLOCATOR_ROLE not granted to allocator" + assert buyback_executor.getRoleMemberCount(ALLOCATOR_ROLE) == 1, "extra ALLOCATOR_ROLE holder on the executor" # vote item 5 assert buyback_executor.hasRole(MANAGER_ROLE, TMC), "executor MANAGER_ROLE not granted to TMC" + assert buyback_executor.getRoleMemberCount(MANAGER_ROLE) == 1, "extra MANAGER_ROLE holder on the executor" # vote item 6 assert buyback_executor.hasRole(EMERGENCY_ROLE, TMC), "executor EMERGENCY_ROLE not granted to TMC" # vote item 7 assert buyback_executor.hasRole(EMERGENCY_ROLE, EMERGENCY_COMMITTEE), "executor EMERGENCY_ROLE not granted to EC" + # vote items 6-7 grant the same role, so TMC and the emergency committee are its only holders + assert buyback_executor.getRoleMemberCount(EMERGENCY_ROLE) == 2, "extra EMERGENCY_ROLE holder on the executor" # vote item 8 assert buyback_allocator.hasRole(MANAGER_ROLE, TMC), "allocator MANAGER_ROLE not granted to TMC" + assert buyback_allocator.getRoleMemberCount(MANAGER_ROLE) == 1, "extra MANAGER_ROLE holder on the allocator" + # the role admin is untouched by the vote: still Voting alone on both contracts + assert buyback_executor.hasRole(DEFAULT_ADMIN_ROLE, VOTING) + assert buyback_executor.getRoleMemberCount(DEFAULT_ADMIN_ROLE) == 1 + assert buyback_allocator.hasRole(DEFAULT_ADMIN_ROLE, VOTING) + assert buyback_allocator.getRoleMemberCount(DEFAULT_ADMIN_ROLE) == 1 # vote item 9: activate() records activationTS as midnight UTC of the execution day assert buyback_allocator.activationTS() == vote_tx.timestamp - (vote_tx.timestamp % ONE_DAY), \ "BuybackAllocator not activated at the vote-execution day's midnight UTC" From ced54b8a633856894ff03604b330149a7e327386 Mon Sep 17 00:00:00 2001 From: phonktown Date: Mon, 3 Aug 2026 22:08:42 +0300 Subject: [PATCH 18/24] feat(Vote): upload IPFS + adjust descriptions --- scripts/upgrade_2026_08_05.py | 25 ++++++------------------- tests/test_2026_08_05.py | 3 +-- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/scripts/upgrade_2026_08_05.py b/scripts/upgrade_2026_08_05.py index f1d61c5c..67b25441 100644 --- a/scripts/upgrade_2026_08_05.py +++ b/scripts/upgrade_2026_08_05.py @@ -84,25 +84,14 @@ OBSERVER_KIND_NO_ARGS = 0 OBSERVER_KIND_WITH_ARGS = 1 -# TODO does not cover DG items 1.5-1.6 (LOL stablecoins registry role grants) yet — awaiting the final wording -DG_PROPOSAL_METADATA = "Register the StakingRevenueSource on the TokenRateNotifier to enable NEST" +DG_PROPOSAL_METADATA = "Activate NEST, add LOL stablecoins Easy Track payment factories (limit of $8M per 6 months), replace CSM Update Share Limits Easy Track factory" # ipfs description -# TODO - add lol factories to description IPFS_DESCRIPTION = """ -# Launch NEST Automated Buybacks in treasury-only mode - -1. **Register the StakingRevenueSource on the TokenRateNotifier to enable NEST** through a Dual -Governance proposal. Migrate the OpStack token rate pusher and register the StakingRevenueSource -on the new TokenRateNotifier, upgrade the LidoLocator implementation to use the new notifier as -the post-token-rebase receiver, and allow the BuybackAllocator on the Stonks stETH top-up Easy -Track registry. Item 1. -2. **Launch NEST Automated Buybacks in treasury-only mode** (LIP-36). Set the Treasury Management -Committee as the OracleRouter manager, configure the treasury-mode Stonks on the BuybackExecutor, -grant the manager, allocator, and emergency roles on the BuybackExecutor and BuybackAllocator, -and activate the BuybackAllocator. Items 2-9. -3. **Replace the incorrectly configured UpdateStakingModuleShareLimits Easy Track factory** with -the corrected deployment. Items 10-11. +# NEST Activation, LOL stablecoins ET Payment Factories Activation, CSM Update Share Limits ET Factory Replacement +1. **Launch NEST (Automated LDO Buyback and Liquidity Provisioning)** in [treasury-only mode](https://research.lido.fi/t/liquid-buybacks-nest-execution-with-ldo-wsteth-liquidity/10894/109#p-25979-h-3-launch-mode-treasury-only-5), with all purchased LDO sent directly to the DAO Treasury. Under the [DAO-approved](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x022e901a6368573d18b150eecda563dd2ee17ad2aa6a0ef9772151cc7ba55187) cumulative surplus model described in [LIP-36](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-36.md#surplus-mechanics), NEST converts a fixed portion of staking revenue above the operating baseline into LDO via CoW Swap. Audit & deployment verification: [Ack3](https://github.com/lidofinance/audits/blob/main/Ack3%20Lido%20NEST%20Audit%20Report%2007-2026.pdf). Items 1.1 - 1.4, 2 - 9. +2. **Add LOL stablecoins Easy Track factories for payments and adding/removing allowed recipients**, with a limit of $8M per 6 months, [as per Snapshot decision](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x863859d857c7429a0dcb85a4b324de803e2f66ddd8f50e4c2f04a31c35c6ae6f). Items 1.5, 1.6, 12 - 14. +3. **Replace CSM Update Share Limits Easy Track factory** to correct the per-motion change cap to 0.5%, [as proposed on the forum](https://research.lido.fi/t/community-staking-module/5917/231). Items 10, 11. """ @@ -166,9 +155,7 @@ def get_vote_items() -> Tuple[List[str], List[Tuple[str, str]]]: vote_desc_items, call_script_items = zip( ( - "1. Submit a Dual Governance proposal containing a single Aragon Agent " - "0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c forward call to Dual Governance " - "0xC1db28B3301331277e307FDCfF8DE28242A4486E", + "1. Submit a Dual Governance proposal to activate NEST, add LOL stablecoins Easy Track payment factories (limit of $8M per 6 months), replace CSM Update Share Limits Easy Track factory", dg_call_script[0], ), ( diff --git a/tests/test_2026_08_05.py b/tests/test_2026_08_05.py index 4c5e662c..2d32d049 100644 --- a/tests/test_2026_08_05.py +++ b/tests/test_2026_08_05.py @@ -162,8 +162,7 @@ EXPECTED_DG_EVENTS_FROM_AGENT = 6 EXPECTED_DG_EVENTS_COUNT = 1 -# TODO set once the IPFS description is uploaded. -IPFS_DESCRIPTION_HASH = "" +IPFS_DESCRIPTION_HASH = "bafkreiea5mkaprjkxfsxjv7vp6aireshqbvpy3iy5tcvwwhopujv6p25cy" # ============================================================================ From c79452ed4bb928635f99257b63c3ea9fb6753d12 Mon Sep 17 00:00:00 2001 From: phonktown Date: Mon, 3 Aug 2026 23:05:11 +0300 Subject: [PATCH 19/24] fix(Makefile): fix stonks tests grep --- Makefile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index bf60093a..50c7c47b 100644 --- a/Makefile +++ b/Makefile @@ -104,8 +104,10 @@ STONKS_POSTVOTE_SUITES ?= staking-revenue-source buyback-happy-path test-stonks-integration-prevote: cd $(STONKS_DIR) && \ { [ -d node_modules ] || npm ci; } && \ - POSTVOTE_PATTERN=$$(echo "$(STONKS_POSTVOTE_SUITES)" | tr ' ' '|') && \ - RPC_URL=$(ETH_RPC_URL) npx hardhat test $$(ls test/integration/*.ts | grep -vE "$$POSTVOTE_PATTERN") --network localhost + POSTVOTE_PATTERN="^test/integration/($$(echo $(STONKS_POSTVOTE_SUITES) | tr ' ' '|'))\.ts$$" && \ + PREVOTE_SUITES=$$(ls test/integration/*.ts | grep -vE "$$POSTVOTE_PATTERN" || true) && \ + { [ -n "$$PREVOTE_SUITES" ] || { echo "No pre-vote suites left after excluding: $(STONKS_POSTVOTE_SUITES)" >&2; exit 1; }; } && \ + RPC_URL=$(ETH_RPC_URL) npx hardhat test $$PREVOTE_SUITES --network localhost test-stonks-integration-postvote: ci-prepare-environment cd $(STONKS_DIR) && \ From 1640c61c0a571154fa72de7e4d16f7f68a90abe8 Mon Sep 17 00:00:00 2001 From: phonktown Date: Tue, 4 Aug 2026 00:27:46 +0300 Subject: [PATCH 20/24] feat(Tests): pick an unclaimed source operator for consolidation pair --- tests/regression/test_easy_track_factories.py | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/regression/test_easy_track_factories.py b/tests/regression/test_easy_track_factories.py index 368c83b6..81df03a9 100644 --- a/tests/regression/test_easy_track_factories.py +++ b/tests/regression/test_easy_track_factories.py @@ -1175,11 +1175,26 @@ def _encode_nor_external_operator_data(module_id, node_operator_id): def _find_active_source_operator(): + """First active curated operator that no MetaRegistry group holds as an external operator. + + CM keeps claiming them, and a claimed one reverts with `AlreadyUsedAsExternalOperator`. + """ nor = contracts.node_operators_registry - for no_id in range(nor.getNodeOperatorsCount()): - if nor.getNodeOperatorIsActive(no_id): - return no_id - raise AssertionError("No active operator found in the curated (source) module") + meta_registry = contracts.cm_meta_registry + + claimed_external_operators = set() + for group_id in range(meta_registry.getOperatorGroupsCount()): + for (external_operator_data,) in meta_registry.getOperatorGroup(group_id)["externalOperators"]: + claimed_external_operators.add(bytes(external_operator_data)) + + for node_operator_id in range(nor.getNodeOperatorsCount()): + if not nor.getNodeOperatorIsActive(node_operator_id): + continue + external_operator_data = _encode_nor_external_operator_data(CONSOLIDATION_SOURCE_MODULE_ID, node_operator_id) + if external_operator_data in claimed_external_operators: + continue + return node_operator_id + raise AssertionError("No unclaimed active operator found in the curated (source) module") def _split_operator_shares(count): From ca290c7b1b29146cf3fd8784345385f4a1c9b122 Mon Sep 17 00:00:00 2001 From: phonktown Date: Tue, 4 Aug 2026 16:17:54 +0300 Subject: [PATCH 21/24] feat(Tests): adjust tests --- .../workflows/stonks_integration_tests.yml | 5 +- configs/config_mainnet.py | 1 + interfaces/Stonks.json | 1169 +++++++++++++++++ scripts/upgrade_2026_08_05.py | 2 +- tests/acceptance/test_locator.py | 8 + tests/test_2026_08_05.py | 70 +- utils/config.py | 4 + 7 files changed, 1252 insertions(+), 7 deletions(-) create mode 100644 interfaces/Stonks.json diff --git a/.github/workflows/stonks_integration_tests.yml b/.github/workflows/stonks_integration_tests.yml index 48d17e1c..e6a83d7a 100644 --- a/.github/workflows/stonks_integration_tests.yml +++ b/.github/workflows/stonks_integration_tests.yml @@ -7,9 +7,8 @@ on: workflow_dispatch: env: - # Stonks branch carrying the NEST V2 integration tests. - # TODO: switch to `main/master` once the tests land there. - STONKS_REF: feature/dao-898-nest-v2-develop-the-protocol + # Stonks branch the NEST V2 integration tests are run from. + STONKS_REF: main jobs: run-tests: diff --git a/configs/config_mainnet.py b/configs/config_mainnet.py index 3e4869b7..04c18e75 100644 --- a/configs/config_mainnet.py +++ b/configs/config_mainnet.py @@ -664,6 +664,7 @@ OPERATOR_GRID = "0xC69685E89Cefc327b43B7234AC646451B27c544d" LAZY_ORACLE = "0x5DB427080200c235F2Ae8Cd17A7be87921f7AD6c" PREDEPOSIT_GUARANTEE = "0xF4bF42c6D6A0E38825785048124DBAD6c9eaaac3" +VAULT_FACTORY = "0x02Ca7772FF14a9F6c1a08aF385aA96bb1b34175A" VAULTS_ADAPTER = "0x28F9Ac198C4E0FA6A9Ad2c2f97CB38F1A3120f27" GATE_SEAL_V3 = "0x881dAd714679A6FeaA636446A0499101375A365c" STAKING_VAULT_BEACON = "0x5FbE8cEf9CCc56ad245736D3C5bAf82ad54Ca789" diff --git a/interfaces/Stonks.json b/interfaces/Stonks.json new file mode 100644 index 00000000..aa240e5a --- /dev/null +++ b/interfaces/Stonks.json @@ -0,0 +1,1169 @@ +[ + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "admin", + "type": "address" + }, + { + "internalType": "address", + "name": "agent", + "type": "address" + }, + { + "internalType": "address", + "name": "manager", + "type": "address" + }, + { + "internalType": "address", + "name": "tokenFrom", + "type": "address" + }, + { + "internalType": "address", + "name": "tokenTo", + "type": "address" + }, + { + "internalType": "address", + "name": "amountConverter", + "type": "address" + }, + { + "internalType": "address", + "name": "orderSample", + "type": "address" + }, + { + "internalType": "uint256", + "name": "orderDurationInSeconds", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "marginInBasisPoints", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "priceToleranceInBasisPoints", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "maxImprovementInBasisPoints", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "allowPartialFill", + "type": "bool" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "internalType": "struct Stonks.InitParams", + "name": "initParams_", + "type": "tuple" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "admin", + "type": "address" + } + ], + "name": "InvalidAdminAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "agent", + "type": "address" + } + ], + "name": "InvalidAgentAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "InvalidAmount", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "amountConverter", + "type": "address" + } + ], + "name": "InvalidAmountConverterAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "min", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "max", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "received", + "type": "uint256" + } + ], + "name": "InvalidOrderDuration", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "orderSample", + "type": "address" + } + ], + "name": "InvalidOrderSampleAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "tokenFrom", + "type": "address" + } + ], + "name": "InvalidTokenFromAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "tokenTo", + "type": "address" + } + ], + "name": "InvalidTokenToAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "limit", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "received", + "type": "uint256" + } + ], + "name": "MarginOverflowsAllowedLimit", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "limit", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "received", + "type": "uint256" + } + ], + "name": "MaxImprovementOverflowsAllowedLimit", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "min", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "received", + "type": "uint256" + } + ], + "name": "MinimumPossibleBalanceNotMet", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "NotAdmin", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "NotAdminOrManager", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "NotEmergencyOperator", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "limit", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "received", + "type": "uint256" + } + ], + "name": "PriceToleranceOverflowsAllowedLimit", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "available", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "requested", + "type": "uint256" + } + ], + "name": "SellAmountExceedsBalance", + "type": "error" + }, + { + "inputs": [], + "name": "StonksKilled", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "TokenFromNotSupported", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "TokenToNotSupported", + "type": "error" + }, + { + "inputs": [], + "name": "TokensCannotBeSame", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "admin", + "type": "address" + } + ], + "name": "AdminSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "bool", + "name": "allowPartialFill", + "type": "bool" + } + ], + "name": "AllowPartialFillSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "amountConverter", + "type": "address" + } + ], + "name": "AmountConverterSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ERC1155Recovered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ERC20Recovered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + } + ], + "name": "ERC721Recovered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "emergencyOperator", + "type": "address" + } + ], + "name": "EmergencyOperatorSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "EtherRecovered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "by", + "type": "address" + } + ], + "name": "KillEngaged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "manager", + "type": "address" + } + ], + "name": "ManagerSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "marginInBasisPoints", + "type": "uint256" + } + ], + "name": "MarginInBasisPointsSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "maxImprovementInBasisPoints", + "type": "uint256" + } + ], + "name": "MaxImprovementInBasisPointsSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "orderContract", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "minBuyAmount", + "type": "uint256" + } + ], + "name": "OrderContractCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "orderDurationInSeconds", + "type": "uint256" + } + ], + "name": "OrderDurationInSecondsSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "orderSample", + "type": "address" + } + ], + "name": "OrderSampleSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Paused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "priceToleranceInBasisPoints", + "type": "uint256" + } + ], + "name": "PriceToleranceInBasisPointsSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ReceiverSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "by", + "type": "address" + } + ], + "name": "SignaturesPaused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "by", + "type": "address" + } + ], + "name": "SignaturesUnpaused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "tokenFrom", + "type": "address" + } + ], + "name": "TokenFromSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "tokenTo", + "type": "address" + } + ], + "name": "TokenToSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Unpaused", + "type": "event" + }, + { + "inputs": [], + "name": "ADMIN", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "AGENT", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ALLOW_PARTIAL_FILL", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "AMOUNT_CONVERTER", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MARGIN_DIFFERENCE_IN_BASIS_POINTS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MARGIN_IN_BASIS_POINTS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_IMPROVEMENT_IN_BASIS_POINTS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ORDER_DURATION_IN_SECONDS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ORDER_SAMPLE", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "PRICE_TOLERANCE_IN_BASIS_POINTS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RECEIVER", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "TOKEN_FROM", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "TOKEN_TO", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "areSignaturesPaused", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "emergencyOperator", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount_", + "type": "uint256" + } + ], + "name": "estimateTradeOutput", + "outputs": [ + { + "internalType": "uint256", + "name": "estimatedTradeOutput", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "estimateTradeOutputFromCurrentBalance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getMaxImprovementBps", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getOrderParameters", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getPriceTolerance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "isCreationPaused", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "isKilled", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "killSwitch", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "manager", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pauseCreation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "pauseSignatures", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paused", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "minBuyAmount_", + "type": "uint256" + } + ], + "name": "placeOrder", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "sellAmount_", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "minBuyAmount_", + "type": "uint256" + } + ], + "name": "placeOrderWithAmount", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token_", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId_", + "type": "uint256" + } + ], + "name": "recoverERC1155", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token_", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount_", + "type": "uint256" + } + ], + "name": "recoverERC20", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token_", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId_", + "type": "uint256" + } + ], + "name": "recoverERC721", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "recoverEther", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "emergencyOperator_", + "type": "address" + } + ], + "name": "setEmergencyOperator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "manager_", + "type": "address" + } + ], + "name": "setManager", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "unpauseCreation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "unpauseSignatures", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/scripts/upgrade_2026_08_05.py b/scripts/upgrade_2026_08_05.py index 67b25441..dcfb1f4f 100644 --- a/scripts/upgrade_2026_08_05.py +++ b/scripts/upgrade_2026_08_05.py @@ -91,7 +91,7 @@ # NEST Activation, LOL stablecoins ET Payment Factories Activation, CSM Update Share Limits ET Factory Replacement 1. **Launch NEST (Automated LDO Buyback and Liquidity Provisioning)** in [treasury-only mode](https://research.lido.fi/t/liquid-buybacks-nest-execution-with-ldo-wsteth-liquidity/10894/109#p-25979-h-3-launch-mode-treasury-only-5), with all purchased LDO sent directly to the DAO Treasury. Under the [DAO-approved](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x022e901a6368573d18b150eecda563dd2ee17ad2aa6a0ef9772151cc7ba55187) cumulative surplus model described in [LIP-36](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-36.md#surplus-mechanics), NEST converts a fixed portion of staking revenue above the operating baseline into LDO via CoW Swap. Audit & deployment verification: [Ack3](https://github.com/lidofinance/audits/blob/main/Ack3%20Lido%20NEST%20Audit%20Report%2007-2026.pdf). Items 1.1 - 1.4, 2 - 9. 2. **Add LOL stablecoins Easy Track factories for payments and adding/removing allowed recipients**, with a limit of $8M per 6 months, [as per Snapshot decision](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x863859d857c7429a0dcb85a4b324de803e2f66ddd8f50e4c2f04a31c35c6ae6f). Items 1.5, 1.6, 12 - 14. -3. **Replace CSM Update Share Limits Easy Track factory** to correct the per-motion change cap to 0.5%, [as proposed on the forum](https://research.lido.fi/t/community-staking-module/5917/231). Items 10, 11. +3. **Replace CSM Update Share Limits Easy Track factory** to correct the per-motion change cap to 0.5%, [as proposed on the forum](https://research.lido.fi/t/community-staking-module/5917/231). Audit & deployment verification: [MixBytes](https://github.com/lidofinance/audits/blob/main/MixBytes%20Lido%20Easy%20Track%20Factories%20(SRv3%20CSMv3%20CMv2)%20Security%20Audit%20Report%2007-2026.pdf). Items 10, 11. """ diff --git a/tests/acceptance/test_locator.py b/tests/acceptance/test_locator.py index 1c678255..f0e01273 100644 --- a/tests/acceptance/test_locator.py +++ b/tests/acceptance/test_locator.py @@ -32,6 +32,14 @@ def test_addresses(contract): assert contract.topUpGateway() == contracts.top_up_gateway assert contract.consolidationGateway() == contracts.consolidation_gateway assert contract.triggerableWithdrawalsGateway() == contracts.triggerable_withdrawals_gateway + assert contract.wstETH() == contracts.wsteth + assert contract.validatorExitDelayVerifier() == contracts.validator_exit_verifier + assert contract.accounting() == contracts.accounting + assert contract.vaultHub() == contracts.vault_hub + assert contract.vaultFactory() == contracts.vault_factory + assert contract.predepositGuarantee() == contracts.predeposit_guarantee + assert contract.operatorGrid() == contracts.operator_grid + assert contract.lazyOracle() == contracts.lazy_oracle assert contract.coreComponents() == ( contracts.execution_layer_rewards_vault, diff --git a/tests/test_2026_08_05.py b/tests/test_2026_08_05.py index 2d32d049..1bbaf588 100644 --- a/tests/test_2026_08_05.py +++ b/tests/test_2026_08_05.py @@ -126,6 +126,26 @@ ONE_DAY = 86400 +# --- NEST launch parameters the omnibus relies on but does not set itself --- +# BuybackExecutor order bounds +BUYBACK_MIN_ALLOWED_ORDER_AMOUNT = ETH(1) +BUYBACK_MAX_ALLOWED_ORDER_AMOUNT = ETH(20) +# BuybackAllocator spending caps +BUYBACK_DAILY_CAP_USD = 50_000 * 10**18 +BUYBACK_YEARLY_CAP_USD = 10_000_000 * 10**18 +BUYBACK_MIN_SPEND_PER_CALL_USD = 1_000 * 10**18 +BUYBACK_MIN_STETH_PRICE_USD = 0 # no price floor +BUYBACK_RESERVE_DAILY_RATE_USD = 109_589 * 10**18 # the LIP-36 operating baseline, ~$40M per year +BUYBACK_SURPLUS_SHARE_BP = 5_000 # 50% of the surplus above the baseline + +# --- treasury-mode Stonks 0xb368586CB980895E51e1D82102E63b3F69d3F151 --- +STONKS_ORDER_DURATION_SECONDS = 1_800 # 30 minutes +STONKS_MARGIN_BP = 110 +STONKS_PRICE_TOLERANCE_BP = 550 +STONKS_MAX_IMPROVEMENT_BP = 1_000 +STONKS_ORDER_SAMPLE = "0x2569633AdB492ca9327cb7433277aa7c68D69e28" +STONKS_AMOUNT_CONVERTER = "0x70dA04C5D0f325F5AF1426dE6672BF2424B4593d" + # --- NEST buyback scenario params --- QUOTE_DENOMINATION_ETH = 1 TOTAL_BASIS_POINTS = 10_000 @@ -162,7 +182,7 @@ EXPECTED_DG_EVENTS_FROM_AGENT = 6 EXPECTED_DG_EVENTS_COUNT = 1 -IPFS_DESCRIPTION_HASH = "bafkreiea5mkaprjkxfsxjv7vp6aireshqbvpy3iy5tcvwwhopujv6p25cy" +IPFS_DESCRIPTION_HASH = "bafkreiefjrubwk5hhikhc3s7wkivju3xpwzfanr5enljzimvxgrrmp6m7a" # ============================================================================ @@ -184,10 +204,11 @@ def validate_manager_set_event(event: EventDict, manager: str, emitted_by: str) _assert_emitted_by(e, emitted_by) -def validate_stonks_set_event(event: EventDict, new_stonks: str, emitted_by: str) -> None: +def validate_stonks_set_event(event: EventDict, new_stonks: str, lp_mode_enabled: bool, emitted_by: str) -> None: validate_events_chain([e.name for e in event], ["LogScriptCall", "StonksAndOperatingModeSet"]) e = _single_event(event, "StonksAndOperatingModeSet") assert convert.to_address(e["newStonks"]) == convert.to_address(new_stonks), "Wrong stonks set" + assert e["newLpModeEnabled"] == lp_mode_enabled, "Wrong operating mode set" _assert_emitted_by(e, emitted_by) @@ -272,6 +293,7 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g oracle_router = interface.OracleRouter(ORACLE_ROUTER) buyback_executor = interface.BuybackExecutor(BUYBACK_EXECUTOR) buyback_allocator = interface.BuybackAllocator(BUYBACK_ALLOCATOR) + buyback_stonks = interface.Stonks(BUYBACK_STONKS_TREASURY) new_token_rate_notifier = interface.TokenRateNotifierV2(NEW_TOKEN_RATE_NOTIFIER) old_token_rate_notifier = interface.TokenRateNotifier(OLD_TOKEN_RATE_NOTIFIER) lido_locator_proxy = interface.OssifiableProxy(LIDO_LOCATOR) @@ -327,6 +349,7 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g assert oracle_router.manager() != TMC, "OracleRouter manager already set to TMC" # vote item 3 assert buyback_executor.stonks() == ZERO_ADDRESS, "BuybackExecutor stonks already set" + assert buyback_executor.stonksOrderDurationSeconds() == 0, "BuybackExecutor already cached an order duration" # vote item 4 assert not buyback_executor.hasRole(ALLOCATOR_ROLE, BUYBACK_ALLOCATOR) assert buyback_executor.getRoleMemberCount(ALLOCATOR_ROLE) == 0 @@ -344,6 +367,39 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g assert buyback_allocator.getRoleMemberCount(MANAGER_ROLE) == 0 # vote item 9 assert buyback_allocator.activationTS() == 0, "BuybackAllocator already activated" + # vote items 3-9: the buyback caps and wiring the launch inherits from the deployment. + # LP-mode parameters are left out — this launch is treasury-only (see lpModeEnabled below). + assert buyback_executor.minAllowedOrderAmount() == BUYBACK_MIN_ALLOWED_ORDER_AMOUNT + assert buyback_executor.maxAllowedOrderAmount() == BUYBACK_MAX_ALLOWED_ORDER_AMOUNT + assert buyback_executor.STETH() == contracts.lido + assert buyback_executor.LDO() == contracts.ldo_token + assert buyback_executor.TREASURY() == AGENT + assert not buyback_executor.paused(), "BuybackExecutor is paused" + assert buyback_allocator.dailyCapUSD() == BUYBACK_DAILY_CAP_USD + assert buyback_allocator.yearlyCapUSD() == BUYBACK_YEARLY_CAP_USD + assert buyback_allocator.minSpendPerCallUSD() == BUYBACK_MIN_SPEND_PER_CALL_USD + assert buyback_allocator.minStEthPriceUSD() == BUYBACK_MIN_STETH_PRICE_USD + assert buyback_allocator.reserveDailyRateUSD() == BUYBACK_RESERVE_DAILY_RATE_USD + assert buyback_allocator.surplusShareBP() == BUYBACK_SURPLUS_SHARE_BP + assert buyback_allocator.executor() == BUYBACK_EXECUTOR + assert buyback_allocator.TREASURY() == AGENT + # vote item 3: the treasury-mode Stonks as deployed — trades stETH for LDO, sends the LDO + # and any recovered assets to the Treasury, and is driven by the executor + assert buyback_stonks.TOKEN_FROM() == contracts.lido + assert buyback_stonks.TOKEN_TO() == contracts.ldo_token + assert buyback_stonks.RECEIVER() == AGENT + assert buyback_stonks.AGENT() == AGENT + assert buyback_stonks.manager() == BUYBACK_EXECUTOR + assert buyback_stonks.ADMIN() == VOTING + assert buyback_stonks.ORDER_SAMPLE() == STONKS_ORDER_SAMPLE + assert buyback_stonks.AMOUNT_CONVERTER() == STONKS_AMOUNT_CONVERTER + assert buyback_stonks.ORDER_DURATION_IN_SECONDS() == STONKS_ORDER_DURATION_SECONDS + assert buyback_stonks.MARGIN_IN_BASIS_POINTS() == STONKS_MARGIN_BP + assert buyback_stonks.PRICE_TOLERANCE_IN_BASIS_POINTS() == STONKS_PRICE_TOLERANCE_BP + assert buyback_stonks.MAX_IMPROVEMENT_IN_BASIS_POINTS() == STONKS_MAX_IMPROVEMENT_BP + assert buyback_stonks.ALLOW_PARTIAL_FILL() + assert not buyback_stonks.isCreationPaused(), "treasury-mode Stonks order creation is paused" + assert not buyback_stonks.areSignaturesPaused(), "treasury-mode Stonks signatures are paused" # vote items 10-11 initial_factories = easy_track.getEVMScriptFactories() assert OLD_UPDATE_STAKING_MODULE_SHARE_LIMITS_FACTORY in initial_factories @@ -404,6 +460,12 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g assert oracle_router.manager() == TMC, "OracleRouter manager not set to TMC" # vote item 3 assert buyback_executor.stonks() == BUYBACK_STONKS_TREASURY, "BuybackExecutor stonks not set" + # setStonks only accepts a Stonks whose RECEIVER is either the executor (LP mode) or the + # Treasury (treasury mode), so a false lpModeEnabled is what pins the LDO proceeds to the Treasury + assert not buyback_executor.lpModeEnabled(), "BuybackExecutor launched in LP mode" + assert ( + buyback_executor.stonksOrderDurationSeconds() == STONKS_ORDER_DURATION_SECONDS + ), "BuybackExecutor did not cache the Stonks order duration" # vote item 4 assert buyback_executor.hasRole(ALLOCATOR_ROLE, BUYBACK_ALLOCATOR), "ALLOCATOR_ROLE not granted to allocator" assert buyback_executor.getRoleMemberCount(ALLOCATOR_ROLE) == 1, "extra ALLOCATOR_ROLE holder on the executor" @@ -481,7 +543,9 @@ def test_vote(helpers, accounts, ldo_holder, vote_ids_from_env, stranger, dual_g # vote item 2 validate_manager_set_event(vote_events[1], manager=TMC, emitted_by=ORACLE_ROUTER) # vote item 3 - validate_stonks_set_event(vote_events[2], new_stonks=BUYBACK_STONKS_TREASURY, emitted_by=BUYBACK_EXECUTOR) + validate_stonks_set_event( + vote_events[2], new_stonks=BUYBACK_STONKS_TREASURY, lp_mode_enabled=False, emitted_by=BUYBACK_EXECUTOR + ) # vote item 4 validate_grant_role_event( vote_events[3], role=ALLOCATOR_ROLE, grant_to=BUYBACK_ALLOCATOR, sender=VOTING, diff --git a/utils/config.py b/utils/config.py index cec055ad..6166fc8e 100644 --- a/utils/config.py +++ b/utils/config.py @@ -370,6 +370,10 @@ def lazy_oracle(self) -> interface.LazyOracle: def predeposit_guarantee(self) -> interface.PredepositGuarantee: return interface.PredepositGuarantee(PREDEPOSIT_GUARANTEE) + @property + def vault_factory(self) -> interface.VaultFactory: + return interface.VaultFactory(VAULT_FACTORY) + @property def staking_vault_beacon(self) -> interface.UpgradeableBeacon: return interface.UpgradeableBeacon(STAKING_VAULT_BEACON) From eaa450ac7ee59af60e9ede10772bd27d8dd49e81 Mon Sep 17 00:00:00 2001 From: phonktown Date: Tue, 4 Aug 2026 17:38:22 +0300 Subject: [PATCH 22/24] feat(Vote): update IPFS --- scripts/upgrade_2026_08_05.py | 2 +- tests/test_2026_08_05.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/upgrade_2026_08_05.py b/scripts/upgrade_2026_08_05.py index dcfb1f4f..23c1e1fb 100644 --- a/scripts/upgrade_2026_08_05.py +++ b/scripts/upgrade_2026_08_05.py @@ -89,7 +89,7 @@ # ipfs description IPFS_DESCRIPTION = """ # NEST Activation, LOL stablecoins ET Payment Factories Activation, CSM Update Share Limits ET Factory Replacement -1. **Launch NEST (Automated LDO Buyback and Liquidity Provisioning)** in [treasury-only mode](https://research.lido.fi/t/liquid-buybacks-nest-execution-with-ldo-wsteth-liquidity/10894/109#p-25979-h-3-launch-mode-treasury-only-5), with all purchased LDO sent directly to the DAO Treasury. Under the [DAO-approved](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x022e901a6368573d18b150eecda563dd2ee17ad2aa6a0ef9772151cc7ba55187) cumulative surplus model described in [LIP-36](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-36.md#surplus-mechanics), NEST converts a fixed portion of staking revenue above the operating baseline into LDO via CoW Swap. Audit & deployment verification: [Ack3](https://github.com/lidofinance/audits/blob/main/Ack3%20Lido%20NEST%20Audit%20Report%2007-2026.pdf). Items 1.1 - 1.4, 2 - 9. +1. **Launch NEST (Automated LDO Buyback and Liquidity Provisioning)** in [treasury-only mode](https://research.lido.fi/t/liquid-buybacks-nest-execution-with-ldo-wsteth-liquidity/10894/109#p-25979-h-3-launch-mode-treasury-only-5), with all purchased LDO sent directly to the DAO Treasury. Under the [DAO-approved](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x022e901a6368573d18b150eecda563dd2ee17ad2aa6a0ef9772151cc7ba55187) cumulative surplus model described in [LIP-36](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-36.md#surplus-mechanics), NEST converts a fixed portion of staking revenue above the operating baseline into LDO via CoW Swap. Ack3 audits & deployment verification: [NEST](https://github.com/lidofinance/audits/blob/main/Ack3%20Lido%20NEST%20Audit%20Report%2007-2026.pdf), [Stonks 2](https://github.com/lidofinance/audits/blob/main/Ackee%20Blockchain%20Stonks%202%20Audit%20Report%2012-25.pdf). Items 1.1 - 1.4, 2 - 9. 2. **Add LOL stablecoins Easy Track factories for payments and adding/removing allowed recipients**, with a limit of $8M per 6 months, [as per Snapshot decision](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x863859d857c7429a0dcb85a4b324de803e2f66ddd8f50e4c2f04a31c35c6ae6f). Items 1.5, 1.6, 12 - 14. 3. **Replace CSM Update Share Limits Easy Track factory** to correct the per-motion change cap to 0.5%, [as proposed on the forum](https://research.lido.fi/t/community-staking-module/5917/231). Audit & deployment verification: [MixBytes](https://github.com/lidofinance/audits/blob/main/MixBytes%20Lido%20Easy%20Track%20Factories%20(SRv3%20CSMv3%20CMv2)%20Security%20Audit%20Report%2007-2026.pdf). Items 10, 11. """ diff --git a/tests/test_2026_08_05.py b/tests/test_2026_08_05.py index 1bbaf588..cc3b8a6f 100644 --- a/tests/test_2026_08_05.py +++ b/tests/test_2026_08_05.py @@ -182,7 +182,7 @@ EXPECTED_DG_EVENTS_FROM_AGENT = 6 EXPECTED_DG_EVENTS_COUNT = 1 -IPFS_DESCRIPTION_HASH = "bafkreiefjrubwk5hhikhc3s7wkivju3xpwzfanr5enljzimvxgrrmp6m7a" +IPFS_DESCRIPTION_HASH = "bafkreicltgxsuj5y7g5jv3iuknqsdpudc4vrnr2w3lqudr3fywvj6hrog4" # ============================================================================ From ad68fbed0fc36d1b1f2fc303a24e3dce30b8f00f Mon Sep 17 00:00:00 2001 From: phonktown Date: Tue, 4 Aug 2026 18:40:39 +0300 Subject: [PATCH 23/24] feat(Vote): update IPFS --- scripts/upgrade_2026_08_05.py | 2 +- tests/test_2026_08_05.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/upgrade_2026_08_05.py b/scripts/upgrade_2026_08_05.py index 23c1e1fb..5f6407c3 100644 --- a/scripts/upgrade_2026_08_05.py +++ b/scripts/upgrade_2026_08_05.py @@ -89,7 +89,7 @@ # ipfs description IPFS_DESCRIPTION = """ # NEST Activation, LOL stablecoins ET Payment Factories Activation, CSM Update Share Limits ET Factory Replacement -1. **Launch NEST (Automated LDO Buyback and Liquidity Provisioning)** in [treasury-only mode](https://research.lido.fi/t/liquid-buybacks-nest-execution-with-ldo-wsteth-liquidity/10894/109#p-25979-h-3-launch-mode-treasury-only-5), with all purchased LDO sent directly to the DAO Treasury. Under the [DAO-approved](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x022e901a6368573d18b150eecda563dd2ee17ad2aa6a0ef9772151cc7ba55187) cumulative surplus model described in [LIP-36](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-36.md#surplus-mechanics), NEST converts a fixed portion of staking revenue above the operating baseline into LDO via CoW Swap. Ack3 audits & deployment verification: [NEST](https://github.com/lidofinance/audits/blob/main/Ack3%20Lido%20NEST%20Audit%20Report%2007-2026.pdf), [Stonks 2](https://github.com/lidofinance/audits/blob/main/Ackee%20Blockchain%20Stonks%202%20Audit%20Report%2012-25.pdf). Items 1.1 - 1.4, 2 - 9. +1. **Launch NEST (Automated LDO Buyback and Liquidity Provisioning)** in [treasury-only mode](https://research.lido.fi/t/liquid-buybacks-nest-execution-with-ldo-wsteth-liquidity/10894/109#p-25979-h-3-launch-mode-treasury-only-5), with all purchased LDO sent directly to the DAO Treasury. Under the [DAO-approved](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x022e901a6368573d18b150eecda563dd2ee17ad2aa6a0ef9772151cc7ba55187) cumulative surplus model described in [LIP-36](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-36.md#surplus-mechanics), NEST converts a fixed portion of staking revenue above the operating baseline into LDO via CoW Swap. Audits & deployment verification: [Ack3 NEST](https://github.com/lidofinance/audits/blob/main/Ack3%20Lido%20NEST%20Audit%20Report%2007-2026.pdf), [Ack3 Stonks 2](https://github.com/lidofinance/audits/blob/main/Ackee%20Blockchain%20Stonks%202%20Audit%20Report%2012-25.pdf), [MixBytes TokenRateNotifier](https://github.com/lidofinance/audits/blob/main/MixBytes%20Lido%20TokenRateNotifier%20Security%20Audit%20Report%2008-2026.pdf). Items 1.1 - 1.4, 2 - 9. 2. **Add LOL stablecoins Easy Track factories for payments and adding/removing allowed recipients**, with a limit of $8M per 6 months, [as per Snapshot decision](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x863859d857c7429a0dcb85a4b324de803e2f66ddd8f50e4c2f04a31c35c6ae6f). Items 1.5, 1.6, 12 - 14. 3. **Replace CSM Update Share Limits Easy Track factory** to correct the per-motion change cap to 0.5%, [as proposed on the forum](https://research.lido.fi/t/community-staking-module/5917/231). Audit & deployment verification: [MixBytes](https://github.com/lidofinance/audits/blob/main/MixBytes%20Lido%20Easy%20Track%20Factories%20(SRv3%20CSMv3%20CMv2)%20Security%20Audit%20Report%2007-2026.pdf). Items 10, 11. """ diff --git a/tests/test_2026_08_05.py b/tests/test_2026_08_05.py index cc3b8a6f..3dffdd75 100644 --- a/tests/test_2026_08_05.py +++ b/tests/test_2026_08_05.py @@ -182,7 +182,7 @@ EXPECTED_DG_EVENTS_FROM_AGENT = 6 EXPECTED_DG_EVENTS_COUNT = 1 -IPFS_DESCRIPTION_HASH = "bafkreicltgxsuj5y7g5jv3iuknqsdpudc4vrnr2w3lqudr3fywvj6hrog4" +IPFS_DESCRIPTION_HASH = "bafkreiey62spdcfej5pnvoz22czz3frmmagc2v2i5xwrzq6j4qhtdpwwoi" # ============================================================================ From d00e51a68eea97f208596a218c79e10e63a121f1 Mon Sep 17 00:00:00 2001 From: phonktown Date: Tue, 4 Aug 2026 19:19:31 +0300 Subject: [PATCH 24/24] feat(Vote): update IPFS --- scripts/upgrade_2026_08_05.py | 8 ++++---- tests/test_2026_08_05.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/upgrade_2026_08_05.py b/scripts/upgrade_2026_08_05.py index 5f6407c3..b5171e25 100644 --- a/scripts/upgrade_2026_08_05.py +++ b/scripts/upgrade_2026_08_05.py @@ -84,13 +84,13 @@ OBSERVER_KIND_NO_ARGS = 0 OBSERVER_KIND_WITH_ARGS = 1 -DG_PROPOSAL_METADATA = "Activate NEST, add LOL stablecoins Easy Track payment factories (limit of $8M per 6 months), replace CSM Update Share Limits Easy Track factory" +DG_PROPOSAL_METADATA = "Activate NEST and add LOL stablecoins Easy Track factories to add/remove allowed recipients" # ipfs description IPFS_DESCRIPTION = """ # NEST Activation, LOL stablecoins ET Payment Factories Activation, CSM Update Share Limits ET Factory Replacement -1. **Launch NEST (Automated LDO Buyback and Liquidity Provisioning)** in [treasury-only mode](https://research.lido.fi/t/liquid-buybacks-nest-execution-with-ldo-wsteth-liquidity/10894/109#p-25979-h-3-launch-mode-treasury-only-5), with all purchased LDO sent directly to the DAO Treasury. Under the [DAO-approved](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x022e901a6368573d18b150eecda563dd2ee17ad2aa6a0ef9772151cc7ba55187) cumulative surplus model described in [LIP-36](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-36.md#surplus-mechanics), NEST converts a fixed portion of staking revenue above the operating baseline into LDO via CoW Swap. Audits & deployment verification: [Ack3 NEST](https://github.com/lidofinance/audits/blob/main/Ack3%20Lido%20NEST%20Audit%20Report%2007-2026.pdf), [Ack3 Stonks 2](https://github.com/lidofinance/audits/blob/main/Ackee%20Blockchain%20Stonks%202%20Audit%20Report%2012-25.pdf), [MixBytes TokenRateNotifier](https://github.com/lidofinance/audits/blob/main/MixBytes%20Lido%20TokenRateNotifier%20Security%20Audit%20Report%2008-2026.pdf). Items 1.1 - 1.4, 2 - 9. -2. **Add LOL stablecoins Easy Track factories for payments and adding/removing allowed recipients**, with a limit of $8M per 6 months, [as per Snapshot decision](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x863859d857c7429a0dcb85a4b324de803e2f66ddd8f50e4c2f04a31c35c6ae6f). Items 1.5, 1.6, 12 - 14. +1. **Launch NEST (Automated LDO Buyback and Liquidity Provisioning)** in [treasury-only mode](https://research.lido.fi/t/liquid-buybacks-nest-execution-with-ldo-wsteth-liquidity/10894/109#p-25979-h-3-launch-mode-treasury-only-5), with all purchased LDO sent directly to the DAO Treasury. Under the cumulative surplus model described in [LIP-36](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-36.md#surplus-mechanics), NEST converts a fixed portion of staking revenue above the operating baseline into LDO via CoW Swap. [Snapshot decision](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x022e901a6368573d18b150eecda563dd2ee17ad2aa6a0ef9772151cc7ba55187). Audits & deployment verification: [Ack3 NEST](https://github.com/lidofinance/audits/blob/main/Ack3%20Lido%20NEST%20Audit%20Report%2007-2026.pdf), [Ack3 Stonks 2](https://github.com/lidofinance/audits/blob/main/Ackee%20Blockchain%20Stonks%202%20Audit%20Report%2012-25.pdf), [MixBytes TokenRateNotifier](https://github.com/lidofinance/audits/blob/main/MixBytes%20Lido%20TokenRateNotifier%20Security%20Audit%20Report%2008-2026.pdf). Items 1.1.1 - 1.1.4, 2 - 9. +2. **Add LOL stablecoins Easy Track factories for payments and adding/removing allowed recipients**, with a limit of $8M per 6 months, [as per Snapshot decision](https://snapshot.box/#/s:lido-snapshot.eth/proposal/0x863859d857c7429a0dcb85a4b324de803e2f66ddd8f50e4c2f04a31c35c6ae6f). Items 1.1.5, 1.1.6, 12 - 14. 3. **Replace CSM Update Share Limits Easy Track factory** to correct the per-motion change cap to 0.5%, [as proposed on the forum](https://research.lido.fi/t/community-staking-module/5917/231). Audit & deployment verification: [MixBytes](https://github.com/lidofinance/audits/blob/main/MixBytes%20Lido%20Easy%20Track%20Factories%20(SRv3%20CSMv3%20CMv2)%20Security%20Audit%20Report%2007-2026.pdf). Items 10, 11. """ @@ -155,7 +155,7 @@ def get_vote_items() -> Tuple[List[str], List[Tuple[str, str]]]: vote_desc_items, call_script_items = zip( ( - "1. Submit a Dual Governance proposal to activate NEST, add LOL stablecoins Easy Track payment factories (limit of $8M per 6 months), replace CSM Update Share Limits Easy Track factory", + "1. Submit a Dual Governance proposal to activate NEST and add LOL stablecoins Easy Track factories to add/remove allowed recipients", dg_call_script[0], ), ( diff --git a/tests/test_2026_08_05.py b/tests/test_2026_08_05.py index 3dffdd75..7e774701 100644 --- a/tests/test_2026_08_05.py +++ b/tests/test_2026_08_05.py @@ -182,7 +182,7 @@ EXPECTED_DG_EVENTS_FROM_AGENT = 6 EXPECTED_DG_EVENTS_COUNT = 1 -IPFS_DESCRIPTION_HASH = "bafkreiey62spdcfej5pnvoz22czz3frmmagc2v2i5xwrzq6j4qhtdpwwoi" +IPFS_DESCRIPTION_HASH = "bafkreictud2nki2d2cuuvrsr7zy5bikjvg67n3qsdqzpmsqgqaq6urpvfu" # ============================================================================