From 2ecf9f17cb5677917e117022558b292290be84b8 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 19 May 2026 21:01:30 +0800 Subject: [PATCH 1/2] Add Gringotts scenario test scripts --- solidity/contracts/mocks/GringottsV2Dummy.sol | 14 + .../gringotts-713714-1779165468088.json | 37 ++ .../scripts/deploy-and-create.example.json | 21 +- solidity/scripts/scenario-tests/README.md | 143 ++++++++ .../scripts/scenario-tests/access-tests.js | 248 +++++++++++++ .../scenario-tests/governance-admin-tests.js | 233 ++++++++++++ solidity/scripts/scenario-tests/lib/common.js | 343 ++++++++++++++++++ .../scripts/scenario-tests/lib/proposals.js | 120 ++++++ .../scripts/scenario-tests/reward-tests.js | 161 ++++++++ .../scenario.config.example.json | 36 ++ .../scenario-tests/staking-operation-tests.js | 272 ++++++++++++++ .../scripts/scenario-tests/upgrade-tests.js | 106 ++++++ .../scripts/scenario-tests/vesting-tests.js | 185 ++++++++++ solidity/test/Gringotts.test.js | 32 ++ 14 files changed, 1941 insertions(+), 10 deletions(-) create mode 100644 solidity/contracts/mocks/GringottsV2Dummy.sol create mode 100644 solidity/deployments/gringotts-713714-1779165468088.json create mode 100644 solidity/scripts/scenario-tests/README.md create mode 100644 solidity/scripts/scenario-tests/access-tests.js create mode 100644 solidity/scripts/scenario-tests/governance-admin-tests.js create mode 100644 solidity/scripts/scenario-tests/lib/common.js create mode 100644 solidity/scripts/scenario-tests/lib/proposals.js create mode 100644 solidity/scripts/scenario-tests/reward-tests.js create mode 100644 solidity/scripts/scenario-tests/scenario.config.example.json create mode 100644 solidity/scripts/scenario-tests/staking-operation-tests.js create mode 100644 solidity/scripts/scenario-tests/upgrade-tests.js create mode 100644 solidity/scripts/scenario-tests/vesting-tests.js diff --git a/solidity/contracts/mocks/GringottsV2Dummy.sol b/solidity/contracts/mocks/GringottsV2Dummy.sol new file mode 100644 index 0000000..c533317 --- /dev/null +++ b/solidity/contracts/mocks/GringottsV2Dummy.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "../Gringotts.sol"; + +contract GringottsV2Dummy is Gringotts { + function dummyVersion() external pure returns (string memory) { + return "gringotts-v2-dummy"; + } + + function dummyNumber() external pure returns (uint256) { + return 2; + } +} diff --git a/solidity/deployments/gringotts-713714-1779165468088.json b/solidity/deployments/gringotts-713714-1779165468088.json new file mode 100644 index 0000000..1169d33 --- /dev/null +++ b/solidity/deployments/gringotts-713714-1779165468088.json @@ -0,0 +1,37 @@ +{ + "deployedAt": "2026-05-19T04:37:48.088Z", + "network": "harbor-shortunbond-testnet", + "chainId": "713714", + "deployer": "0x9eFbcf57C4DE1b750749d840B9fB7CcF03e239Ac", + "configFile": "/Users/xiaoyuchen/repos/gringotts/solidity/scripts/deploy-and-create.example.json", + "implementation": "0xff7b70EE3ddB75C209d5A1c249eC9bC21c4f6aEF", + "factory": "0xEaaF4eF2Ef3E4926c236998301cb1248F6C19687", + "proxy": "0xE79F135F45D571352f7d570Da1E2a295f966A58B", + "createTransaction": "0x769a45778d56e729d5a49ca7f5ed6693ee889756c69ba9579ee8cfe5852ab184", + "params": { + "admins": [ + "0x28fc161d44DeBA46E3d4aD2416dB84044035b02a", + "0x806465dc3e5eF9dC5AFf42a5b8744c94f0ddEc78", + "0x06868083F2B914e52dC09A6e22994EA8ff2FDa49" + ], + "operators": [ + "0xAE9Ed5c5397d74FC5FbF396d0e0de4663b4895B9" + ], + "vestingTimestamps": [ + "1779199200", + "1779242400", + "1779285600" + ], + "vestingAmounts": [ + "1000000000000000000", + "1000000000000000000", + "1000000000000000000" + ], + "unlockDistributionAddress": "0x7fd42b44F08eC90Aa7C82f9C40235Dc66b86C201", + "stakingRewardAddress": "0x6199d949c97e818abd967EC9EcA3e89FFbE92C44", + "maxVotingPeriod": "300", + "adminVotingThresholdPercentage": "50", + "totalAmount": "3000000000000000000", + "vestingTotal": "3000000000000000000" + } +} \ No newline at end of file diff --git a/solidity/scripts/deploy-and-create.example.json b/solidity/scripts/deploy-and-create.example.json index b5066ed..1ca48b7 100644 --- a/solidity/scripts/deploy-and-create.example.json +++ b/solidity/scripts/deploy-and-create.example.json @@ -1,24 +1,25 @@ { "admins": [ - "0x1111111111111111111111111111111111111111", - "0x2222222222222222222222222222222222222222" + "0x28fc161d44DeBA46E3d4aD2416dB84044035b02a", + "0x806465dc3e5eF9dC5AFf42a5b8744c94f0ddEc78", + "0x06868083F2B914e52dC09A6e22994EA8ff2FDa49" ], "operators": [ - "0x3333333333333333333333333333333333333333" + "0xAE9Ed5c5397d74FC5FbF396d0e0de4663b4895B9" ], "vestingTimestamps": [ - 1893456000, - 1924992000, - 1956528000 + 1779199200, + 1779242400, + 1779285600 ], "vestingAmounts": [ "1000000000000000000", "1000000000000000000", "1000000000000000000" ], - "unlockDistributionAddress": "0x4444444444444444444444444444444444444444", - "stakingRewardAddress": "0x5555555555555555555555555555555555555555", - "maxVotingPeriod": 3600, - "adminVotingThresholdPercentage": 75, + "unlockDistributionAddress": "0x7fd42b44F08eC90Aa7C82f9C40235Dc66b86C201", + "stakingRewardAddress": "0x6199d949c97e818abd967EC9EcA3e89FFbE92C44", + "maxVotingPeriod": 300, + "adminVotingThresholdPercentage": 50, "totalAmount": "3000000000000000000" } diff --git a/solidity/scripts/scenario-tests/README.md b/solidity/scripts/scenario-tests/README.md new file mode 100644 index 0000000..62645e8 --- /dev/null +++ b/solidity/scripts/scenario-tests/README.md @@ -0,0 +1,143 @@ +# Gringotts Scenario Scripts + +These scripts exercise the deployed `Gringotts` proxy against the scenario list in `/Users/xiaoyuchen/Downloads/EVM Gringotts Testing.txt`. + +They are review-safe by default. Without `EXECUTE=true`, scripts only run read/static-call checks and skip state-changing scenarios. + +## One-Time Setup + +Compile contracts first so the scripts can load current ABIs, including `GringottsV2Dummy`: + +```bash +cd /Users/xiaoyuchen/repos/gringotts/solidity +npm run compile +``` + +The scripts resolve the proxy address in this order: + +1. `PROXY_ADDRESS` +2. `SCENARIO_CONFIG.proxyAddress` +3. latest `deployments/gringotts-*.json` + +Create an ignored local config for signer keys and validator addresses: + +```bash +cp scripts/scenario-tests/scenario.config.example.json scripts/scenario-tests/scenario.local.json +``` + +Then fill in signer private keys and validator addresses: + +- `adminPrivateKeys`: enough current admin keys to pass proposals. +- `operatorPrivateKey`: a current operator key for staking/withdrawal flows. +- `implementationDeployerPrivateKey`: optional funded key for `upgrade-tests.js`; it does not need admin permission. + +```bash +SCENARIO_CONFIG=scripts/scenario-tests/scenario.local.json \ + npx hardhat --config hardhat.harbor-shortunbond.config.js run scripts/scenario-tests/access-tests.js --network harbor-shortunbond-testnet +``` + +Do not commit `scenario.local.json`. + +## Static Review Mode + +Run scripts without `EXECUTE=true` first. This mode checks read-only state and `staticCall` reverts, then skips mutating scenarios. + +```bash +SCENARIO_CONFIG=scripts/scenario-tests/scenario.local.json \ + npx hardhat --config hardhat.harbor-shortunbond.config.js run scripts/scenario-tests/access-tests.js --network harbor-shortunbond-testnet +``` + +Run all scenario groups in static/review mode: + +```bash +for script in \ + access-tests.js \ + vesting-tests.js \ + staking-operation-tests.js \ + reward-tests.js \ + governance-admin-tests.js \ + upgrade-tests.js +do + SCENARIO_CONFIG=scripts/scenario-tests/scenario.local.json \ + npx hardhat --config hardhat.harbor-shortunbond.config.js run "scripts/scenario-tests/$script" --network harbor-shortunbond-testnet +done +``` + +## Execution Guards + +- `EXECUTE=true`: allow state-changing transactions. +- `ALLOW_DESTRUCTIVE=true`: allow irreversible/destructive checks such as emergency withdrawal. +- `WAIT_FOR_EXPIRY=true`: allow scripts to wait through proposal expiration windows. +- `RUN_STRESS=true`: allow repeated 7-entry unbonding/redelegation stress loops. + +## Mutating Runs + +Run one group at a time when sending transactions. Example: + +```bash +EXECUTE=true \ +SCENARIO_CONFIG=scripts/scenario-tests/scenario.local.json \ + npx hardhat --config hardhat.harbor-shortunbond.config.js run scripts/scenario-tests/access-tests.js --network harbor-shortunbond-testnet +``` + +Run the live upgrade/migration-style check. This deploys `GringottsV2Dummy`, proposes an upgrade, votes/processes it, then calls the new dummy functions through the existing proxy: + +```bash +EXECUTE=true \ +SCENARIO_CONFIG=scripts/scenario-tests/scenario.local.json \ + npx hardhat --config hardhat.harbor-shortunbond.config.js run scripts/scenario-tests/upgrade-tests.js --network harbor-shortunbond-testnet +``` + +Run expiration scenarios only when you are willing to wait through `maxVotingPeriod`: + +```bash +EXECUTE=true WAIT_FOR_EXPIRY=true \ +SCENARIO_CONFIG=scripts/scenario-tests/scenario.local.json \ + npx hardhat --config hardhat.harbor-shortunbond.config.js run scripts/scenario-tests/governance-admin-tests.js --network harbor-shortunbond-testnet +``` + +Run destructive/stress scenarios only on a disposable deployment: + +```bash +EXECUTE=true ALLOW_DESTRUCTIVE=true RUN_STRESS=true \ +SCENARIO_CONFIG=scripts/scenario-tests/scenario.local.json \ + npx hardhat --config hardhat.harbor-shortunbond.config.js run scripts/scenario-tests/vesting-tests.js --network harbor-shortunbond-testnet +``` + +## Unit Tests + +The Hardhat unit suite includes a local upgrade test that upgrades through admin proposals to `GringottsV2Dummy` and calls the dummy functions through the proxy. + +Run only the upgrade unit test: + +```bash +npx hardhat test test/Gringotts.test.js --grep "GringottsV2Dummy" +``` + +Run the full Solidity test suite: + +```bash +npm test +``` + +## Script Groups + +- `access-tests.js`: operator/admin access, direct upgrade blocking, proposal lifecycle basics. +- `vesting-tests.js`: vesting reads and guarded unlocked/emergency withdrawal flows. +- `staking-operation-tests.js`: delegate, undelegate, redelegate, invalid validator, truncation, entry-limit scenarios. +- `reward-tests.js`: reward withdrawal and distribution address update flows. +- `governance-admin-tests.js`: admin/distribution/gov proposals, expiration, threshold, last-admin/last-operator invariants. +- `upgrade-tests.js`: deploys `GringottsV2Dummy`, upgrades by admin proposal, then calls the new dummy function through the proxy. + +Some scenarios depend on live-chain timing or available rewards. The scripts explicitly skip those when prerequisites are missing rather than pretending the condition was tested. + +## Validator Addresses + +The Harbor short-unbond testnet currently has these bonded validators: + +```text +seivaloper1z7dlhv79r45ulcnnumxle67ven8n65hcaxvwea +seivaloper1yp96dyhkjndszsyf7mv55tck34c5vgue5l2rxv +seivaloper1jjy6d7kpyphcgtkgjsgrp8624m2uq3cp2gauhz +seivaloper15c6rdhx97mc7uuhl94v3ramslcpt6xcjeqqrx9 +``` diff --git a/solidity/scripts/scenario-tests/access-tests.js b/solidity/scripts/scenario-tests/access-tests.js new file mode 100644 index 0000000..940022b --- /dev/null +++ b/solidity/scripts/scenario-tests/access-tests.js @@ -0,0 +1,248 @@ +const { + ZERO_ADDRESS, + connectGringotts, + ethers, + expectRevert, + expectSuccess, + loadActors, + loadScenarioConfig, + requireActor, + requireValidator, + ScenarioRunner, + sendTx, + staticCall, + txOverrides, +} = require("./lib/common"); + +const { + createProposal, + expectDuplicateVoteFails, + expectProcessBeforeThresholdFails, + expectVoteAfterExpirationBehavior, + processPassedProposal, + voteUntilPassed, +} = require("./lib/proposals"); + +async function main() { + const config = loadScenarioConfig(); + const runner = new ScenarioRunner("Access Tests", config); + runner.header(); + + const contract = await connectGringotts(config.proxyAddress); + const actors = loadActors(config); + const currentAdmins = await contract.listAdmins(); + const currentOperators = await contract.listOperators(); + const info = await contract.getInfo(); + + runner.note(`current admins: ${currentAdmins.join(", ")}`); + runner.note(`current operators: ${currentOperators.join(", ")}`); + + await runner.scenario("Non-op cannot call operator methods", async () => { + const nonOp = actors.nonOperator; + await expectRevert(runner, "non-op delegate", () => + staticCall(contract, nonOp, "delegate", [config.validators.invalid, config.amounts.delegateWei]) + ); + await expectRevert(runner, "non-op redelegate", () => + staticCall(contract, nonOp, "redelegate", [ + config.validators.invalid, + config.validators.invalid, + config.amounts.delegateWei, + ]) + ); + await expectRevert(runner, "non-op undelegate", () => + staticCall(contract, nonOp, "undelegate", [config.validators.invalid, config.amounts.delegateWei]) + ); + await expectRevert(runner, "non-op initiateWithdrawUnlocked", () => + staticCall(contract, nonOp, "initiateWithdrawUnlocked", [config.amounts.withdrawWei]) + ); + await expectRevert(runner, "non-op initiateWithdrawReward", () => + staticCall(contract, nonOp, "initiateWithdrawReward", [[]]) + ); + }); + + await runner.scenario("Current op can call safe/no-op operational methods", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + + await expectSuccess(runner, "operator withdraw rewards with empty validator list static-call", () => + staticCall(contract, actors.operator, "initiateWithdrawReward", [[]]) + ); + await expectSuccess(runner, "operator withdraw unlocked 0 static-call", () => + staticCall(contract, actors.operator, "initiateWithdrawUnlocked", [0n]) + ); + + if (!requireValidator(runner, config.validators.primary, "primary")) return; + await expectRevert(runner, "operator delegate 0 fails", () => + staticCall(contract, actors.operator, "delegate", [config.validators.primary, 0n]) + ); + }); + + await runner.scenario("Current op can call staking operations successfully when validator state exists", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + if (!requireValidator(runner, config.validators.primary, "primary")) return; + if (!requireValidator(runner, config.validators.secondary, "secondary")) return; + + runner.note("delegate/redelegate/undelegate success paths are mutating; static-call may not reflect precompile side effects on every RPC"); + if (!config.execution.execute) { + runner.skip("set EXECUTE=true to send the success-path staking transactions"); + return; + } + + await sendTx(config, contract, actors.operator, "delegate", [ + config.validators.primary, + config.amounts.delegateWei, + ]); + runner.pass("operator delegate sent"); + + await sendTx(config, contract, actors.operator, "redelegate", [ + config.validators.primary, + config.validators.secondary, + config.amounts.smallWei, + ]); + runner.pass("operator redelegate sent"); + + await sendTx(config, contract, actors.operator, "undelegate", [ + config.validators.secondary, + config.amounts.smallWei, + ]); + runner.pass("operator undelegate sent"); + }); + + await runner.scenario("Non-admin cannot add/remove ops or manage proposals", async () => { + const nonAdmin = actors.nonAdmin; + await expectRevert(runner, "non-admin updateOp", () => + staticCall(contract, nonAdmin, "updateOp", [actors.newOperator.address, false]) + ); + await expectRevert(runner, "non-admin proposeUpdateAdmin", () => + staticCall(contract, nonAdmin, "proposeUpdateAdmin", [actors.newAdmin.address, false]) + ); + await expectRevert(runner, "non-admin voteProposal", () => + staticCall(contract, nonAdmin, "voteProposal", [1n]) + ); + await expectRevert(runner, "non-admin processProposal", () => + staticCall(contract, nonAdmin, "processProposal", [1n]) + ); + }); + + await runner.scenario("Admin can add/remove an op; new/removed op access changes immediately", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + const newOperator = actors.newOperator; + + if (!config.execution.execute) { + runner.skip("set EXECUTE=true to add/remove operator on-chain"); + return; + } + + await sendTx(config, contract, actors.admin, "updateOp", [newOperator.address, false]); + runner.pass(`added operator ${newOperator.address}`); + await expectSuccess(runner, "new operator can call operator no-op static-call", () => + staticCall(contract, newOperator, "initiateWithdrawReward", [[]]) + ); + + await sendTx(config, contract, actors.admin, "updateOp", [newOperator.address, true]); + runner.pass(`removed operator ${newOperator.address}`); + await expectRevert(runner, "removed operator loses access", () => + staticCall(contract, newOperator, "initiateWithdrawReward", [[]]) + ); + }); + + await runner.scenario("Proposal creator auto-votes yes; duplicate vote fails; threshold/process behavior", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateUnlockedDistributionAddress", [ + info._unlockDistributionAddress, + ]); + if (proposalId === null) return; + + const proposal = await contract.getProposal(proposalId); + if (proposal.yesVotes === 1n && (await contract.hasVoted(proposalId, actors.admin.address))) { + runner.pass("proposal creator auto-voted yes"); + } else { + runner.fail("proposal creator auto-vote was not recorded as expected"); + } + + await expectDuplicateVoteFails(runner, contract, actors.admin, proposalId); + + if (Number(proposal.status) === 0) { + await expectProcessBeforeThresholdFails(runner, contract, actors.admin, proposalId); + } else { + runner.skip("process-before-threshold: proposer auto-vote already reached threshold"); + } + + if (actors.admins.length < 2) { + runner.skip("process-after-threshold requires at least two admin private keys"); + return; + } + + await voteUntilPassed(config, runner, contract, proposalId, actors.admins); + await processPassedProposal(config, runner, contract, actors.admin, proposalId); + await expectRevert(runner, "processing the same proposal twice fails", () => + staticCall(contract, actors.admin, "processProposal", [proposalId]) + ); + }); + + await runner.scenario("Vote after proposal expiration", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + if (!requireActor(runner, actors.secondAdmin, "secondAdmin")) return; + + runner.note("current contract returns cleanly when voteProposal sees an expired proposal; it does not revert"); + await expectVoteAfterExpirationBehavior( + config, + runner, + contract, + actors.admin, + actors.secondAdmin, + "proposeUpdateUnlockedDistributionAddress", + [info._unlockDistributionAddress] + ); + }); + + await runner.scenario("Internal-only methods cannot be called directly; direct UUPS upgrade is blocked", async () => { + const internalNames = [ + "_executeUpgrade", + "_executeUpdateAdmin", + "_executeUpdateUnlockDistributionAddress", + "_executeUpdateStakingRewardAddress", + "_executeEmergencyWithdraw", + ]; + + const exposed = internalNames.filter((name) => contract.interface.fragments.some((fragment) => fragment.name === name)); + if (exposed.length === 0) { + runner.pass("internal helper methods are absent from ABI"); + } else { + runner.fail(`internal helpers exposed in ABI: ${exposed.join(", ")}`); + } + + await expectRevert(runner, "direct upgradeToAndCall is blocked", () => + staticCall(contract, actors.nonAdmin, "upgradeToAndCall", [ZERO_ADDRESS, "0x"]) + ); + }); + + await runner.scenario("Internal-only methods succeed only through proposal execution path", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + if (actors.admins.length < 2) { + runner.skip("requires enough admin private keys to pass a proposal"); + return; + } + + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateUnlockedDistributionAddress", [ + info._unlockDistributionAddress, + ]); + if (proposalId === null) return; + + await voteUntilPassed(config, runner, contract, proposalId, actors.admins); + await processPassedProposal(config, runner, contract, actors.admin, proposalId); + const after = await contract.getInfo(); + if (after._unlockDistributionAddress === info._unlockDistributionAddress) { + runner.pass("proposal execution path completed without exposing internal helper"); + } else { + runner.fail("unexpected unlock distribution address after proposal execution"); + } + }); + + runner.summary(); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/solidity/scripts/scenario-tests/governance-admin-tests.js b/solidity/scripts/scenario-tests/governance-admin-tests.js new file mode 100644 index 0000000..1e4310d --- /dev/null +++ b/solidity/scripts/scenario-tests/governance-admin-tests.js @@ -0,0 +1,233 @@ +const { + connectGringotts, + ethers, + expectRevert, + loadActors, + loadScenarioConfig, + mineOrWait, + requireActor, + ScenarioRunner, + sendTx, + staticCall, +} = require("./lib/common"); + +const { + createProposal, + expectProcessBeforeThresholdFails, + expectVoteAfterExpirationBehavior, + processPassedProposal, + voteUntilPassed, +} = require("./lib/proposals"); + +async function main() { + const config = loadScenarioConfig(); + const runner = new ScenarioRunner("Governance/Admin Proposal Tests", config); + runner.header(); + + const contract = await connectGringotts(config.proxyAddress); + const actors = loadActors(config); + + await runner.scenario("Propose admin add/remove, collect enough votes, process, and verify admin set changes", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + if (actors.admins.length < 2) { + runner.skip("requires enough admin private keys to pass proposals"); + return; + } + + const newAdmin = actors.newAdmin; + const addProposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateAdmin", [ + newAdmin.address, + false, + ]); + if (addProposalId === null) return; + + await voteUntilPassed(config, runner, contract, addProposalId, actors.admins); + await processPassedProposal(config, runner, contract, actors.admin, addProposalId); + + if (await contract.isAdmin(newAdmin.address)) runner.pass("new admin added"); + else runner.fail("new admin was not added"); + + const removeProposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateAdmin", [ + newAdmin.address, + true, + ]); + if (removeProposalId === null) return; + + await voteUntilPassed(config, runner, contract, removeProposalId, actors.admins); + await processPassedProposal(config, runner, contract, actors.admin, removeProposalId); + + if (!(await contract.isAdmin(newAdmin.address))) runner.pass("new admin removed"); + else runner.fail("new admin still present after removal"); + }); + + await runner.scenario("Propose unlocked distribution address update, process, then withdraw unlocked", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + if (actors.admins.length < 2) { + runner.skip("requires enough admin private keys to pass proposal"); + return; + } + + const info = await contract.getInfo(); + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateUnlockedDistributionAddress", [ + info._unlockDistributionAddress, + ]); + if (proposalId === null) return; + + await voteUntilPassed(config, runner, contract, proposalId, actors.admins); + await processPassedProposal(config, runner, contract, actors.admin, proposalId); + runner.skip("actual unlocked withdrawal after address update is covered by vesting-tests.js with an operator key"); + }); + + await runner.scenario("Propose staking reward address update, process, then withdraw rewards", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + if (actors.admins.length < 2) { + runner.skip("requires enough admin private keys to pass proposal"); + return; + } + + const info = await contract.getInfo(); + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateStakingRewardDistributionAddress", [ + info._stakingRewardAddress, + ]); + if (proposalId === null) return; + + await voteUntilPassed(config, runner, contract, proposalId, actors.admins); + await processPassedProposal(config, runner, contract, actors.admin, proposalId); + runner.skip("actual reward withdrawal after address update is covered by reward-tests.js with an operator key"); + }); + + await runner.scenario("Propose gov vote and process it", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + if (actors.admins.length < 2) { + runner.skip("requires enough admin private keys to pass proposal"); + return; + } + if (!config.governance.govProposalId) { + runner.skip("set governance.govProposalId or GOV_PROPOSAL_ID"); + return; + } + + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeGovVote", [ + config.governance.govProposalId, + config.governance.voteOption, + ]); + if (proposalId === null) return; + + await voteUntilPassed(config, runner, contract, proposalId, actors.admins); + await processPassedProposal(config, runner, contract, actors.admin, proposalId); + runner.pass("gov vote proposal processed; verify vote through Sei gov query tooling"); + }); + + await runner.scenario("Proposal that expires before threshold cannot be processed", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + + runner.note("current processProposal returns cleanly when it sees Expired; it does not revert"); + await expectVoteAfterExpirationBehavior( + config, + runner, + contract, + actors.admin, + actors.secondAdmin || actors.nonAdmin, + "proposeUpdateUnlockedDistributionAddress", + [(await contract.getInfo())._unlockDistributionAddress] + ); + }); + + await runner.scenario("Proposal that reached threshold before expiration can still be processed after expiration", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + if (actors.admins.length < 2) { + runner.skip("requires enough admin private keys to pass proposal"); + return; + } + if (!config.execution.execute || !config.execution.waitForExpiry) { + runner.skip("requires EXECUTE=true and WAIT_FOR_EXPIRY=true"); + return; + } + + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateUnlockedDistributionAddress", [ + (await contract.getInfo())._unlockDistributionAddress, + ]); + if (proposalId === null) return; + + await voteUntilPassed(config, runner, contract, proposalId, actors.admins); + const proposal = await contract.getProposal(proposalId); + const latestBlock = await ethers.provider.getBlock("latest"); + const waitSeconds = Math.max(Number(proposal.expiresAt) - Number(latestBlock.timestamp) + 2, 1); + runner.note(`proposal is Passed; waiting ${waitSeconds}s before processing after expiration`); + await mineOrWait(waitSeconds); + await processPassedProposal(config, runner, contract, actors.admin, proposalId); + }); + + await runner.scenario("Admin removal changes threshold behavior for later proposals", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + if (actors.admins.length < 2) { + runner.skip("requires enough admin private keys to change admin set"); + return; + } + + runner.skip("destructive to admin topology; use the add/remove admin scenario with a disposable newAdmin key first"); + }); + + await runner.scenario("Removing the last admin should fail or be prevented", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + + const admins = await contract.listAdmins(); + if (admins.length !== 1) { + runner.skip("contract currently has more than one admin; last-admin condition not present"); + return; + } + if (!config.execution.execute) { + runner.skip("requires EXECUTE=true to create the last-admin removal proposal, then process it via static-call"); + return; + } + + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateAdmin", [ + admins[0], + true, + ]); + if (proposalId === null) return; + + await expectRevert(runner, "processing last admin removal fails", () => + staticCall(contract, actors.admin, "processProposal", [proposalId]) + ); + }); + + await runner.scenario("Removing the last op should fail or be prevented if invariant is kept", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + + const operators = await contract.listOperators(); + if (operators.length !== 1) { + runner.skip("contract currently has more than one operator; last-op condition not present"); + return; + } + + runner.note("current Gringotts has no CannotRemoveLastOperator invariant; this scenario is expected to reveal that mismatch if executed."); + await expectRevert(runner, "last operator removal should fail under CW invariant", () => + staticCall(contract, actors.admin, "updateOp", [operators[0], true]) + ); + }); + + await runner.scenario("Process before threshold is reached fails", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateUnlockedDistributionAddress", [ + (await contract.getInfo())._unlockDistributionAddress, + ]); + if (proposalId === null) return; + + const proposal = await contract.getProposal(proposalId); + if (Number(proposal.status) !== 0) { + runner.skip("auto-vote already reached threshold"); + return; + } + + await expectProcessBeforeThresholdFails(runner, contract, actors.admin, proposalId); + }); + + runner.summary(); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/solidity/scripts/scenario-tests/lib/common.js b/solidity/scripts/scenario-tests/lib/common.js new file mode 100644 index 0000000..e308d04 --- /dev/null +++ b/solidity/scripts/scenario-tests/lib/common.js @@ -0,0 +1,343 @@ +const fs = require("fs"); +const path = require("path"); +const hre = require("hardhat"); + +const { ethers } = hre; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; +const STAKING_PRECOMPILE = "0x0000000000000000000000000000000000001005"; +const DEFAULT_CONFIG_PATH = "scripts/scenario-tests/scenario.config.example.json"; + +const STAKING_ABI = [ + "function delegation(address delegator, string valAddress) view returns (tuple(tuple(uint256 amount,string denom) balance, tuple(string delegator_address,uint256 shares,uint256 decimals,string validator_address) delegation))", + "function unbondingDelegation(address delegator, string validatorAddress) view returns (tuple(string delegatorAddress,string validatorAddress,tuple(int64 creationHeight,int64 completionTime,string initialBalance,string balance)[] entries))", + "function delegatorUnbondingDelegations(address delegator, bytes nextKey) view returns (tuple(tuple(string delegatorAddress,string validatorAddress,tuple(int64 creationHeight,int64 completionTime,string initialBalance,string balance)[] entries)[] unbondingDelegations, bytes nextKey))", + "function redelegations(string delegator, string srcValidator, string dstValidator, bytes nextKey) view returns (tuple(tuple(string delegatorAddress,string validatorSrcAddress,string validatorDstAddress,tuple(int64 creationHeight,int64 completionTime,string initialBalance,string sharesDst)[] entries)[] redelegations, bytes nextKey))", +]; + +function parseBool(value, fallback = false) { + if (value === undefined || value === null || value === "") return fallback; + if (typeof value === "boolean") return value; + return ["1", "true", "yes", "y"].includes(String(value).toLowerCase()); +} + +function splitCsv(value) { + if (!value) return []; + return String(value) + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function readJsonIfExists(filePath) { + if (!filePath) return {}; + const fullPath = path.resolve(filePath); + if (!fs.existsSync(fullPath)) return {}; + return JSON.parse(fs.readFileSync(fullPath, "utf8")); +} + +function latestDeployment() { + const dir = path.resolve("deployments"); + if (!fs.existsSync(dir)) return {}; + + const files = fs + .readdirSync(dir) + .filter((file) => /^gringotts-\d+-\d+\.json$/.test(file)) + .map((file) => path.join(dir, file)) + .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); + + if (files.length === 0) return {}; + return JSON.parse(fs.readFileSync(files[0], "utf8")); +} + +function asArray(value) { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +function toWei(value, fieldName) { + if (typeof value === "bigint") return value; + if (typeof value === "number") return BigInt(value); + if (typeof value === "string" && value.trim() !== "") return BigInt(value); + throw new Error(`${fieldName} must be a base-10 wei string`); +} + +function optionalAddress(value) { + if (!value) return undefined; + return ethers.getAddress(value); +} + +function loadScenarioConfig() { + const scenarioConfigPath = process.env.SCENARIO_CONFIG || DEFAULT_CONFIG_PATH; + const fileConfig = readJsonIfExists(scenarioConfigPath); + const deployment = latestDeployment(); + const deployParams = deployment.params || {}; + const actorConfig = fileConfig.actors || {}; + const validatorConfig = fileConfig.validators || {}; + const amountConfig = fileConfig.amounts || {}; + const executionConfig = fileConfig.execution || {}; + const govConfig = fileConfig.governance || {}; + + const proxyAddress = optionalAddress( + process.env.PROXY_ADDRESS || fileConfig.proxyAddress || deployment.proxy + ); + if (!proxyAddress) { + throw new Error("Missing proxy address. Set PROXY_ADDRESS or keep a deployment JSON in deployments/."); + } + + const adminPrivateKeys = splitCsv(process.env.ADMIN_PRIVATE_KEYS).concat( + asArray(actorConfig.adminPrivateKeys) + ); + + return { + scenarioConfigPath: path.resolve(scenarioConfigPath), + deployment, + proxyAddress, + implementationAddress: optionalAddress( + process.env.IMPLEMENTATION_ADDRESS || fileConfig.implementationAddress || deployment.implementation + ), + expectedAdmins: asArray(fileConfig.admins || deployParams.admins).map((a) => ethers.getAddress(a)), + expectedOperators: asArray(fileConfig.operators || deployParams.operators).map((a) => ethers.getAddress(a)), + actors: { + adminPrivateKeys, + operatorPrivateKey: process.env.OPERATOR_PRIVATE_KEY || actorConfig.operatorPrivateKey || "", + nonAdminPrivateKey: process.env.NON_ADMIN_PRIVATE_KEY || actorConfig.nonAdminPrivateKey || "", + nonOperatorPrivateKey: process.env.NON_OPERATOR_PRIVATE_KEY || actorConfig.nonOperatorPrivateKey || "", + newAdminPrivateKey: process.env.NEW_ADMIN_PRIVATE_KEY || actorConfig.newAdminPrivateKey || "", + newOperatorPrivateKey: process.env.NEW_OPERATOR_PRIVATE_KEY || actorConfig.newOperatorPrivateKey || "", + implementationDeployerPrivateKey: + process.env.IMPLEMENTATION_DEPLOYER_PRIVATE_KEY || + actorConfig.implementationDeployerPrivateKey || + "", + }, + validators: { + primary: process.env.PRIMARY_VALIDATOR || validatorConfig.primary || "", + secondary: process.env.SECONDARY_VALIDATOR || validatorConfig.secondary || "", + tertiary: process.env.TERTIARY_VALIDATOR || validatorConfig.tertiary || "", + invalid: process.env.INVALID_VALIDATOR || validatorConfig.invalid || "seivaloper1invalidvalidatoraddress", + }, + amounts: { + delegateWei: toWei(process.env.DELEGATE_WEI || amountConfig.delegateWei || ethers.parseEther("1").toString(), "delegateWei"), + smallWei: toWei(process.env.SMALL_WEI || amountConfig.smallWei || ethers.parseEther("0.25").toString(), "smallWei"), + dustWei: toWei(process.env.DUST_WEI || amountConfig.dustWei || "1", "dustWei"), + withdrawWei: toWei(process.env.WITHDRAW_WEI || amountConfig.withdrawWei || ethers.parseEther("0.5").toString(), "withdrawWei"), + }, + execution: { + execute: parseBool(process.env.EXECUTE, parseBool(executionConfig.execute, false)), + waitForExpiry: parseBool(process.env.WAIT_FOR_EXPIRY, parseBool(executionConfig.waitForExpiry, false)), + allowDestructive: parseBool(process.env.ALLOW_DESTRUCTIVE, parseBool(executionConfig.allowDestructive, false)), + runStress: parseBool(process.env.RUN_STRESS, parseBool(executionConfig.runStress, false)), + gasLimit: process.env.GAS_LIMIT || executionConfig.gasLimit || "", + }, + governance: { + govProposalId: Number(process.env.GOV_PROPOSAL_ID || govConfig.govProposalId || 0), + voteOption: Number(process.env.GOV_VOTE_OPTION || govConfig.voteOption || 1), + }, + }; +} + +async function connectGringotts(proxyAddress) { + const artifact = await hre.artifacts.readArtifact("Gringotts"); + return new ethers.Contract(proxyAddress, artifact.abi, ethers.provider); +} + +function connectStakingPrecompile() { + return new ethers.Contract(STAKING_PRECOMPILE, STAKING_ABI, ethers.provider); +} + +function walletFromPrivateKey(privateKey, label) { + if (!privateKey) return null; + try { + return new ethers.Wallet(privateKey, ethers.provider); + } catch (error) { + throw new Error(`Invalid private key for ${label}: ${error.message}`); + } +} + +function randomReadOnlyWallet(label) { + const wallet = ethers.Wallet.createRandom().connect(ethers.provider); + wallet.__scenarioLabel = `${label} (random eth_call-only wallet)`; + return wallet; +} + +function loadActors(config) { + const adminWallets = config.actors.adminPrivateKeys.map((key, index) => + walletFromPrivateKey(key, `adminPrivateKeys[${index}]`) + ); + + return { + admins: adminWallets, + admin: adminWallets[0] || null, + secondAdmin: adminWallets[1] || null, + thirdAdmin: adminWallets[2] || null, + operator: walletFromPrivateKey(config.actors.operatorPrivateKey, "operatorPrivateKey"), + nonAdmin: + walletFromPrivateKey(config.actors.nonAdminPrivateKey, "nonAdminPrivateKey") || + randomReadOnlyWallet("nonAdmin"), + nonOperator: + walletFromPrivateKey(config.actors.nonOperatorPrivateKey, "nonOperatorPrivateKey") || + randomReadOnlyWallet("nonOperator"), + newAdmin: + walletFromPrivateKey(config.actors.newAdminPrivateKey, "newAdminPrivateKey") || + randomReadOnlyWallet("newAdmin"), + newOperator: + walletFromPrivateKey(config.actors.newOperatorPrivateKey, "newOperatorPrivateKey") || + randomReadOnlyWallet("newOperator"), + implementationDeployer: + walletFromPrivateKey(config.actors.implementationDeployerPrivateKey, "implementationDeployerPrivateKey"), + }; +} + +function txOverrides(config, extra = {}) { + const overrides = { ...extra }; + if (config.execution.gasLimit) { + overrides.gasLimit = BigInt(config.execution.gasLimit); + } + return overrides; +} + +async function staticCall(contract, signer, fn, args = [], overrides = {}) { + return contract.connect(signer)[fn].staticCall(...args, overrides); +} + +async function sendTx(config, contract, signer, fn, args = [], overrides = {}) { + if (!config.execution.execute) { + throw new Error(`Refusing to send ${fn}; set EXECUTE=true after reviewing the script.`); + } + const tx = await contract.connect(signer)[fn](...args, txOverrides(config, overrides)); + return tx.wait(); +} + +function requireActor(runner, actor, label) { + if (!actor) { + runner.skip(`missing ${label}; provide it in SCENARIO_CONFIG or ${label.toUpperCase()}_PRIVATE_KEY env`); + return false; + } + return true; +} + +function requireValidator(runner, value, label) { + if (!value) { + runner.skip(`missing ${label}; set it in SCENARIO_CONFIG or ${label.toUpperCase()}_VALIDATOR env`); + return false; + } + return true; +} + +async function expectRevert(runner, label, thunk) { + try { + await thunk(); + runner.fail(`${label}: expected revert but call succeeded`); + } catch (error) { + runner.pass(label, shortError(error)); + } +} + +async function expectSuccess(runner, label, thunk) { + try { + const value = await thunk(); + runner.pass(label); + return value; + } catch (error) { + runner.fail(`${label}: ${shortError(error)}`); + return undefined; + } +} + +function shortError(error) { + const text = error && (error.shortMessage || error.reason || error.message || String(error)); + return String(text).split("\n")[0]; +} + +function proposalStatusName(status) { + return ["Open", "Passed", "Executed", "Expired"][Number(status)] || String(status); +} + +function weiToSei(value) { + return ethers.formatEther(value); +} + +async function mineOrWait(seconds) { + try { + await ethers.provider.send("evm_increaseTime", [seconds]); + await ethers.provider.send("evm_mine", []); + } catch { + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + } +} + +class ScenarioRunner { + constructor(title, config) { + this.title = title; + this.config = config; + this.passed = 0; + this.failed = 0; + this.skipped = 0; + } + + header() { + console.log(""); + console.log("=".repeat(72)); + console.log(this.title); + console.log("=".repeat(72)); + console.log("Proxy:", this.config.proxyAddress); + console.log("Config:", this.config.scenarioConfigPath); + console.log("Mode:", this.config.execution.execute ? "EXECUTE transactions" : "review/static only"); + console.log(""); + } + + async scenario(name, fn) { + console.log(`\n- ${name}`); + try { + await fn(); + } catch (error) { + this.fail(shortError(error)); + } + } + + pass(label, detail) { + this.passed++; + console.log(` PASS ${label}${detail ? ` (${detail})` : ""}`); + } + + fail(label) { + this.failed++; + console.log(` FAIL ${label}`); + } + + skip(label) { + this.skipped++; + console.log(` SKIP ${label}`); + } + + note(label) { + console.log(` NOTE ${label}`); + } + + summary() { + console.log(""); + console.log("Summary:", `${this.passed} passed, ${this.failed} failed, ${this.skipped} skipped`); + if (this.failed > 0) process.exitCode = 1; + } +} + +module.exports = { + ZERO_ADDRESS, + ethers, + loadScenarioConfig, + connectGringotts, + connectStakingPrecompile, + loadActors, + txOverrides, + staticCall, + sendTx, + requireActor, + requireValidator, + expectRevert, + expectSuccess, + shortError, + proposalStatusName, + weiToSei, + mineOrWait, + ScenarioRunner, +}; diff --git a/solidity/scripts/scenario-tests/lib/proposals.js b/solidity/scripts/scenario-tests/lib/proposals.js new file mode 100644 index 0000000..52490c3 --- /dev/null +++ b/solidity/scripts/scenario-tests/lib/proposals.js @@ -0,0 +1,120 @@ +const { + expectRevert, + ethers, + mineOrWait, + proposalStatusName, + sendTx, + staticCall, +} = require("./common"); + +function proposalIdFromReceipt(contract, receipt) { + for (const log of receipt.logs) { + try { + const parsed = contract.interface.parseLog(log); + if (parsed && parsed.name === "ProposalCreated") { + return parsed.args.proposalId; + } + } catch { + // Ignore logs emitted by other contracts/precompiles. + } + } + return null; +} + +async function createProposal(config, runner, contract, admin, fn, args) { + if (!config.execution.execute) { + runner.skip(`${fn} would create a proposal; set EXECUTE=true to send it`); + return null; + } + + const receipt = await sendTx(config, contract, admin, fn, args); + const proposalId = proposalIdFromReceipt(contract, receipt); + if (proposalId === null) { + runner.fail(`${fn}: ProposalCreated event not found`); + return null; + } + + const proposal = await contract.getProposal(proposalId); + runner.pass(`${fn}: created proposal ${proposalId}`, `status=${proposalStatusName(proposal.status)}, yesVotes=${proposal.yesVotes}`); + return proposalId; +} + +async function voteUntilPassed(config, runner, contract, proposalId, adminWallets) { + for (const admin of adminWallets.slice(1)) { + const before = await contract.getProposal(proposalId); + if (Number(before.status) === 1) { + runner.pass(`proposal ${proposalId} already passed`); + return; + } + + await sendTx(config, contract, admin, "voteProposal", [proposalId]); + const after = await contract.getProposal(proposalId); + runner.pass(`admin ${admin.address} voted`, `status=${proposalStatusName(after.status)}, yesVotes=${after.yesVotes}`); + + if (Number(after.status) === 1) return; + } + + const finalProposal = await contract.getProposal(proposalId); + if (Number(finalProposal.status) !== 1) { + runner.fail(`proposal ${proposalId} did not reach Passed with provided admin keys`); + } +} + +async function processPassedProposal(config, runner, contract, admin, proposalId) { + await sendTx(config, contract, admin, "processProposal", [proposalId]); + const proposal = await contract.getProposal(proposalId); + if (Number(proposal.status) === 2) { + runner.pass(`processed proposal ${proposalId}`); + } else { + runner.fail(`proposal ${proposalId} processed but status is ${proposalStatusName(proposal.status)}`); + } +} + +async function expectProcessBeforeThresholdFails(runner, contract, admin, proposalId) { + await expectRevert(runner, "process before threshold fails", () => + staticCall(contract, admin, "processProposal", [proposalId]) + ); +} + +async function expectDuplicateVoteFails(runner, contract, admin, proposalId) { + await expectRevert(runner, "duplicate admin vote fails", () => + staticCall(contract, admin, "voteProposal", [proposalId]) + ); +} + +async function expectVoteAfterExpirationBehavior(config, runner, contract, proposer, voter, fn, args) { + if (!config.execution.execute || !config.execution.waitForExpiry) { + runner.skip("vote-after-expiration requires EXECUTE=true and WAIT_FOR_EXPIRY=true"); + return; + } + + const proposalId = await createProposal(config, runner, contract, proposer, fn, args); + if (proposalId === null) return; + + const proposal = await contract.getProposal(proposalId); + const latestBlock = await ethers.provider.getBlock("latest"); + const now = Number(latestBlock.timestamp); + const waitSeconds = Math.max(Number(proposal.expiresAt) - now + 2, 1); + runner.note(`waiting ${waitSeconds}s for proposal ${proposalId} to expire`); + await mineOrWait(waitSeconds); + + const before = await contract.getProposal(proposalId); + await sendTx(config, contract, voter, "voteProposal", [proposalId]); + const after = await contract.getProposal(proposalId); + if (Number(after.status) === 3) { + runner.pass("expired vote transaction returned cleanly and marked proposal Expired"); + } else { + runner.fail(`expected Expired after late vote, got ${proposalStatusName(after.status)}`); + } + runner.note(`status before late vote=${proposalStatusName(before.status)}, after=${proposalStatusName(after.status)}`); +} + +module.exports = { + proposalIdFromReceipt, + createProposal, + voteUntilPassed, + processPassedProposal, + expectProcessBeforeThresholdFails, + expectDuplicateVoteFails, + expectVoteAfterExpirationBehavior, +}; diff --git a/solidity/scripts/scenario-tests/reward-tests.js b/solidity/scripts/scenario-tests/reward-tests.js new file mode 100644 index 0000000..f7070aa --- /dev/null +++ b/solidity/scripts/scenario-tests/reward-tests.js @@ -0,0 +1,161 @@ +const { + connectGringotts, + expectSuccess, + loadActors, + loadScenarioConfig, + requireActor, + requireValidator, + ScenarioRunner, + sendTx, + staticCall, + weiToSei, +} = require("./lib/common"); + +const { + createProposal, + processPassedProposal, + voteUntilPassed, +} = require("./lib/proposals"); + +async function main() { + const config = loadScenarioConfig(); + const runner = new ScenarioRunner("Reward Withdrawal Tests", config); + runner.header(); + + const contract = await connectGringotts(config.proxyAddress); + const actors = loadActors(config); + + await runner.scenario("Withdraw rewards with no active delegations", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + + await expectSuccess(runner, "withdraw rewards empty validator list static-call", () => + staticCall(contract, actors.operator, "initiateWithdrawReward", [[]]) + ); + runner.note("Current API treats empty validator list as no-op after sending any banked rewards."); + }); + + await runner.scenario("Withdraw rewards with active delegation and no unbonding", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + if (!requireValidator(runner, config.validators.primary, "primary")) return; + + const rewards = await contract.getPendingRewards(); + runner.note(`pending reward validator buckets: ${rewards.rewards.length}`); + + if (!config.execution.execute) { + runner.skip("set EXECUTE=true to withdraw live rewards"); + return; + } + + const before = await contract.getInfo(); + await sendTx(config, contract, actors.operator, "initiateWithdrawReward", [[config.validators.primary]]); + const after = await contract.getInfo(); + runner.pass( + "withdraw rewards sent", + `withdrawnStakingRewards ${weiToSei(before._withdrawnStakingRewards)} -> ${weiToSei(after._withdrawnStakingRewards)}` + ); + }); + + await runner.scenario("Withdraw rewards while unbonding exists", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + if (!requireValidator(runner, config.validators.primary, "primary")) return; + + runner.note("This verifies accounting after a prior undelegate created an unbonding entry."); + if (!config.execution.execute) { + runner.skip("requires prior unbonding state and EXECUTE=true"); + return; + } + + const before = await contract.getInfo(); + await sendTx(config, contract, actors.operator, "initiateWithdrawReward", [[config.validators.primary]]); + const after = await contract.getInfo(); + if (after._withdrawnLocked === before._withdrawnLocked && after._withdrawnUnlocked === before._withdrawnUnlocked) { + runner.pass("reward withdrawal did not modify principal withdrawal accounting"); + } else { + runner.fail("reward withdrawal changed principal withdrawal accounting"); + } + }); + + await runner.scenario("Withdraw rewards after all unbonding has completed", async () => { + runner.skip("requires waiting through unbonding completion and EndBlock processing before running reward withdrawal"); + }); + + await runner.scenario("Withdraw rewards over multiple validators and twice in a row", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + const validators = [config.validators.primary, config.validators.secondary, config.validators.tertiary].filter(Boolean); + if (validators.length === 0) { + runner.skip("no validators configured"); + return; + } + + if (!config.execution.execute) { + runner.skip("set EXECUTE=true to withdraw live rewards"); + return; + } + + const before = await contract.getInfo(); + await sendTx(config, contract, actors.operator, "initiateWithdrawReward", [validators]); + const afterFirst = await contract.getInfo(); + await sendTx(config, contract, actors.operator, "initiateWithdrawReward", [validators]); + const afterSecond = await contract.getInfo(); + + runner.pass( + "two reward withdrawals sent", + `withdrawn ${before._withdrawnStakingRewards} -> ${afterFirst._withdrawnStakingRewards} -> ${afterSecond._withdrawnStakingRewards}` + ); + }); + + await runner.scenario("Update staking reward distribution address, then confirm subsequent rewards use it", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + if (actors.admins.length < 2) { + runner.skip("requires enough admin private keys to pass proposal"); + return; + } + + const info = await contract.getInfo(); + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateStakingRewardDistributionAddress", [ + info._stakingRewardAddress, + ]); + if (proposalId === null) return; + + await voteUntilPassed(config, runner, contract, proposalId, actors.admins); + await processPassedProposal(config, runner, contract, actors.admin, proposalId); + + const after = await contract.getInfo(); + if (after._stakingRewardAddress === info._stakingRewardAddress) { + runner.pass("staking reward address proposal executed"); + } else { + runner.fail("staking reward address changed unexpectedly"); + } + }); + + await runner.scenario("Update unlocked distribution address, then confirm subsequent unlocked principal uses it", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + if (actors.admins.length < 2) { + runner.skip("requires enough admin private keys to pass proposal"); + return; + } + + const info = await contract.getInfo(); + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateUnlockedDistributionAddress", [ + info._unlockDistributionAddress, + ]); + if (proposalId === null) return; + + await voteUntilPassed(config, runner, contract, proposalId, actors.admins); + await processPassedProposal(config, runner, contract, actors.admin, proposalId); + + const after = await contract.getInfo(); + if (after._unlockDistributionAddress === info._unlockDistributionAddress) { + runner.pass("unlock distribution address proposal executed"); + } else { + runner.fail("unlock distribution address changed unexpectedly"); + } + }); + + runner.summary(); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/solidity/scripts/scenario-tests/scenario.config.example.json b/solidity/scripts/scenario-tests/scenario.config.example.json new file mode 100644 index 0000000..26a8ece --- /dev/null +++ b/solidity/scripts/scenario-tests/scenario.config.example.json @@ -0,0 +1,36 @@ +{ + "proxyAddress": "0xE79F135F45D571352f7d570Da1E2a295f966A58B", + "implementationAddress": "0xff7b70EE3ddB75C209d5A1c249eC9bC21c4f6aEF", + "actors": { + "adminPrivateKeys": [], + "operatorPrivateKey": "", + "nonAdminPrivateKey": "", + "nonOperatorPrivateKey": "", + "newAdminPrivateKey": "", + "newOperatorPrivateKey": "", + "implementationDeployerPrivateKey": "" + }, + "validators": { + "primary": "", + "secondary": "", + "tertiary": "", + "invalid": "seivaloper1invalidvalidatoraddress" + }, + "amounts": { + "delegateWei": "1000000000000000000", + "smallWei": "250000000000000000", + "dustWei": "1", + "withdrawWei": "500000000000000000" + }, + "governance": { + "govProposalId": 0, + "voteOption": 1 + }, + "execution": { + "execute": false, + "waitForExpiry": false, + "allowDestructive": false, + "runStress": false, + "gasLimit": "" + } +} diff --git a/solidity/scripts/scenario-tests/staking-operation-tests.js b/solidity/scripts/scenario-tests/staking-operation-tests.js new file mode 100644 index 0000000..15f3224 --- /dev/null +++ b/solidity/scripts/scenario-tests/staking-operation-tests.js @@ -0,0 +1,272 @@ +const { + connectGringotts, + connectStakingPrecompile, + expectRevert, + expectSuccess, + loadActors, + loadScenarioConfig, + requireActor, + requireValidator, + ScenarioRunner, + sendTx, + staticCall, +} = require("./lib/common"); + +function delegationAmountUsei(delegation) { + return BigInt(delegation.balance.amount); +} + +async function main() { + const config = loadScenarioConfig(); + const runner = new ScenarioRunner("Staking Operation Tests", config); + runner.header(); + + const contract = await connectGringotts(config.proxyAddress); + const staking = connectStakingPrecompile(); + const actors = loadActors(config); + + await runner.scenario("Delegate 0 fails", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + if (!requireValidator(runner, config.validators.primary, "primary")) return; + + await expectRevert(runner, "delegate 0", () => + staticCall(contract, actors.operator, "delegate", [config.validators.primary, 0n]) + ); + }); + + await runner.scenario("Delegate with nonzero amount succeeds and increases delegation", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + if (!requireValidator(runner, config.validators.primary, "primary")) return; + + if (!config.execution.execute) { + runner.skip("set EXECUTE=true to delegate real stake"); + return; + } + + const before = await contract.getDelegation(config.validators.primary); + await sendTx(config, contract, actors.operator, "delegate", [ + config.validators.primary, + config.amounts.delegateWei, + ]); + const after = await contract.getDelegation(config.validators.primary); + + if (delegationAmountUsei(after) > delegationAmountUsei(before)) { + runner.pass("delegation amount increased"); + } else { + runner.fail("delegation amount did not increase"); + } + }); + + await runner.scenario("Delegate without msg.value", async () => { + runner.note("Gringotts.delegate(amount) sends value from the contract balance to the precompile; callers do not send msg.value to Gringotts."); + runner.note("So the old 'without msg.value fails' scenario does not map directly to this EVM API."); + }); + + await runner.scenario("Delegate to invalid validator fails", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + + await expectRevert(runner, "delegate invalid validator", () => + staticCall(contract, actors.operator, "delegate", [config.validators.invalid, config.amounts.delegateWei]) + ); + }); + + await runner.scenario("Delegate to the same validator twice", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + if (!requireValidator(runner, config.validators.primary, "primary")) return; + + if (!config.execution.execute) { + runner.skip("set EXECUTE=true to perform two real delegations"); + return; + } + + await sendTx(config, contract, actors.operator, "delegate", [ + config.validators.primary, + config.amounts.smallWei, + ]); + await sendTx(config, contract, actors.operator, "delegate", [ + config.validators.primary, + config.amounts.smallWei, + ]); + runner.pass("two delegate transactions to same validator sent"); + }); + + await runner.scenario("Undelegate less/exact/more than delegated amount", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + if (!requireValidator(runner, config.validators.primary, "primary")) return; + + const delegation = await contract.getDelegation(config.validators.primary); + const delegatedUsei = delegationAmountUsei(delegation); + if (delegatedUsei === 0n) { + runner.skip("no active delegation for primary validator"); + return; + } + + const delegatedWei = delegatedUsei * 1000000000000n; + const lessWei = delegatedWei > 1000000000000n ? delegatedWei / 2n : delegatedWei; + + await expectSuccess(runner, "undelegate less/equal delegated static-call succeeds", () => + staticCall(contract, actors.operator, "undelegate", [config.validators.primary, lessWei]) + ); + await expectRevert(runner, "undelegate more than delegated fails", () => + staticCall(contract, actors.operator, "undelegate", [config.validators.primary, delegatedWei + 1000000000000n]) + ); + + if (!config.execution.execute) { + runner.skip("set EXECUTE=true to create real unbonding entries"); + return; + } + + await sendTx(config, contract, actors.operator, "undelegate", [config.validators.primary, lessWei]); + runner.pass("undelegate transaction sent; inspect unbonding entries separately"); + }); + + await runner.scenario("Undelegate while rewards exist accounts for rewards separately", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + if (!requireValidator(runner, config.validators.primary, "primary")) return; + + const rewardsBefore = (await contract.getInfo())._withdrawnStakingRewards; + runner.note(`withdrawnStakingRewards before: ${rewardsBefore}`); + + if (!config.execution.execute) { + runner.skip("requires live rewards plus EXECUTE=true"); + return; + } + + await sendTx(config, contract, actors.operator, "undelegate", [ + config.validators.primary, + config.amounts.smallWei, + ]); + const rewardsAfter = (await contract.getInfo())._withdrawnStakingRewards; + runner.pass(`undelegate sent; withdrawnStakingRewards after=${rewardsAfter}`); + }); + + await runner.scenario("Create 7 unbonding entries, verify 8th fails, then retry after maturity", async () => { + if (!config.execution.runStress || !config.execution.execute) { + runner.skip("requires RUN_STRESS=true and EXECUTE=true"); + return; + } + if (!requireActor(runner, actors.operator, "operator")) return; + if (!requireValidator(runner, config.validators.primary, "primary")) return; + + for (let i = 0; i < 7; i++) { + await sendTx(config, contract, actors.operator, "undelegate", [ + config.validators.primary, + config.amounts.smallWei, + ]); + runner.pass(`created unbonding entry ${i + 1}`); + } + + await expectRevert(runner, "8th undelegate fails with max entries", () => + staticCall(contract, actors.operator, "undelegate", [config.validators.primary, config.amounts.smallWei]) + ); + runner.skip("maturity + EndBlock retry requires waiting for chain unbonding time"); + }); + + await runner.scenario("Redelegate less/exact/more and invalid destination cases", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + if (!requireValidator(runner, config.validators.primary, "primary")) return; + if (!requireValidator(runner, config.validators.secondary, "secondary")) return; + + const delegation = await contract.getDelegation(config.validators.primary); + const delegatedUsei = delegationAmountUsei(delegation); + if (delegatedUsei === 0n) { + runner.skip("no active delegation for primary validator"); + return; + } + + const delegatedWei = delegatedUsei * 1000000000000n; + const lessWei = delegatedWei > 1000000000000n ? delegatedWei / 2n : delegatedWei; + + await expectSuccess(runner, "redelegate less/equal delegated static-call succeeds", () => + staticCall(contract, actors.operator, "redelegate", [ + config.validators.primary, + config.validators.secondary, + lessWei, + ]) + ); + await expectRevert(runner, "redelegate more than delegated fails", () => + staticCall(contract, actors.operator, "redelegate", [ + config.validators.primary, + config.validators.secondary, + delegatedWei + 1000000000000n, + ]) + ); + await expectRevert(runner, "redelegate to invalid destination fails", () => + staticCall(contract, actors.operator, "redelegate", [ + config.validators.primary, + config.validators.invalid, + lessWei, + ]) + ); + await expectRevert(runner, "redelegate amount that truncates to zero fails", () => + staticCall(contract, actors.operator, "redelegate", [ + config.validators.primary, + config.validators.secondary, + config.amounts.dustWei, + ]) + ); + + runner.note("same-validator redelegate is not explicitly rejected by Gringotts; expected behavior depends on Sei precompile."); + await expectRevert(runner, "redelegate from validator to itself", () => + staticCall(contract, actors.operator, "redelegate", [ + config.validators.primary, + config.validators.primary, + lessWei, + ]) + ); + }); + + await runner.scenario("Redelegate reward side effects and transitive redelegation", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + if (!requireValidator(runner, config.validators.primary, "primary")) return; + if (!requireValidator(runner, config.validators.secondary, "secondary")) return; + if (!requireValidator(runner, config.validators.tertiary, "tertiary")) return; + + runner.skip("requires controlled live rewards and redelegation completion windows; keep as guarded manual flow"); + }); + + await runner.scenario("Create 7 redelegations for same tuple, verify 8th fails", async () => { + if (!config.execution.runStress || !config.execution.execute) { + runner.skip("requires RUN_STRESS=true and EXECUTE=true"); + return; + } + if (!requireActor(runner, actors.operator, "operator")) return; + if (!requireValidator(runner, config.validators.primary, "primary")) return; + if (!requireValidator(runner, config.validators.secondary, "secondary")) return; + + for (let i = 0; i < 7; i++) { + await sendTx(config, contract, actors.operator, "redelegate", [ + config.validators.primary, + config.validators.secondary, + config.amounts.smallWei, + ]); + runner.pass(`created redelegation entry ${i + 1}`); + } + + await expectRevert(runner, "8th redelegation fails with max entries", () => + staticCall(contract, actors.operator, "redelegate", [ + config.validators.primary, + config.validators.secondary, + config.amounts.smallWei, + ]) + ); + runner.skip("post-completion retry requires waiting for chain redelegation completion time"); + }); + + await runner.scenario("Query unbonding delegations and redelegations", async () => { + if (!requireValidator(runner, config.validators.primary, "primary")) return; + + const unbonding = await staking.delegatorUnbondingDelegations(config.proxyAddress, "0x"); + runner.pass(`queried delegatorUnbondingDelegations`, `${unbonding.unbondingDelegations.length} entries`); + + const redelegations = await staking.redelegations(config.proxyAddress, "", "", "0x"); + runner.pass(`queried redelegations`, `${redelegations.redelegations.length} entries`); + }); + + runner.summary(); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/solidity/scripts/scenario-tests/upgrade-tests.js b/solidity/scripts/scenario-tests/upgrade-tests.js new file mode 100644 index 0000000..c225c17 --- /dev/null +++ b/solidity/scripts/scenario-tests/upgrade-tests.js @@ -0,0 +1,106 @@ +const { + connectGringotts, + ethers, + expectRevert, + loadActors, + loadScenarioConfig, + requireActor, + ScenarioRunner, + staticCall, +} = require("./lib/common"); + +const { + createProposal, + processPassedProposal, + voteUntilPassed, +} = require("./lib/proposals"); + +async function main() { + const config = loadScenarioConfig(); + const runner = new ScenarioRunner("Upgrade Tests", config); + runner.header(); + + const contract = await connectGringotts(config.proxyAddress); + const actors = loadActors(config); + + await runner.scenario("Direct UUPS upgrade remains blocked", async () => { + const currentImplementation = await contract.getImplementation(); + await expectRevert(runner, "direct upgradeToAndCall reverts", () => + staticCall(contract, actors.nonAdmin, "upgradeToAndCall", [currentImplementation, "0x"]) + ); + }); + + await runner.scenario("Upgrade through admin proposal to GringottsV2Dummy and call dummy function", async () => { + if (!requireActor(runner, actors.admin, "admin")) return; + if (actors.admins.length < 2) { + runner.skip("requires enough admin private keys to pass the upgrade proposal"); + return; + } + + const beforeImplementation = await contract.getImplementation(); + const beforeAdmins = await contract.listAdmins(); + const beforeOperators = await contract.listOperators(); + const beforeTotalAmount = await contract.totalAmount(); + runner.note(`current implementation: ${beforeImplementation}`); + + if (!config.execution.execute) { + runner.skip("set EXECUTE=true to deploy V2 implementation and upgrade the live proxy"); + return; + } + + const implementationDeployer = actors.implementationDeployer || actors.admin; + runner.note(`implementation deployer: ${implementationDeployer.address}`); + + const GringottsV2Dummy = await ethers.getContractFactory("GringottsV2Dummy", implementationDeployer); + const newImplementation = await GringottsV2Dummy.deploy(); + await newImplementation.waitForDeployment(); + const newImplementationAddress = await newImplementation.getAddress(); + runner.pass(`deployed GringottsV2Dummy implementation`, newImplementationAddress); + + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpgrade", [ + newImplementationAddress, + ]); + if (proposalId === null) return; + + await voteUntilPassed(config, runner, contract, proposalId, actors.admins); + await processPassedProposal(config, runner, contract, actors.admin, proposalId); + + const afterImplementation = await contract.getImplementation(); + if (afterImplementation === newImplementationAddress) { + runner.pass("proxy implementation updated to GringottsV2Dummy"); + } else { + runner.fail(`expected implementation ${newImplementationAddress}, got ${afterImplementation}`); + } + + const upgraded = GringottsV2Dummy.attach(config.proxyAddress); + const dummyVersion = await upgraded.dummyVersion(); + const dummyNumber = await upgraded.dummyNumber(); + + if (dummyVersion === "gringotts-v2-dummy" && dummyNumber === 2n) { + runner.pass("dummy V2 functions are callable through proxy"); + } else { + runner.fail(`unexpected dummy function result: ${dummyVersion}, ${dummyNumber}`); + } + + const afterAdmins = await contract.listAdmins(); + const afterOperators = await contract.listOperators(); + const afterTotalAmount = await contract.totalAmount(); + + if ( + JSON.stringify(afterAdmins) === JSON.stringify(beforeAdmins) && + JSON.stringify(afterOperators) === JSON.stringify(beforeOperators) && + afterTotalAmount === beforeTotalAmount + ) { + runner.pass("core storage survived upgrade"); + } else { + runner.fail("core storage changed unexpectedly after upgrade"); + } + }); + + runner.summary(); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/solidity/scripts/scenario-tests/vesting-tests.js b/solidity/scripts/scenario-tests/vesting-tests.js new file mode 100644 index 0000000..c382b6f --- /dev/null +++ b/solidity/scripts/scenario-tests/vesting-tests.js @@ -0,0 +1,185 @@ +const { + connectGringotts, + ethers, + expectRevert, + expectSuccess, + loadActors, + loadScenarioConfig, + requireActor, + ScenarioRunner, + sendTx, + staticCall, + weiToSei, +} = require("./lib/common"); + +const { + createProposal, + processPassedProposal, + voteUntilPassed, +} = require("./lib/proposals"); + +async function main() { + const config = loadScenarioConfig(); + const runner = new ScenarioRunner("Vesting Tests", config); + runner.header(); + + const contract = await connectGringotts(config.proxyAddress); + const actors = loadActors(config); + + const [timestamps, amounts] = await contract.getVestingSchedule(); + const info = await contract.getInfo(); + const now = Math.floor(Date.now() / 1000); + + await runner.scenario("Query totalVested before/exactly/just-before vest timestamps", async () => { + if (timestamps.length === 0) { + runner.skip("vesting schedule is empty"); + return; + } + + const totalVested = await contract.getTotalVested(); + runner.note(`now=${now}; firstVest=${timestamps[0]}; current totalVested=${weiToSei(totalVested)} SEI`); + + if (now < Number(timestamps[0])) { + if (totalVested === 0n) runner.pass("before first vest timestamp returns zero"); + else runner.fail(`before first vest expected zero but got ${totalVested}`); + } else { + runner.skip("before-first-timestamp case no longer applies on this deployed schedule"); + } + + const exactIndex = timestamps.findIndex((ts) => Number(ts) === now); + if (exactIndex >= 0) { + const expected = amounts.slice(0, exactIndex + 1).reduce((sum, amount) => sum + amount, 0n); + if (totalVested === expected) runner.pass("exact vest timestamp includes that tranche"); + else runner.fail(`exact vest timestamp expected ${expected} but got ${totalVested}`); + } else { + runner.skip("exact vest timestamp requires running at the exact scheduled second"); + } + + const justBeforeIndex = timestamps.findIndex((ts) => Number(ts) === now + 1); + if (justBeforeIndex >= 0) { + const expected = amounts.slice(0, justBeforeIndex).reduce((sum, amount) => sum + amount, 0n); + if (totalVested === expected) runner.pass("just before vest timestamp excludes that tranche"); + else runner.fail(`just-before timestamp expected ${expected} but got ${totalVested}`); + } else { + runner.skip("just-before timestamp requires running one second before a vest"); + } + }); + + await runner.scenario("Withdraw unlocked amount 0", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + + await expectSuccess(runner, "withdraw unlocked 0 static-call succeeds", () => + staticCall(contract, actors.operator, "initiateWithdrawUnlocked", [0n]) + ); + runner.note("current implementation emits UnlockedWithdrawn(..., 0) if sent as a transaction; it sends zero value"); + }); + + await runner.scenario("Withdraw less/equal/greater than vested amount", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + + const totalVested = await contract.getTotalVested(); + const withdrawable = totalVested > info._withdrawnUnlocked ? totalVested - info._withdrawnUnlocked : 0n; + runner.note(`withdrawable by vesting math now: ${weiToSei(withdrawable)} SEI`); + + if (withdrawable === 0n) { + await expectRevert(runner, "withdraw greater than vested fails", () => + staticCall(contract, actors.operator, "initiateWithdrawUnlocked", [1n]) + ); + runner.skip("less/equal vested withdrawal requires vested principal"); + return; + } + + const lessThanVested = withdrawable > 1n ? withdrawable / 2n : 0n; + if (lessThanVested > 0n) { + await expectSuccess(runner, "withdraw less than vested static-call succeeds", () => + staticCall(contract, actors.operator, "initiateWithdrawUnlocked", [lessThanVested]) + ); + } else { + runner.skip("less-than-vested case needs withdrawable amount > 1 wei"); + } + + await expectSuccess(runner, "withdraw exactly vested static-call succeeds", () => + staticCall(contract, actors.operator, "initiateWithdrawUnlocked", [withdrawable]) + ); + await expectRevert(runner, "withdraw greater than vested fails", () => + staticCall(contract, actors.operator, "initiateWithdrawUnlocked", [withdrawable + 1n]) + ); + + if (!config.execution.execute) { + runner.skip("set EXECUTE=true to actually test state changes for partial/exact withdrawals"); + return; + } + + await sendTx(config, contract, actors.operator, "initiateWithdrawUnlocked", [lessThanVested || withdrawable]); + runner.pass("sent one unlocked withdrawal transaction"); + }); + + await runner.scenario("Withdraw across two vested tranches and fully withdraw all vested tranches", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + if (timestamps.length < 2) { + runner.skip("needs at least two tranches"); + return; + } + + const twoTrancheAmount = amounts[0] + amounts[1] / 2n; + if (now < Number(timestamps[1])) { + runner.skip("second tranche is not vested yet on the live chain"); + return; + } + + await expectSuccess(runner, "withdraw across two vested tranches static-call succeeds", () => + staticCall(contract, actors.operator, "initiateWithdrawUnlocked", [twoTrancheAmount]) + ); + + if (!config.execution.execute) { + runner.skip("set EXECUTE=true to inspect partial remainder after actual withdrawal"); + } + }); + + await runner.scenario("Repeated unlocked withdrawals cannot exceed cumulative vested amount", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + + const totalVested = await contract.getTotalVested(); + await expectRevert(runner, "single call above cumulative vested fails", () => + staticCall(contract, actors.operator, "initiateWithdrawUnlocked", [totalVested + 1n]) + ); + + runner.skip("true repeated-withdrawal check requires EXECUTE=true and changes deployed state"); + }); + + await runner.scenario("Attempt unlocked withdrawal while principal is staked/unbonding", async () => { + if (!requireActor(runner, actors.operator, "operator")) return; + runner.skip("requires a prior full-principal delegate/undelegate sequence and live unbonding window"); + }); + + await runner.scenario("Emergency locked withdrawal through proposal", async () => { + if (!config.execution.allowDestructive) { + runner.skip("emergency withdrawal is destructive; set ALLOW_DESTRUCTIVE=true and EXECUTE=true"); + return; + } + if (!requireActor(runner, actors.admin, "admin")) return; + if (actors.admins.length < 2) { + runner.skip("requires enough admin private keys to pass proposal"); + return; + } + + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeEmergencyWithdraw", [ + info._unlockDistributionAddress, + ]); + if (proposalId === null) return; + + await voteUntilPassed(config, runner, contract, proposalId, actors.admins); + await processPassedProposal(config, runner, contract, actors.admin, proposalId); + + const [afterTimestamps] = await contract.getVestingSchedule(); + if (afterTimestamps.length === 0) runner.pass("emergency withdrawal emptied vesting schedule"); + else runner.fail("vesting schedule is not empty after emergency withdrawal"); + }); + + runner.summary(); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/solidity/test/Gringotts.test.js b/solidity/test/Gringotts.test.js index a18c2bf..60ccec1 100644 --- a/solidity/test/Gringotts.test.js +++ b/solidity/test/Gringotts.test.js @@ -58,6 +58,13 @@ describe("Gringotts on a local Sei chain", function () { return implementation; } + async function deployDummyV2Implementation(signer = wallets.funder) { + const GringottsV2Dummy = await ethers.getContractFactory("GringottsV2Dummy", signer); + const implementation = await GringottsV2Dummy.deploy({ gasLimit: GAS }); + await implementation.waitForDeployment(); + return implementation; + } + async function deployProxy({ implementation, admins = [address("admin1"), address("admin2"), address("admin3")], @@ -733,6 +740,31 @@ describe("Gringotts on a local Sei chain", function () { }); }); + describe("Upgrades", function () { + it("upgrades through admin proposals to an implementation with a new dummy function", async function () { + const { gringotts } = await deployProxy(); + const originalAdmins = await gringotts.listAdmins(); + const originalOperators = await gringotts.listOperators(); + const originalTotalAmount = await gringotts.totalAmount(); + + const newImplementation = await deployDummyV2Implementation(); + await (await gringotts.connect(wallets.admin1).proposeUpgrade(await newImplementation.getAddress(), { + gasLimit: GAS, + })).wait(); + await passAndProcess(gringotts); + + expect(await gringotts.getImplementation()).to.equal(await newImplementation.getAddress()); + expect(await gringotts.listAdmins()).to.deep.equal(originalAdmins); + expect(await gringotts.listOperators()).to.deep.equal(originalOperators); + expect(await gringotts.totalAmount()).to.equal(originalTotalAmount); + + const GringottsV2Dummy = await ethers.getContractFactory("GringottsV2Dummy", wallets.admin1); + const upgraded = GringottsV2Dummy.attach(await gringotts.getAddress()); + expect(await upgraded.dummyVersion()).to.equal("gringotts-v2-dummy"); + expect(await upgraded.dummyNumber()).to.equal(2n); + }); + }); + describe("Factory", function () { it("deploys Gringotts proxies through the factory on the Sei node", async function () { const implementation = await deployImplementation(); From b85586e972054f527588ed6baca4b4fa24b864a2 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 20 May 2026 14:16:38 +0800 Subject: [PATCH 2/2] Add scenario test fixes and operator invariant --- solidity/contracts/Gringotts.sol | 3 + .../gringotts-713714-1779251801379.json | 37 +++++ solidity/scripts/scenario-tests/README.md | 10 ++ .../scenario-tests/governance-admin-tests.js | 10 +- solidity/scripts/scenario-tests/lib/common.js | 126 ++++++++++++++++++ .../scripts/scenario-tests/reward-tests.js | 8 ++ .../scenario.config.example.json | 6 + .../scenario-tests/staking-operation-tests.js | 5 +- .../scripts/scenario-tests/vesting-tests.js | 53 ++++++-- 9 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 solidity/deployments/gringotts-713714-1779251801379.json diff --git a/solidity/contracts/Gringotts.sol b/solidity/contracts/Gringotts.sol index 43544a5..4f23f30 100644 --- a/solidity/contracts/Gringotts.sol +++ b/solidity/contracts/Gringotts.sol @@ -160,6 +160,7 @@ contract Gringotts is Initializable, UUPSUpgradeable, ReentrancyGuardUpgradeable error InvalidImplementation(); error UpgradeNotApproved(); error CannotRemoveLastAdmin(); + error CannotRemoveLastOperator(); error DuplicateAddress(); error InvalidVoteOption(); error GovVoteFailed(); @@ -1086,6 +1087,8 @@ contract Gringotts is Initializable, UUPSUpgradeable, ReentrancyGuardUpgradeable if (!operators[operator]) { return; } + if (operatorCount <= 1) revert CannotRemoveLastOperator(); + operators[operator] = false; operatorCount--; _removeAddressFromList(operatorList, operatorListIndexPlusOne, operator); diff --git a/solidity/deployments/gringotts-713714-1779251801379.json b/solidity/deployments/gringotts-713714-1779251801379.json new file mode 100644 index 0000000..cdf5370 --- /dev/null +++ b/solidity/deployments/gringotts-713714-1779251801379.json @@ -0,0 +1,37 @@ +{ + "deployedAt": "2026-05-20T04:36:41.379Z", + "network": "harbor-shortunbond-testnet", + "chainId": "713714", + "deployer": "0x9eFbcf57C4DE1b750749d840B9fB7CcF03e239Ac", + "configFile": "/tmp/gringotts-deploy-test-config.json", + "implementation": "0x0b18CA044eABCBD5aC80357E9DF6EB28f5385750", + "factory": "0x732F65Ab5Ee3BD5496127705532360D7F84774dF", + "proxy": "0xD1984F3e3b3DbB7425EeCfD9309075355c27a55b", + "createTransaction": "0xf7860f5aea0eeee58a5bffc90998289a824dc6115a247c0c66a27446b4146f7a", + "params": { + "admins": [ + "0x28fc161d44DeBA46E3d4aD2416dB84044035b02a", + "0x806465dc3e5eF9dC5AFf42a5b8744c94f0ddEc78", + "0x06868083F2B914e52dC09A6e22994EA8ff2FDa49" + ], + "operators": [ + "0xAE9Ed5c5397d74FC5FbF396d0e0de4663b4895B9" + ], + "vestingTimestamps": [ + "1779251685", + "1779251715", + "1779251745" + ], + "vestingAmounts": [ + "1000000000000000000", + "1000000000000000000", + "1000000000000000000" + ], + "unlockDistributionAddress": "0x7fd42b44F08eC90Aa7C82f9C40235Dc66b86C201", + "stakingRewardAddress": "0x6199d949c97e818abd967EC9EcA3e89FFbE92C44", + "maxVotingPeriod": "15", + "adminVotingThresholdPercentage": "50", + "totalAmount": "3000000000000000000", + "vestingTotal": "3000000000000000000" + } +} \ No newline at end of file diff --git a/solidity/scripts/scenario-tests/README.md b/solidity/scripts/scenario-tests/README.md index 62645e8..c8ef162 100644 --- a/solidity/scripts/scenario-tests/README.md +++ b/solidity/scripts/scenario-tests/README.md @@ -30,6 +30,7 @@ Then fill in signer private keys and validator addresses: - `adminPrivateKeys`: enough current admin keys to pass proposals. - `operatorPrivateKey`: a current operator key for staking/withdrawal flows. - `implementationDeployerPrivateKey`: optional funded key for `upgrade-tests.js`; it does not need admin permission. +- `distribution.stakingRewardPrivateKey`: optional key for the contract's current staking reward address. If that address is not yet associated, mutating reward-address proposal tests use the default Hardhat signer, or `distribution.associationFunderPrivateKey` when set, to fund it and then send a tiny transaction back so Sei creates the address association. Do not commit this key. ```bash SCENARIO_CONFIG=scripts/scenario-tests/scenario.local.json \ @@ -80,6 +81,15 @@ SCENARIO_CONFIG=scripts/scenario-tests/scenario.local.json \ npx hardhat --config hardhat.harbor-shortunbond.config.js run scripts/scenario-tests/access-tests.js --network harbor-shortunbond-testnet ``` +If the current staking reward address is not associated yet, provide its key when running reward proposal flows. The script preserves the contract's reward address, funds that address from the default Hardhat signer, and sends a tiny transaction back so Sei creates the association before processing the proposal: + +```bash +EXECUTE=true \ +STAKING_REWARD_PRIVATE_KEY=0x... \ +SCENARIO_CONFIG=scripts/scenario-tests/scenario.local.json \ + npx hardhat --config hardhat.harbor-shortunbond.config.js run scripts/scenario-tests/reward-tests.js --network harbor-shortunbond-testnet +``` + Run the live upgrade/migration-style check. This deploys `GringottsV2Dummy`, proposes an upgrade, votes/processes it, then calls the new dummy functions through the existing proxy: ```bash diff --git a/solidity/scripts/scenario-tests/governance-admin-tests.js b/solidity/scripts/scenario-tests/governance-admin-tests.js index 1e4310d..46b569c 100644 --- a/solidity/scripts/scenario-tests/governance-admin-tests.js +++ b/solidity/scripts/scenario-tests/governance-admin-tests.js @@ -1,5 +1,6 @@ const { connectGringotts, + ensureStakingRewardAddressAssociated, ethers, expectRevert, loadActors, @@ -86,6 +87,13 @@ async function main() { } const info = await contract.getInfo(); + const rewardAddressAssociated = await ensureStakingRewardAddressAssociated( + config, + runner, + info._stakingRewardAddress + ); + if (!rewardAddressAssociated) return; + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateStakingRewardDistributionAddress", [ info._stakingRewardAddress, ]); @@ -201,7 +209,7 @@ async function main() { return; } - runner.note("current Gringotts has no CannotRemoveLastOperator invariant; this scenario is expected to reveal that mismatch if executed."); + runner.note("Gringotts should preserve at least one operator so operational actions remain recoverable."); await expectRevert(runner, "last operator removal should fail under CW invariant", () => staticCall(contract, actors.admin, "updateOp", [operators[0], true]) ); diff --git a/solidity/scripts/scenario-tests/lib/common.js b/solidity/scripts/scenario-tests/lib/common.js index e308d04..36cb96f 100644 --- a/solidity/scripts/scenario-tests/lib/common.js +++ b/solidity/scripts/scenario-tests/lib/common.js @@ -5,9 +5,14 @@ const hre = require("hardhat"); const { ethers } = hre; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; +const ADDR_PRECOMPILE = "0x0000000000000000000000000000000000001004"; const STAKING_PRECOMPILE = "0x0000000000000000000000000000000000001005"; const DEFAULT_CONFIG_PATH = "scripts/scenario-tests/scenario.config.example.json"; +const ADDR_ABI = [ + "function getSeiAddr(address addr) view returns (string response)", +]; + const STAKING_ABI = [ "function delegation(address delegator, string valAddress) view returns (tuple(tuple(uint256 amount,string denom) balance, tuple(string delegator_address,uint256 shares,uint256 decimals,string validator_address) delegation))", "function unbondingDelegation(address delegator, string validatorAddress) view returns (tuple(string delegatorAddress,string validatorAddress,tuple(int64 creationHeight,int64 completionTime,string initialBalance,string balance)[] entries))", @@ -75,6 +80,7 @@ function loadScenarioConfig() { const actorConfig = fileConfig.actors || {}; const validatorConfig = fileConfig.validators || {}; const amountConfig = fileConfig.amounts || {}; + const distributionConfig = fileConfig.distribution || {}; const executionConfig = fileConfig.execution || {}; const govConfig = fileConfig.governance || {}; @@ -122,6 +128,26 @@ function loadScenarioConfig() { dustWei: toWei(process.env.DUST_WEI || amountConfig.dustWei || "1", "dustWei"), withdrawWei: toWei(process.env.WITHDRAW_WEI || amountConfig.withdrawWei || ethers.parseEther("0.5").toString(), "withdrawWei"), }, + distribution: { + stakingRewardPrivateKey: + process.env.STAKING_REWARD_PRIVATE_KEY || + distributionConfig.stakingRewardPrivateKey || + "", + associationFunderPrivateKey: + process.env.ASSOCIATION_FUNDER_PRIVATE_KEY || + distributionConfig.associationFunderPrivateKey || + "", + associationFundWei: toWei( + process.env.ASSOCIATION_FUND_WEI || + distributionConfig.associationFundWei || + ethers.parseEther("0.01").toString(), + "associationFundWei" + ), + associationReturnWei: toWei( + process.env.ASSOCIATION_RETURN_WEI || distributionConfig.associationReturnWei || "1", + "associationReturnWei" + ), + }, execution: { execute: parseBool(process.env.EXECUTE, parseBool(executionConfig.execute, false)), waitForExpiry: parseBool(process.env.WAIT_FOR_EXPIRY, parseBool(executionConfig.waitForExpiry, false)), @@ -145,6 +171,23 @@ function connectStakingPrecompile() { return new ethers.Contract(STAKING_PRECOMPILE, STAKING_ABI, ethers.provider); } +function connectAddressPrecompile() { + return new ethers.Contract(ADDR_PRECOMPILE, ADDR_ABI, ethers.provider); +} + +async function getAssociatedSeiAddress(evmAddress) { + return connectAddressPrecompile().getSeiAddr(evmAddress); +} + +async function addressAssociation(address) { + try { + const seiAddress = await getAssociatedSeiAddress(address); + return { associated: true, seiAddress }; + } catch { + return { associated: false, seiAddress: "" }; + } +} + function walletFromPrivateKey(privateKey, label) { if (!privateKey) return null; try { @@ -154,6 +197,85 @@ function walletFromPrivateKey(privateKey, label) { } } +async function signerAddress(signer) { + if (signer.address) return signer.address; + return signer.getAddress(); +} + +async function defaultFunderSigner() { + const [funder] = await ethers.getSigners(); + if (!funder) throw new Error("No default signer available to fund association bootstrap"); + return funder; +} + +async function ensureStakingRewardAddressAssociated(config, runner, rewardAddress) { + const address = ethers.getAddress(rewardAddress); + const current = await addressAssociation(address); + if (current.associated) { + runner.note(`staking reward address already associated as ${current.seiAddress}`); + return true; + } + + if (!config.execution.execute) { + runner.skip("staking reward address is unassociated; set EXECUTE=true with STAKING_REWARD_PRIVATE_KEY to bootstrap it"); + return false; + } + + if (!config.distribution.stakingRewardPrivateKey) { + runner.skip("staking reward address is unassociated; provide distribution.stakingRewardPrivateKey or STAKING_REWARD_PRIVATE_KEY"); + return false; + } + + const rewardWallet = walletFromPrivateKey( + config.distribution.stakingRewardPrivateKey, + "stakingRewardPrivateKey" + ); + if (ethers.getAddress(rewardWallet.address) !== address) { + runner.fail(`stakingRewardPrivateKey resolves to ${rewardWallet.address}, not ${address}`); + return false; + } + + const funder = config.distribution.associationFunderPrivateKey + ? walletFromPrivateKey(config.distribution.associationFunderPrivateKey, "associationFunderPrivateKey") + : await defaultFunderSigner(); + const funderAddress = await signerAddress(funder); + + if (config.distribution.associationFundWei > 0n) { + const fundTx = await funder.sendTransaction( + txOverrides(config, { + to: address, + value: config.distribution.associationFundWei, + }) + ); + await fundTx.wait(); + runner.pass( + "funded staking reward address for association", + `${weiToSei(config.distribution.associationFundWei)} SEI from ${funderAddress}` + ); + } + + const associationTx = await rewardWallet.sendTransaction( + txOverrides(config, { + to: funderAddress, + value: config.distribution.associationReturnWei, + }) + ); + await associationTx.wait(); + runner.pass( + "staking reward address sent association transaction", + `${weiToSei(config.distribution.associationReturnWei)} SEI to ${funderAddress}` + ); + + const updated = await addressAssociation(address); + if (!updated.associated) { + runner.fail("staking reward address is still unassociated after bootstrap transaction"); + return false; + } + + runner.pass("staking reward address associated", updated.seiAddress); + return true; +} + function randomReadOnlyWallet(label) { const wallet = ethers.Wallet.createRandom().connect(ethers.provider); wallet.__scenarioLabel = `${label} (random eth_call-only wallet)`; @@ -327,6 +449,10 @@ module.exports = { loadScenarioConfig, connectGringotts, connectStakingPrecompile, + connectAddressPrecompile, + getAssociatedSeiAddress, + addressAssociation, + ensureStakingRewardAddressAssociated, loadActors, txOverrides, staticCall, diff --git a/solidity/scripts/scenario-tests/reward-tests.js b/solidity/scripts/scenario-tests/reward-tests.js index f7070aa..43bbd22 100644 --- a/solidity/scripts/scenario-tests/reward-tests.js +++ b/solidity/scripts/scenario-tests/reward-tests.js @@ -1,5 +1,6 @@ const { connectGringotts, + ensureStakingRewardAddressAssociated, expectSuccess, loadActors, loadScenarioConfig, @@ -112,6 +113,13 @@ async function main() { } const info = await contract.getInfo(); + const rewardAddressAssociated = await ensureStakingRewardAddressAssociated( + config, + runner, + info._stakingRewardAddress + ); + if (!rewardAddressAssociated) return; + const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeUpdateStakingRewardDistributionAddress", [ info._stakingRewardAddress, ]); diff --git a/solidity/scripts/scenario-tests/scenario.config.example.json b/solidity/scripts/scenario-tests/scenario.config.example.json index 26a8ece..4beea24 100644 --- a/solidity/scripts/scenario-tests/scenario.config.example.json +++ b/solidity/scripts/scenario-tests/scenario.config.example.json @@ -22,6 +22,12 @@ "dustWei": "1", "withdrawWei": "500000000000000000" }, + "distribution": { + "stakingRewardPrivateKey": "", + "associationFunderPrivateKey": "", + "associationFundWei": "10000000000000000", + "associationReturnWei": "1" + }, "governance": { "govProposalId": 0, "voteOption": 1 diff --git a/solidity/scripts/scenario-tests/staking-operation-tests.js b/solidity/scripts/scenario-tests/staking-operation-tests.js index 15f3224..b83c85c 100644 --- a/solidity/scripts/scenario-tests/staking-operation-tests.js +++ b/solidity/scripts/scenario-tests/staking-operation-tests.js @@ -3,6 +3,7 @@ const { connectStakingPrecompile, expectRevert, expectSuccess, + getAssociatedSeiAddress, loadActors, loadScenarioConfig, requireActor, @@ -259,7 +260,9 @@ async function main() { const unbonding = await staking.delegatorUnbondingDelegations(config.proxyAddress, "0x"); runner.pass(`queried delegatorUnbondingDelegations`, `${unbonding.unbondingDelegations.length} entries`); - const redelegations = await staking.redelegations(config.proxyAddress, "", "", "0x"); + // redelegations takes a bech32 delegator string, unlike the other delegator queries. + const delegatorSeiAddress = await getAssociatedSeiAddress(config.proxyAddress); + const redelegations = await staking.redelegations(delegatorSeiAddress, "", "", "0x"); runner.pass(`queried redelegations`, `${redelegations.redelegations.length} entries`); }); diff --git a/solidity/scripts/scenario-tests/vesting-tests.js b/solidity/scripts/scenario-tests/vesting-tests.js index c382b6f..2506282 100644 --- a/solidity/scripts/scenario-tests/vesting-tests.js +++ b/solidity/scripts/scenario-tests/vesting-tests.js @@ -18,6 +18,28 @@ const { voteUntilPassed, } = require("./lib/proposals"); +function vestedAmountFromSchedule(timestamps, amounts, timestamp) { + return amounts.reduce((sum, amount, index) => { + if (Number(timestamps[index]) > timestamp) return sum; + return sum + amount; + }, 0n); +} + +function vestedTrancheIndexes(timestamps, timestamp) { + return timestamps + .map((ts, index) => (Number(ts) <= timestamp ? index : -1)) + .filter((index) => index >= 0); +} + +async function loadVestingState(contract) { + const [timestamps, amounts] = await contract.getVestingSchedule(); + const info = await contract.getInfo(); + const latestBlock = await ethers.provider.getBlock("latest"); + const now = Number(latestBlock.timestamp); + + return { timestamps, amounts, info, now }; +} + async function main() { const config = loadScenarioConfig(); const runner = new ScenarioRunner("Vesting Tests", config); @@ -26,11 +48,8 @@ async function main() { const contract = await connectGringotts(config.proxyAddress); const actors = loadActors(config); - const [timestamps, amounts] = await contract.getVestingSchedule(); - const info = await contract.getInfo(); - const now = Math.floor(Date.now() / 1000); - await runner.scenario("Query totalVested before/exactly/just-before vest timestamps", async () => { + const { timestamps, amounts, now } = await loadVestingState(contract); if (timestamps.length === 0) { runner.skip("vesting schedule is empty"); return; @@ -77,9 +96,11 @@ async function main() { await runner.scenario("Withdraw less/equal/greater than vested amount", async () => { if (!requireActor(runner, actors.operator, "operator")) return; + const { timestamps, amounts, info, now } = await loadVestingState(contract); const totalVested = await contract.getTotalVested(); - const withdrawable = totalVested > info._withdrawnUnlocked ? totalVested - info._withdrawnUnlocked : 0n; - runner.note(`withdrawable by vesting math now: ${weiToSei(withdrawable)} SEI`); + const withdrawable = vestedAmountFromSchedule(timestamps, amounts, now); + runner.note(`withdrawable in current vesting schedule now: ${weiToSei(withdrawable)} SEI`); + runner.note(`getTotalVested=${weiToSei(totalVested)} SEI; withdrawnUnlocked=${weiToSei(info._withdrawnUnlocked)} SEI`); if (withdrawable === 0n) { await expectRevert(runner, "withdraw greater than vested fails", () => @@ -116,23 +137,36 @@ async function main() { await runner.scenario("Withdraw across two vested tranches and fully withdraw all vested tranches", async () => { if (!requireActor(runner, actors.operator, "operator")) return; + const { timestamps, amounts, now } = await loadVestingState(contract); if (timestamps.length < 2) { runner.skip("needs at least two tranches"); return; } - const twoTrancheAmount = amounts[0] + amounts[1] / 2n; - if (now < Number(timestamps[1])) { - runner.skip("second tranche is not vested yet on the live chain"); + const vestedIndexes = vestedTrancheIndexes(timestamps, now); + if (vestedIndexes.length < 2) { + runner.skip("needs at least two currently vested remaining tranches"); return; } + const firstVestedIndex = vestedIndexes[0]; + const secondVestedIndex = vestedIndexes[1]; + const secondPartialAmount = amounts[secondVestedIndex] > 1n + ? amounts[secondVestedIndex] / 2n + : amounts[secondVestedIndex]; + const twoTrancheAmount = amounts[firstVestedIndex] + secondPartialAmount; + const allVestedAmount = vestedAmountFromSchedule(timestamps, amounts, now); + await expectSuccess(runner, "withdraw across two vested tranches static-call succeeds", () => staticCall(contract, actors.operator, "initiateWithdrawUnlocked", [twoTrancheAmount]) ); + await expectSuccess(runner, "withdraw all currently vested tranches static-call succeeds", () => + staticCall(contract, actors.operator, "initiateWithdrawUnlocked", [allVestedAmount]) + ); if (!config.execution.execute) { runner.skip("set EXECUTE=true to inspect partial remainder after actual withdrawal"); + return; } }); @@ -163,6 +197,7 @@ async function main() { return; } + const { info } = await loadVestingState(contract); const proposalId = await createProposal(config, runner, contract, actors.admin, "proposeEmergencyWithdraw", [ info._unlockDistributionAddress, ]);