From e2e88db3aaba3de24f4702a4da9346527ea30c38 Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 10 Apr 2025 16:07:21 -0700 Subject: [PATCH 01/52] chore: Add Yearn BORG architectures --- README-yearnBorg.md | 58 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 README-yearnBorg.md diff --git a/README-yearnBorg.md b/README-yearnBorg.md new file mode 100644 index 0000000..8140fb5 --- /dev/null +++ b/README-yearnBorg.md @@ -0,0 +1,58 @@ +# Yearn BORG + +## BORG Architectures + +```mermaid +graph TD + ychad[ychad.eth
6/9 signers] + yearnDaoVoting[Yearn DAO Snapshot Voting] + govExecutor[/TODO governanceExecutor?/] + snapshotProposer[/TODO proposer?/] + + %% TODO TBD + tempOwner[/TODO owner?/] + recoveryAddr[/TODO recovery address?/] + + borg{{Yearn BORG
BORG Core}} + + subgraph implants + failSafeImplant{{Failsafe}} + ejectImplant{{Eject}} + voteImplant{{Vote}} + end + + ychad -->|"proposeTransaction(addMember)"| voteImplant + + govExecutor -->|monitor| yearnDaoVoting + + snapshotProposer -->|monitor| voteImplant + snapshotProposer -->|propose| yearnDaoVoting + + borg -->|guard| ychad + + implants -->|modules| ychad + + failSafeImplant -->|failSafe| ejectImplant + + tempOwner -->|owner| borg + tempOwner -->|owner| implants + + recoveryAddr -->|recovery address| failSafeImplant + + govExecutor -->|"executeProposal(addMemberProposalId)"| voteImplant + + %% Styling (optional, Mermaid supports limited styling) + classDef default fill:#191918,stroke:#fff,stroke-width:2px,color:#fff; + classDef borg fill:#191918,stroke:#E1FE52,stroke-width:2px,color:#E1FE52; + classDef safe fill:#191918,stroke:#76FB8D,stroke-width:2px,color:#76FB8D; + classDef todo fill:#191918,stroke:#F09B4A,stroke-width:2px,color:#F09B4A; + class borg borg; + class failSafeImplant borg; + class ejectImplant borg; + class voteImplant borg; + class ychad safe; + class tempOwner todo; + class recoveryAddr todo; + class govExecutor todo; + class snapshotProposer todo; +``` From ee42434c8fd3694a13256a89d7c514ee5ddaa5cf Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 10 Apr 2025 16:35:31 -0700 Subject: [PATCH 02/52] chore: Add member-management workflow --- README-yearnBorg.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index 8140fb5..8a78511 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -7,7 +7,7 @@ graph TD ychad[ychad.eth
6/9 signers] yearnDaoVoting[Yearn DAO Snapshot Voting] govExecutor[/TODO governanceExecutor?/] - snapshotProposer[/TODO proposer?/] + snapshotProposer[/TODO Snapshot proposer?/] %% TODO TBD tempOwner[/TODO owner?/] @@ -56,3 +56,13 @@ graph TD class govExecutor todo; class snapshotProposer todo; ``` + +## Member Management Voting Workflow + +Example below demonstrates adding `alice` as a new signer to `ychad.eth`, +but it also works for broader multisig operations that demand DAO voting. + +1. `ychad.eth` approves and calls `voteImplant.proposeTransaction("addMember(alice)")` +2. Snapshot Proposer sees the proposal on-chain and proposes voting on Snapshot +3. `governanceExecutor` sees the vote is passed and calls `voteImplant.executeProposal(proposalId)` +4. `alice` is now added to `ychad.eth` From 3c7503ab94e2b90a6067a17202ad68f89e095156 Mon Sep 17 00:00:00 2001 From: detoo Date: Fri, 11 Apr 2025 13:13:03 -0700 Subject: [PATCH 03/52] chore: Use SnapshotExecutor for member-management workflow --- README-yearnBorg.md | 116 +++++++++++++++++++++++--------------------- 1 file changed, 61 insertions(+), 55 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index 8a78511..95cc6f3 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -4,65 +4,71 @@ ```mermaid graph TD - ychad[ychad.eth
6/9 signers] - yearnDaoVoting[Yearn DAO Snapshot Voting] - govExecutor[/TODO governanceExecutor?/] - snapshotProposer[/TODO Snapshot proposer?/] - - %% TODO TBD - tempOwner[/TODO owner?/] - recoveryAddr[/TODO recovery address?/] - - borg{{Yearn BORG
BORG Core}} - - subgraph implants - failSafeImplant{{Failsafe}} - ejectImplant{{Eject}} - voteImplant{{Vote}} - end + ychad[ychad.eth
6/9 signers] + yearnDaoVoting[Yearn DAO Snapshot Voting] + + %% TODO TBD + tempOwner[/TODO owner?/] + recoveryAddr[/TODO recovery address?/] + oracleAddr[/TODO oracle address?/] + + borg{{Yearn BORG
BORG Core}} + + subgraph implants + failSafeImplant{{Failsafe Implant}} + ejectImplant{{Eject Implant}} + + signatureCondition{{Signature Condition}} + end + + snapshotExecutor[SnapshotExecutor

TODO Waiting period?] + + ychad -->|"sign() / revokeSignature()"| signatureCondition - ychad -->|"proposeTransaction(addMember)"| voteImplant - - govExecutor -->|monitor| yearnDaoVoting - - snapshotProposer -->|monitor| voteImplant - snapshotProposer -->|propose| yearnDaoVoting - - borg -->|guard| ychad - - implants -->|modules| ychad - - failSafeImplant -->|failSafe| ejectImplant - - tempOwner -->|owner| borg - tempOwner -->|owner| implants - - recoveryAddr -->|recovery address| failSafeImplant - - govExecutor -->|"executeProposal(addMemberProposalId)"| voteImplant - - %% Styling (optional, Mermaid supports limited styling) - classDef default fill:#191918,stroke:#fff,stroke-width:2px,color:#fff; - classDef borg fill:#191918,stroke:#E1FE52,stroke-width:2px,color:#E1FE52; - classDef safe fill:#191918,stroke:#76FB8D,stroke-width:2px,color:#76FB8D; - classDef todo fill:#191918,stroke:#F09B4A,stroke-width:2px,color:#F09B4A; - class borg borg; - class failSafeImplant borg; - class ejectImplant borg; - class voteImplant borg; - class ychad safe; - class tempOwner todo; - class recoveryAddr todo; - class govExecutor todo; - class snapshotProposer todo; + oracleAddr -->|monitor| yearnDaoVoting + oracleAddr -->|"propose(addOwner() / swapOwner())"| snapshotExecutor + + borg -->|guard| ychad + + implants -->|modules| ychad + + failSafeImplant -->|failSafe| ejectImplant + + signatureCondition -->|conditions| ejectImplant + + snapshotExecutor -->|"addOwner() / swapOwner()"| ejectImplant + + tempOwner -->|owner| borg + tempOwner -->|owner| implants + + recoveryAddr -->|recovery address| failSafeImplant + + %% Styling (optional, Mermaid supports limited styling) + classDef default fill:#191918,stroke:#fff,stroke-width:2px,color:#fff; + classDef borg fill:#191918,stroke:#E1FE52,stroke-width:2px,color:#E1FE52; + classDef safe fill:#191918,stroke:#76FB8D,stroke-width:2px,color:#76FB8D; + classDef todo fill:#191918,stroke:#F09B4A,stroke-width:2px,color:#F09B4A; + class borg borg; + class failSafeImplant borg; + class ejectImplant borg; + class voteImplant borg; + class signatureCondition borg; + class ychad safe; + class tempOwner todo; + class recoveryAddr todo; + class oracleAddr todo; + class govExecutor todo; + class snapshotProposer todo; ``` ## Member Management Voting Workflow -Example below demonstrates adding `alice` as a new signer to `ychad.eth`, -but it also works for broader multisig operations that demand DAO voting. +Example below demonstrates adding `alice` as a new signer to `ychad.eth`. -1. `ychad.eth` approves and calls `voteImplant.proposeTransaction("addMember(alice)")` -2. Snapshot Proposer sees the proposal on-chain and proposes voting on Snapshot -3. `governanceExecutor` sees the vote is passed and calls `voteImplant.executeProposal(proposalId)` +1. DAO proposes to add `alice` to `ychad.eth` +2. Once passed on Snapshot voting, `oracle` calls `SnapshotExecutor.propose(addOwner(alice))` +3. `ychad.eth` does one of the following: + - To approve it, call `SignatureCondition.sign()` if not yet done + - To reject it, call `SignatureCondition.revokeSignature()` if not yet done +3. Once `ychad.eth` approved and the proposal waiting period is passed, any can call `SnapshotExecutor.execute(proposalId)` 4. `alice` is now added to `ychad.eth` From f8748ed01b41e787e5bcdaab443aaf34587b4c62 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 14 Apr 2025 10:18:35 -0700 Subject: [PATCH 04/52] chore: Clarify BORG modules ownership and member management --- README-yearnBorg.md | 60 +++++++++++++++++---------------------------- 1 file changed, 23 insertions(+), 37 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index 95cc6f3..e315be4 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -5,43 +5,34 @@ ```mermaid graph TD ychad[ychad.eth
6/9 signers] - yearnDaoVoting[Yearn DAO Snapshot Voting] + ychadSigner[ychad.eth signer] + yearnDaoVoting[Yearn DAO Voting Snapshot] - %% TODO TBD - tempOwner[/TODO owner?/] - recoveryAddr[/TODO recovery address?/] - oracleAddr[/TODO oracle address?/] + oracleAddr[oracle] borg{{Yearn BORG
BORG Core}} - subgraph implants - failSafeImplant{{Failsafe Implant}} - ejectImplant{{Eject Implant}} - - signatureCondition{{Signature Condition}} - end - - snapshotExecutor[SnapshotExecutor

TODO Waiting period?] - + ejectImplant{{Eject Implant}} + + signatureCondition[Signature Condition] + + snapshotExecutor[SnapshotExecutor] + + ychad -->|"owner / guard by"| borg ychad -->|"sign() / revokeSignature()"| signatureCondition + + ychadSigner -->|signer| ychad + ychadSigner -->|"selfEject()"| ejectImplant oracleAddr -->|monitor| yearnDaoVoting - oracleAddr -->|"propose(addOwner() / swapOwner())"| snapshotExecutor - - borg -->|guard| ychad - - implants -->|modules| ychad - - failSafeImplant -->|failSafe| ejectImplant - - signatureCondition -->|conditions| ejectImplant + oracleAddr -->|"propose(member management func)"| snapshotExecutor - snapshotExecutor -->|"addOwner() / swapOwner()"| ejectImplant + ejectImplant -->|module| ychad - tempOwner -->|owner| borg - tempOwner -->|owner| implants + signatureCondition -->|"conditions(member management func)"| ejectImplant - recoveryAddr -->|recovery address| failSafeImplant + snapshotExecutor -->|owner| ejectImplant + snapshotExecutor -->|"call member management func"| ejectImplant %% Styling (optional, Mermaid supports limited styling) classDef default fill:#191918,stroke:#fff,stroke-width:2px,color:#fff; @@ -49,26 +40,21 @@ graph TD classDef safe fill:#191918,stroke:#76FB8D,stroke-width:2px,color:#76FB8D; classDef todo fill:#191918,stroke:#F09B4A,stroke-width:2px,color:#F09B4A; class borg borg; - class failSafeImplant borg; class ejectImplant borg; - class voteImplant borg; class signatureCondition borg; + class snapshotExecutor borg; + class oracleAddr borg; class ychad safe; - class tempOwner todo; - class recoveryAddr todo; - class oracleAddr todo; - class govExecutor todo; - class snapshotProposer todo; ``` -## Member Management Voting Workflow +## Member Management Workflow Example below demonstrates adding `alice` as a new signer to `ychad.eth`. -1. DAO proposes to add `alice` to `ychad.eth` +1. DAO proposes adding `alice` to `ychad.eth` through SnapshotExecutor service 2. Once passed on Snapshot voting, `oracle` calls `SnapshotExecutor.propose(addOwner(alice))` 3. `ychad.eth` does one of the following: - To approve it, call `SignatureCondition.sign()` if not yet done - To reject it, call `SignatureCondition.revokeSignature()` if not yet done -3. Once `ychad.eth` approved and the proposal waiting period is passed, any can call `SnapshotExecutor.execute(proposalId)` +3. Once `ychad.eth` approved and the proposal waiting period is passed, anyone can call `SnapshotExecutor.execute(proposalId)` 4. `alice` is now added to `ychad.eth` From a1108fb1386d2b3b54258e788986c581e8fe8e86 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 14 Apr 2025 13:09:05 -0700 Subject: [PATCH 05/52] chore: Simplify member management voting workflow --- README-yearnBorg.md | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index e315be4..69fc06d 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -14,25 +14,20 @@ graph TD ejectImplant{{Eject Implant}} - signatureCondition[Signature Condition] - snapshotExecutor[SnapshotExecutor] ychad -->|"owner / guard by"| borg - ychad -->|"sign() / revokeSignature()"| signatureCondition + ychad -->|"owner / execute()"| snapshotExecutor ychadSigner -->|signer| ychad ychadSigner -->|"selfEject()"| ejectImplant + oracleAddr -->|"oracle / propose(member management func)"| snapshotExecutor oracleAddr -->|monitor| yearnDaoVoting - oracleAddr -->|"propose(member management func)"| snapshotExecutor ejectImplant -->|module| ychad - signatureCondition -->|"conditions(member management func)"| ejectImplant - - snapshotExecutor -->|owner| ejectImplant - snapshotExecutor -->|"call member management func"| ejectImplant + snapshotExecutor -->|"owner / member management func()"| ejectImplant %% Styling (optional, Mermaid supports limited styling) classDef default fill:#191918,stroke:#fff,stroke-width:2px,color:#fff; @@ -41,7 +36,6 @@ graph TD classDef todo fill:#191918,stroke:#F09B4A,stroke-width:2px,color:#F09B4A; class borg borg; class ejectImplant borg; - class signatureCondition borg; class snapshotExecutor borg; class oracleAddr borg; class ychad safe; @@ -49,12 +43,7 @@ graph TD ## Member Management Workflow -Example below demonstrates adding `alice` as a new signer to `ychad.eth`. - -1. DAO proposes adding `alice` to `ychad.eth` through SnapshotExecutor service -2. Once passed on Snapshot voting, `oracle` calls `SnapshotExecutor.propose(addOwner(alice))` -3. `ychad.eth` does one of the following: - - To approve it, call `SignatureCondition.sign()` if not yet done - - To reject it, call `SignatureCondition.revokeSignature()` if not yet done -3. Once `ychad.eth` approved and the proposal waiting period is passed, anyone can call `SnapshotExecutor.execute(proposalId)` -4. `alice` is now added to `ychad.eth` +1. Action is initiated on the MetaLeX OS webapp +2. A Snapshot proposal will be submitted via API using Yearn's existing voting settings +3. MetaLeX's Snapshot oracle will submit the results onchain to an executor contract, which will have the proposed transaction pending for co-approval +4. ychad.eth will submit co-approval / execute the action through the MetaLeX OS webapp From 14f12e8d648fc78e4ac45589f71db3fdf65cac09 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 14 Apr 2025 14:29:33 -0700 Subject: [PATCH 06/52] feat: Pull previous SnapShotExecutor because it fits the requirements better --- src/libs/governance/snapShotExecutor.sol | 127 +++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/libs/governance/snapShotExecutor.sol diff --git a/src/libs/governance/snapShotExecutor.sol b/src/libs/governance/snapShotExecutor.sol new file mode 100644 index 0000000..e6e5674 --- /dev/null +++ b/src/libs/governance/snapShotExecutor.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import "../auth.sol"; +import "openzeppelin/contracts/utils/Address.sol"; + +contract SnapShotExecutor is BorgAuthACL { + + address public borgSafe; + address public oracle; + uint256 public waitingPeriod; + uint256 public threshold; + uint256 public postVetoWaitingPeriod; // TODO Review against clearBorg version + uint256 public pendingProposalCount; + uint256 public pendingProposalLimit; + + struct proposal { + address target; + uint256 value; + bytes cdata; + string description; + uint256 timestamp; + } + + error SnapShotExecutor_NotAuthorized(); + error SnapShotExecutor_InvalidProposal(); + error SnapShotExecutor_ExecutionFailed(); + error SnapShotExecutor_ZeroAddress(); + error SnapShotExecutor_WaitingPeriod(); + error SnapShotExeuctor_InvalidParams(); + error SnapShotExecutor_AlreadyVoted(); + error SnapShotExecutor_TooManyPendingProposals(); + + //events + event ProposalCreated(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); + event ProposalExecuted(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp, bool success); + event ProposalCanceled(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); + event VotedToCancel(address indexed voter, bytes32 proposalId); + + mapping(bytes32 => proposal) public pendingProposals; + mapping(bytes32 => address[]) public cancelVotes; + + modifier onlyOracle() { + if (msg.sender != oracle) revert SnapShotExecutor_NotAuthorized(); + _; + } + + constructor(BorgAuth _auth, address _borgSafe, address _oracle, uint256 _waitingPeriod, uint256 threshold, uint256 _pendingProposals) BorgAuthACL(_auth) { + if(_borgSafe == address(0) || _oracle == address(0)) revert SnapShotExecutor_ZeroAddress(); + borgSafe = _borgSafe; + oracle = _oracle; + if(_waitingPeriod < 1 minutes) revert SnapShotExeuctor_InvalidParams(); + waitingPeriod = _waitingPeriod; + if(threshold < 2) revert SnapShotExeuctor_InvalidParams(); + threshold = threshold; + pendingProposalLimit = _pendingProposals; + } + + function propose(address target, uint256 value, bytes calldata cdata, string memory description) external onlyOracle() returns (bytes32) { + // TODO Review against clearBorg version + if(block.timestamp < postVetoWaitingPeriod) revert SnapShotExecutor_WaitingPeriod(); + if(pendingProposalCount>pendingProposalLimit) revert SnapShotExecutor_TooManyPendingProposals(); + bytes32 proposalId = keccak256(abi.encodePacked(target, value, cdata, description)); + pendingProposals[proposalId] = proposal(target, value, cdata, description, block.timestamp + waitingPeriod); + pendingProposalCount++; + emit ProposalCreated(proposalId, target, value, cdata, description, block.timestamp + waitingPeriod); + return proposalId; + } + + function execute(bytes32 proposalId) payable external onlyOwner() { + proposal memory p = pendingProposals[proposalId]; + if (p.timestamp > block.timestamp) revert SnapShotExecutor_WaitingPeriod(); + if(p.target == address(0)) revert SnapShotExecutor_InvalidProposal(); + (bool success, bytes memory returndata) = p.target.call{value: p.value}(p.cdata); + emit ProposalExecuted(proposalId, p.target, p.value, p.cdata, p.description, p.timestamp, success); + pendingProposalCount--; + delete pendingProposals[proposalId]; + } + + // TODO Review against clearBorg version + function voteToCancel(bytes32 proposalId) external { + if(pendingProposals[proposalId].timestamp < block.timestamp) revert SnapShotExecutor_InvalidProposal(); + if(msg.sender != borgSafe) + { + address adapter = AUTH.roleAdapters(AUTH.OWNER_ROLE()); + if(adapter == address(0)) revert SnapShotExecutor_NotAuthorized(); + if (!(IAuthAdapter(adapter).isAuthorized(msg.sender) >= AUTH.OWNER_ROLE())) revert SnapShotExecutor_NotAuthorized(); + } + if(alreadyVotedCheck(proposalId)) revert SnapShotExecutor_AlreadyVoted(); + cancelVotes[proposalId].push(msg.sender); + emit VotedToCancel(msg.sender, proposalId); + + //Check if the proposal should be canceled + bool hasBORG = (msg.sender == borgSafe); + if(!hasBORG) { + for(uint256 i = 0; i < cancelVotes[proposalId].length; i++) { + if(cancelVotes[proposalId][i] == borgSafe) { + hasBORG = true; + break; + } + } + } + if(cancelVotes[proposalId].length >= threshold && hasBORG) { + cancel(proposalId); + } + } + + // TODO Review against clearBorg version + function alreadyVotedCheck(bytes32 proposalId) internal view returns (bool) { + for(uint256 i = 0; i < cancelVotes[proposalId].length; i++) { + if(cancelVotes[proposalId][i] == msg.sender) { + return true; + } + } + return false; + } + + // TODO Review against clearBorg version + function cancel(bytes32 proposalId) internal { + proposal memory p = pendingProposals[proposalId]; + delete pendingProposals[proposalId]; + postVetoWaitingPeriod = block.timestamp + 5 minutes; + pendingProposalCount--; + emit ProposalCanceled(proposalId, p.target, p.value, p.cdata, p.description, p.timestamp); + } + +} \ No newline at end of file From 9a2419e54252721c62d31ed5af826741bca78ef0 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 14 Apr 2025 17:50:34 -0700 Subject: [PATCH 07/52] wip: feat: Yearn BORG deploy scripts and tests --- .gitignore | 2 +- README-yearnBorg.md | 18 ++ scripts/yearnBorg.s.sol | 130 +++++++++++++ test/libraries/safe.t.sol | 2 + test/libraries/safeTxHelper.sol | 319 ++++++++++++++++++++++++++++++++ test/yearnBorg.t.sol | 18 ++ test/yearnBorgAcceptance.t.sol | 103 +++++++++++ 7 files changed, 591 insertions(+), 1 deletion(-) create mode 100644 scripts/yearnBorg.s.sol create mode 100644 test/libraries/safeTxHelper.sol create mode 100644 test/yearnBorg.t.sol create mode 100644 test/yearnBorgAcceptance.t.sol diff --git a/.gitignore b/.gitignore index a71d847..d1b5248 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ cache/ out/ -.env +.env* test-command.txt broadcast/ diff --git a/README-yearnBorg.md b/README-yearnBorg.md index 69fc06d..d799883 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -47,3 +47,21 @@ graph TD 2. A Snapshot proposal will be submitted via API using Yearn's existing voting settings 3. MetaLeX's Snapshot oracle will submit the results onchain to an executor contract, which will have the proposed transaction pending for co-approval 4. ychad.eth will submit co-approval / execute the action through the MetaLeX OS webapp + +## Tests + +### Integration Tests + +Test the deployment scripts and verify the results. + +```bash +forge test --optimize --optimizer-runs 200 --use solc:0.8.20 --via-ir --fork-url --fork-block-number 22268905 --mc YearnBorgTest +``` + +### Acceptance Tests + +Verify the specified deployment results. + +```bash +forge test --optimize --optimizer-runs 200 --use solc:0.8.20 --via-ir --fork-url --fork-block-number --mc YearnBorgAcceptanceTest +``` diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol new file mode 100644 index 0000000..55dcbd8 --- /dev/null +++ b/scripts/yearnBorg.s.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; +import {borgCore} from "../src/borgCore.sol"; +import {ejectImplant} from "../src/implants/ejectImplant.sol"; +import {optimisticGrantImplant} from "../src/implants/optimisticGrantImplant.sol"; +import {daoVoteGrantImplant} from "../src/implants/daoVoteGrantImplant.sol"; +import {daoVetoGrantImplant} from "../src/implants/daoVetoGrantImplant.sol"; +import {daoVetoImplant} from "../src/implants/daoVetoImplant.sol"; +import {daoVoteImplant} from "../src/implants/daoVoteImplant.sol"; +import {SignatureCondition} from "../src/libs/conditions/signatureCondition.sol"; +import {BorgAuth} from "../src/libs/auth.sol"; +import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; +import {SafeTxHelper} from "../test/libraries/safeTxHelper.sol"; +import {IGnosisSafe, GnosisTransaction} from "../test/libraries/safe.t.sol"; + +contract MockFailSafeImplant { + uint256 public immutable IMPLANT_ID = 0; + + error MockFailSafeImplant_UnexpectedTrigger(); + + function recoverSafeFunds() external { + revert MockFailSafeImplant_UnexpectedTrigger(); + } +} + +contract YearnBorgDeployScript is Script, SafeTxHelper { + // Configs: BORG Core + + string borgIdentifier = "Yearn BORG"; // TODO WIP Ask for confirmation + borgCore.borgModes borgMode = borgCore.borgModes.unrestricted; + uint256 borgType = 0x3; // TODO WIP Ask for confirmation + + // Configs: SnapShowExecutor + + uint256 snapShotWaitingPeriod = 3 days; + uint256 snapShotThreshold = 2; + uint256 snapShotPendingProposalLimit = 3; + address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00; + + borgCore core; + BorgAuth coreAuth; + BorgAuth ejectAuth; + ejectImplant eject; + SnapShotExecutor snapShotExecutor; + + constructor() SafeTxHelper( + // TODO test +// 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52, // ychad.eth + 0xa2536225f0c0979D119E1877100f514179339700, // test + + // TODO deprecated: This is no longer useful for non 1/1 multisig + vm.envUint("PRIVATE_KEY_BORG_MEMBER_A") // Signer + ) {} + + function run() public returns(borgCore, ejectImplant, SnapShotExecutor) { + console2.log("block number:", block.number); + + console2.log("Configs:"); + console2.log(" BORG name:", borgIdentifier); + console2.log(" BORG mode:", uint8(borgMode)); + console2.log(" BORG type:", borgType); + console2.log(" Safe Multisig:", address(safe)); + console2.log(" Snapshot waiting period (secs.):", snapShotWaitingPeriod); + console2.log(" Snapshot threshold:", snapShotThreshold); + console2.log(" Snapshot pending proposal limit:", snapShotPendingProposalLimit); + + // TODO test +// uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY_DEPLOYER"); +// address deployerAddress = vm.addr(deployerPrivateKey); +// +// console2.log("deployer:", deployerAddress); +// +// vm.startBroadcast(deployerPrivateKey); + + address deployerAddress = vm.addr(signerPrivateKey); + console2.log("Deployer:", deployerAddress); + + vm.startBroadcast(signerPrivateKey); + + // Core + + coreAuth = new BorgAuth(); + core = new borgCore(coreAuth, borgType, borgMode, borgIdentifier, address(safe)); + + // SnapShotExecutor + + snapShotExecutor = new SnapShotExecutor(coreAuth, address(safe), address(oracle), snapShotWaitingPeriod, snapShotThreshold, snapShotPendingProposalLimit); + + // Add modules + + ejectAuth = new BorgAuth(); + // TODO WIP Use mock failSafe + eject = new ejectImplant( + ejectAuth, + address(safe), + address(new MockFailSafeImplant()), // _failSafe + true, // _allowManagement + true // _allowEjection + ); + + // TODO Staged due to external signers + executeSingle(getAddModuleData(address(eject))); + + // TODO Staged due to external signers + // Set the core as the guard for the Safe + executeSingle(getSetGuardData(address(core))); + + // We have done everything that requires owner role + // Transferring core ownership to the Safe itself + coreAuth.updateRole(address(safe), coreAuth.OWNER_ROLE()); + coreAuth.zeroOwner(); + // Transferring eject implant ownership to SnapShotExecutor + ejectAuth.updateRole(address(snapShotExecutor), ejectAuth.OWNER_ROLE()); + ejectAuth.zeroOwner(); + + vm.stopBroadcast(); + + console2.log("Deployed addresses:"); + console2.log(" Core: ", address(core)); + console2.log(" Core Auth: ", address(coreAuth)); + console2.log(" Eject Implant: ", address(eject)); + console2.log(" Eject Auth: ", address(ejectAuth)); + console2.log(" SnapShotExecutor: ", address(snapShotExecutor)); + + return (core, eject, snapShotExecutor); + } +} diff --git a/test/libraries/safe.t.sol b/test/libraries/safe.t.sol index 7d987bd..b54a2ad 100644 --- a/test/libraries/safe.t.sol +++ b/test/libraries/safe.t.sol @@ -11,6 +11,8 @@ interface IGnosisSafe { function setGuard(address guard) external; + function addOwnerWithThreshold(address owner, uint256 threshold) external; + function execTransaction( address to, uint256 value, diff --git a/test/libraries/safeTxHelper.sol b/test/libraries/safeTxHelper.sol new file mode 100644 index 0000000..57a6d78 --- /dev/null +++ b/test/libraries/safeTxHelper.sol @@ -0,0 +1,319 @@ + +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import {CommonBase} from "forge-std/Base.sol"; +import {BaseAllocation} from "metavest/BaseAllocation.sol"; +import "./safe.t.sol"; + +contract SafeTxHelper is CommonBase { + IGnosisSafe safe; + uint256 signerPrivateKey; + + constructor(address _safe, uint256 _signerPrivateKey) { + safe = IGnosisSafe(_safe); + signerPrivateKey = _signerPrivateKey; + } + + function createTestBatch(address core) public returns (GnosisTransaction[] memory) { + GnosisTransaction[] memory batch = new GnosisTransaction[](2); + address guyToApprove = address(0xdeadbabe); + address token = 0xF17A3fE536F8F7847F1385ec1bC967b2Ca9caE8D; + + // set guard + bytes4 setGuardFunctionSignature = bytes4( + keccak256("setGuard(address)") + ); + + bytes memory guardData = abi.encodeWithSelector( + setGuardFunctionSignature, + core + ); + + batch[0] = GnosisTransaction({to: address(safe), value: 0, data: guardData}); + + bytes4 approveFunctionSignature = bytes4( + keccak256("approve(address,uint256)") + ); + // Approve Tx -- this will go through as its a multicall before the guard is set for checkTx. + uint256 wad2 = 200; + bytes memory approveData2 = abi.encodeWithSelector( + approveFunctionSignature, + guyToApprove, + wad2 + ); + batch[1] = GnosisTransaction({to: token, value: 0, data: approveData2}); + + return batch; + } + + function getAddModuleData(address to) public view returns (GnosisTransaction memory) { + bytes4 addContractMethod = bytes4( + keccak256("enableModule(address)") + ); + + bytes memory guardData = abi.encodeWithSelector( + addContractMethod, + to + ); + GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: guardData}); + return txData; + } + + function getSetGuardData(address core) public view returns (GnosisTransaction memory) { + bytes4 setGuardFunctionSignature = bytes4( + keccak256("setGuard(address)") + ); + + bytes memory guardData = abi.encodeWithSelector( + setGuardFunctionSignature, + core + ); + GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: guardData}); + return txData; + } + + function getNativeTransferData(address to, uint256 amount) public view returns (GnosisTransaction memory) { + // Send the value with no data + GnosisTransaction memory txData = GnosisTransaction({to: to, value: amount, data: ""}); + return txData; + } + + function getTransferData(address token, address to, uint256 amount) public view returns (GnosisTransaction memory) { + bytes4 transferFunctionSignature = bytes4( + keccak256("transfer(address,uint256)") + ); + + bytes memory transferData = abi.encodeWithSelector( + transferFunctionSignature, + to, + amount + ); + GnosisTransaction memory txData = GnosisTransaction({to: token, value: 0, data: transferData}); + return txData; + } + + function getApproveData(address token, address spender, uint256 amount) public view returns (GnosisTransaction memory) { + bytes4 approveFunctionSignature = bytes4( + keccak256("approve(address,uint256)") + ); + + bytes memory approveData = abi.encodeWithSelector( + approveFunctionSignature, + spender, + amount + ); + GnosisTransaction memory txData = GnosisTransaction({to: token, value: 0, data: approveData}); + return txData; + } + + function getAddContractGuardData(address to, address allow, uint256 amount) public view returns (GnosisTransaction memory) { + bytes4 addContractMethod = bytes4( + keccak256("addContract(address,uint256)") + ); + + bytes memory guardData = abi.encodeWithSelector( + addContractMethod, + address(allow), + amount + ); + GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: guardData}); + return txData; + } + + function getAddEjectModuleData(address to) public view returns (GnosisTransaction memory) { + bytes4 addContractMethod = bytes4( + keccak256("enableModule(address)") + ); + + bytes memory guardData = abi.encodeWithSelector( + addContractMethod, + to + ); + GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: guardData}); + return txData; + } + + function getAddOwnerData(address toAdd) public view returns (GnosisTransaction memory) { + bytes4 addContractMethod = bytes4( + keccak256("addOwnerWithThreshold(address,uint256)") + ); + + bytes memory guardData = abi.encodeWithSelector( + addContractMethod, + toAdd, + 1 + ); + GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: guardData}); + return txData; + } + + function getAddRecipientGuardData(address to, address allow, uint256 amount) public view returns (GnosisTransaction memory) { + bytes4 addRecipientMethod = bytes4( + keccak256("addRecipient(address,uint256)") + ); + + bytes memory recData = abi.encodeWithSelector( + addRecipientMethod, + address(allow), + amount + ); + GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: recData}); + return txData; + } + + function getRemoveRecepientGuardData(address to, address allow) public view returns (GnosisTransaction memory) { + bytes4 removeRecepientMethod = bytes4( + keccak256("removeRecepient(address)") + ); + + bytes memory recData = abi.encodeWithSelector( + removeRecepientMethod, + address(allow) + ); + GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: recData}); + return txData; + } + + function getRemoveContractGuardData(address to, address allow) public view returns (GnosisTransaction memory) { + bytes4 removeContractMethod = bytes4( + keccak256("removeContract(address)") + ); + + bytes memory recData = abi.encodeWithSelector( + removeContractMethod, + address(allow) + ); + GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: recData}); + return txData; + } + + function getCreateGrantData(address opGrant, address token, address rec, uint256 amount) public view returns (GnosisTransaction memory) { + bytes4 addContractMethod = bytes4( + keccak256("createDirectGrant(address,address,uint256)") + ); + + bytes memory guardData = abi.encodeWithSelector( + addContractMethod, + token, + rec, + amount + ); + GnosisTransaction memory txData = GnosisTransaction({to: opGrant, value: 0, data: guardData}); + return txData; + } + + function getCreateBasicGrantData(address opGrant, address token, address rec, uint256 amount) public view returns (GnosisTransaction memory) { + //Configure the metavest details + uint256 _unlocked = amount/2; + uint256 _vested = amount/2; + BaseAllocation.Milestone[] memory emptyMilestones; + BaseAllocation.Allocation memory _metavestDetails = BaseAllocation.Allocation({ + tokenStreamTotal: amount, + vestingCliffCredit: 0, + unlockingCliffCredit: 0, + vestingRate: uint160(10), + vestingStartTime: uint48(block.timestamp), + unlockRate: uint160(10), + unlockStartTime: uint48(block.timestamp), + tokenContract: token + }); + bytes4 addContractMethod = bytes4( + keccak256("createAdvancedGrant(uint8,address,(uint256,uint128,uint128,uint160,uint48,uint48,uint160,uint48,uint48,address),(uint256,bool,bool,address[])[],uint256,address,uint256,uint256)") + ); + bytes memory guardData = abi.encodeWithSelector( + addContractMethod, + 0, + rec, + _metavestDetails, + emptyMilestones, + 0, + address(0), + 0, + 0 + ); + GnosisTransaction memory txData = GnosisTransaction({to: opGrant, value: 0, data: guardData}); + return txData; + } + + function getSignature( + address to, + uint256 value, + bytes memory data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 nonce + ) public view returns (bytes memory) { + bytes memory txHashData = safe.encodeTransactionData( + to, + value, + data, + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + nonce + ); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, keccak256(txHashData)); + bytes memory signature = abi.encodePacked(r, s, v); + return signature; + } + + function executeSingle(GnosisTransaction memory tx) public { + executeData(tx.to, 0, tx.data, tx.value, ""); + } + + function executeSingle(GnosisTransaction memory tx, bytes memory expectRevertData) public { + executeData(tx.to, 0, tx.data, tx.value, expectRevertData); + } + + function executeData( + address to, + uint8 operation, + bytes memory data, + uint256 value, + bytes memory expectRevertData + ) public { + uint256 safeTxGas = 0; + uint256 baseGas = 0; + uint256 gasPrice = 0; + address gasToken = address(0); + address refundReceiver = address(0); + uint256 nonce = safe.nonce(); + bytes memory signature = getSignature( + to, + value, + data, + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + nonce + ); + + if (expectRevertData.length > 0) { + vm.expectRevert(expectRevertData); + } + safe.execTransaction( + to, + value, + data, + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + signature + ); + } +} \ No newline at end of file diff --git a/test/yearnBorg.t.sol b/test/yearnBorg.t.sol new file mode 100644 index 0000000..8a6c3d1 --- /dev/null +++ b/test/yearnBorg.t.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; +import {YearnBorgDeployScript} from "../scripts/yearnBorg.s.sol"; +import {YearnBorgAcceptanceTest} from "./yearnBorgAcceptance.t.sol"; + +contract YearnBorgTest is YearnBorgAcceptanceTest { + function setUp() public override { + // Assume Ethereum mainnet fork after block 22268905 + + // Run deploy script and override with the newly deployed contract addresses + (core, eject, snapShotExecutor) = (new YearnBorgDeployScript()).run(); + } + + // The acceptance tests will run against the overridden setup +} diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol new file mode 100644 index 0000000..2c8bc9a --- /dev/null +++ b/test/yearnBorgAcceptance.t.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import "forge-std/Test.sol"; +import "solady/tokens/ERC20.sol"; +import {borgCore} from "../src/borgCore.sol"; +import {ejectImplant} from "../src/implants/ejectImplant.sol"; +import {BorgAuth} from "../src/libs/auth.sol"; +import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; +import {SafeTxHelper} from "./libraries/safeTxHelper.sol"; +import "./libraries/safe.t.sol"; + +contract YearnBorgAcceptanceTest is Test, SafeTxHelper { + + ERC20 weth = ERC20(0x4200000000000000000000000000000000000006); + + address safeSigner1 = 0x48E2a0d849c8F3c815ec1B0c0A9bC076d840c107; // TODO Replace it with ychad.eth signer + uint256 safeThreshold = 1; // TODO Replace it with ychad.eth threshold + + borgCore core; + ejectImplant eject; + SnapShotExecutor snapShotExecutor; + + constructor() SafeTxHelper( + 0xa2536225f0c0979D119E1877100f514179339700, // Safe Multisig + vm.envUint("PRIVATE_KEY_BORG_MEMBER_A") // Signer + ) {} + + /// If run directly, it will test against the predefined deployment. This way it can be run reliably in CICD. + /// Furthermore, one could override it for dynamic integration tests. + function setUp() public virtual { + // Assume Ethereum mainnet fork after block 22268905 + + core = borgCore(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO WIP + eject = ejectImplant(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO WIP + snapShotExecutor = SnapShotExecutor(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO WIP + } + + /// @dev BORG Core metadata should meet specs + function testBorgMeta() public { + assertEq(core.id(), "Yearn BORG", "Unexpected BORG ID"); + assertEq(core.borgType(), 0x3, "Unexpected BORG Core type"); + assertEq(uint8(core.borgMode()), uint8(borgCore.borgModes.unrestricted), "Unexpected BORG Core mode"); + } + + /// @dev BorgAuth instances should be proper assigned and configured + function testAuth() public { + BorgAuth coreAuth = core.AUTH(); + BorgAuth ejectAuth = eject.AUTH(); + + assertNotEq(address(coreAuth), address(ejectAuth), "Core auth instance should not be the same as Eject Implant's"); + assertEq(address(snapShotExecutor.AUTH()), address(coreAuth), "SnapShotExecutor auth should be core auth"); + + // Verify core auth roles + { + uint256 ownerRole = coreAuth.OWNER_ROLE(); + coreAuth.onlyRole(ownerRole, address(safe)); + } + + // Verify eject auth roles + { + uint256 ownerRole = ejectAuth.OWNER_ROLE(); + ejectAuth.onlyRole(ownerRole, address(snapShotExecutor)); + // Verify not owners + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(safe))); + ejectAuth.onlyRole(ownerRole, address(safe)); + } + } + + /// @dev Safe normal operations should be unrestricted + function testSafeOpUnrestricted() public { + { + uint256 balanceBefore = safeSigner1.balance; + deal(address(safe), 1 ether); + executeSingle(getNativeTransferData(safeSigner1, 1 ether)); + vm.assertEq(safeSigner1.balance - balanceBefore, 1 ether); + } + + { + uint256 balanceBefore = weth.balanceOf(safeSigner1); + deal(address(weth), address(safe), 1 ether); + executeSingle(getTransferData(address(weth), safeSigner1, 1 ether)); + vm.assertEq(weth.balanceOf(safeSigner1) - balanceBefore, 1 ether); + } + + // TODO How to do it when Safe is not 1/1? + } + + /// @dev Safe signers should be able to self-resign + function testSelfEject() public { + vm.assertTrue(safe.isOwner(safeSigner1), "Should be Safe signer"); + + // Self-resign without changing threshold + + vm.prank(safeSigner1); + eject.selfEject(false); + + vm.assertFalse(safe.isOwner(safeSigner1), "Should not be Safe signer"); + vm.assertEq(safe.getThreshold(), safeThreshold, "Threshold should not change"); + + // TODO Test with reduce = true + } +} From b277486c1d140e94c27f31166436227c1f161873 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 14 Apr 2025 21:39:58 -0700 Subject: [PATCH 08/52] wip: feat: Fix typos and add more tests --- src/libs/governance/snapShotExecutor.sol | 6 +- test/libraries/safeTxHelper.sol | 64 ++++++++++---------- test/yearnBorgAcceptance.t.sol | 77 ++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 35 deletions(-) diff --git a/src/libs/governance/snapShotExecutor.sol b/src/libs/governance/snapShotExecutor.sol index e6e5674..c53d35c 100644 --- a/src/libs/governance/snapShotExecutor.sol +++ b/src/libs/governance/snapShotExecutor.sol @@ -45,14 +45,14 @@ contract SnapShotExecutor is BorgAuthACL { _; } - constructor(BorgAuth _auth, address _borgSafe, address _oracle, uint256 _waitingPeriod, uint256 threshold, uint256 _pendingProposals) BorgAuthACL(_auth) { + constructor(BorgAuth _auth, address _borgSafe, address _oracle, uint256 _waitingPeriod, uint256 _threshold, uint256 _pendingProposals) BorgAuthACL(_auth) { if(_borgSafe == address(0) || _oracle == address(0)) revert SnapShotExecutor_ZeroAddress(); borgSafe = _borgSafe; oracle = _oracle; if(_waitingPeriod < 1 minutes) revert SnapShotExeuctor_InvalidParams(); waitingPeriod = _waitingPeriod; - if(threshold < 2) revert SnapShotExeuctor_InvalidParams(); - threshold = threshold; + if(_threshold < 2) revert SnapShotExeuctor_InvalidParams(); + threshold = _threshold; pendingProposalLimit = _pendingProposals; } diff --git a/test/libraries/safeTxHelper.sol b/test/libraries/safeTxHelper.sol index 57a6d78..8f42059 100644 --- a/test/libraries/safeTxHelper.sol +++ b/test/libraries/safeTxHelper.sol @@ -21,16 +21,16 @@ contract SafeTxHelper is CommonBase { address token = 0xF17A3fE536F8F7847F1385ec1bC967b2Ca9caE8D; // set guard - bytes4 setGuardFunctionSignature = bytes4( + bytes4 funcSig = bytes4( keccak256("setGuard(address)") ); - bytes memory guardData = abi.encodeWithSelector( - setGuardFunctionSignature, + bytes memory cdata = abi.encodeWithSelector( + funcSig, core ); - batch[0] = GnosisTransaction({to: address(safe), value: 0, data: guardData}); + batch[0] = GnosisTransaction({to: address(safe), value: 0, data: cdata}); bytes4 approveFunctionSignature = bytes4( keccak256("approve(address,uint256)") @@ -48,28 +48,28 @@ contract SafeTxHelper is CommonBase { } function getAddModuleData(address to) public view returns (GnosisTransaction memory) { - bytes4 addContractMethod = bytes4( + bytes4 funcSig = bytes4( keccak256("enableModule(address)") ); - bytes memory guardData = abi.encodeWithSelector( - addContractMethod, + bytes memory cdata = abi.encodeWithSelector( + funcSig, to ); - GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: guardData}); + GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: cdata}); return txData; } function getSetGuardData(address core) public view returns (GnosisTransaction memory) { - bytes4 setGuardFunctionSignature = bytes4( + bytes4 funcSig = bytes4( keccak256("setGuard(address)") ); - bytes memory guardData = abi.encodeWithSelector( - setGuardFunctionSignature, + bytes memory cdata = abi.encodeWithSelector( + funcSig, core ); - GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: guardData}); + GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: cdata}); return txData; } @@ -108,43 +108,43 @@ contract SafeTxHelper is CommonBase { } function getAddContractGuardData(address to, address allow, uint256 amount) public view returns (GnosisTransaction memory) { - bytes4 addContractMethod = bytes4( + bytes4 funcSig = bytes4( keccak256("addContract(address,uint256)") ); - bytes memory guardData = abi.encodeWithSelector( - addContractMethod, + bytes memory cdata = abi.encodeWithSelector( + funcSig, address(allow), amount ); - GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: guardData}); + GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: cdata}); return txData; } function getAddEjectModuleData(address to) public view returns (GnosisTransaction memory) { - bytes4 addContractMethod = bytes4( + bytes4 funcSig = bytes4( keccak256("enableModule(address)") ); - bytes memory guardData = abi.encodeWithSelector( - addContractMethod, + bytes memory cdata = abi.encodeWithSelector( + funcSig, to ); - GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: guardData}); + GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: cdata}); return txData; } function getAddOwnerData(address toAdd) public view returns (GnosisTransaction memory) { - bytes4 addContractMethod = bytes4( + bytes4 funcSig = bytes4( keccak256("addOwnerWithThreshold(address,uint256)") ); - bytes memory guardData = abi.encodeWithSelector( - addContractMethod, + bytes memory cdata = abi.encodeWithSelector( + funcSig, toAdd, 1 ); - GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: guardData}); + GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: cdata}); return txData; } @@ -189,17 +189,17 @@ contract SafeTxHelper is CommonBase { } function getCreateGrantData(address opGrant, address token, address rec, uint256 amount) public view returns (GnosisTransaction memory) { - bytes4 addContractMethod = bytes4( + bytes4 funcSig = bytes4( keccak256("createDirectGrant(address,address,uint256)") ); - bytes memory guardData = abi.encodeWithSelector( - addContractMethod, + bytes memory cdata = abi.encodeWithSelector( + funcSig, token, rec, amount ); - GnosisTransaction memory txData = GnosisTransaction({to: opGrant, value: 0, data: guardData}); + GnosisTransaction memory txData = GnosisTransaction({to: opGrant, value: 0, data: cdata}); return txData; } @@ -218,11 +218,11 @@ contract SafeTxHelper is CommonBase { unlockStartTime: uint48(block.timestamp), tokenContract: token }); - bytes4 addContractMethod = bytes4( + bytes4 funcSig = bytes4( keccak256("createAdvancedGrant(uint8,address,(uint256,uint128,uint128,uint160,uint48,uint48,uint160,uint48,uint48,address),(uint256,bool,bool,address[])[],uint256,address,uint256,uint256)") ); - bytes memory guardData = abi.encodeWithSelector( - addContractMethod, + bytes memory cdata = abi.encodeWithSelector( + funcSig, 0, rec, _metavestDetails, @@ -232,7 +232,7 @@ contract SafeTxHelper is CommonBase { 0, 0 ); - GnosisTransaction memory txData = GnosisTransaction({to: opGrant, value: 0, data: guardData}); + GnosisTransaction memory txData = GnosisTransaction({to: opGrant, value: 0, data: cdata}); return txData; } diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 2c8bc9a..6506c19 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -17,6 +17,10 @@ contract YearnBorgAcceptanceTest is Test, SafeTxHelper { address safeSigner1 = 0x48E2a0d849c8F3c815ec1B0c0A9bC076d840c107; // TODO Replace it with ychad.eth signer uint256 safeThreshold = 1; // TODO Replace it with ychad.eth threshold + address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00; + + address alice = vm.addr(1); + borgCore core; ejectImplant eject; SnapShotExecutor snapShotExecutor; @@ -67,6 +71,13 @@ contract YearnBorgAcceptanceTest is Test, SafeTxHelper { } } + function testSnapShotExecutorMeta() public { + assertEq(snapShotExecutor.oracle(), oracle, "Unexpected oracle"); + assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); + assertEq(snapShotExecutor.threshold(), 2, "Unexpected threshold"); + assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); + } + /// @dev Safe normal operations should be unrestricted function testSafeOpUnrestricted() public { { @@ -100,4 +111,70 @@ contract YearnBorgAcceptanceTest is Test, SafeTxHelper { // TODO Test with reduce = true } + + /// @dev Normal Member Management workflow should succeed + function testMemberManagement() public { + vm.assertFalse(safe.isOwner(alice), "Should not be Safe signer"); + + vm.prank(oracle); + bytes32 proposalId = snapShotExecutor.propose( + address(eject), // target + 0, // value + abi.encodeWithSelector( + bytes4(keccak256("addOwner(address)")), + alice // newOwner + ), // cdata + "Add Alice as new signer" + ); + + bytes memory executeCalldata = abi.encodeWithSelector( + snapShotExecutor.execute.selector, + proposalId + ); + + // Should fail within waiting period + executeSingle( + GnosisTransaction({ + to: address(snapShotExecutor), + value: 0, + data: executeCalldata + }), + "GS013" // expectRevertData + ); + + // After waiting period + skip(snapShotExecutor.waitingPeriod()); + + // Should fail if not executed from Safe + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, core.AUTH().OWNER_ROLE(), address(this))); + snapShotExecutor.execute(proposalId); + + // Should succeed if executed from Safe + executeSingle(GnosisTransaction({ + to: address(snapShotExecutor), + value: 0, + data: executeCalldata + })); + + vm.assertTrue(safe.isOwner(alice), "Should be Safe signer"); + } + + /// @dev Non-oracle should not be able to propose + function test_RevertIf_NotOracle() public { + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); + snapShotExecutor.propose( + address(eject), // target + 0, // value + "", // cdata + "Arbitrary instruction" + ); + } + + /// @dev Safe should not be able to add/remove signer itself + function test_RevertIf_DirectMemberManagement() public { + executeSingle( + getAddOwnerData(alice), // tx + abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData + ); + } } From 8b23d0ec726065dd099778a9e093d5927cec546b Mon Sep 17 00:00:00 2001 From: detoo Date: Tue, 15 Apr 2025 11:52:39 -0700 Subject: [PATCH 09/52] test: Fork ychad.eth as Safe for tests --- README-yearnBorg.md | 12 ++++++ scripts/yearnBorg.s.sol | 50 ++++++++++------------ test/libraries/safeTxHelper.sol | 7 +++- test/yearnBorg.t.sol | 8 +++- test/yearnBorgAcceptance.t.sol | 74 ++++++++++++++++----------------- 5 files changed, 83 insertions(+), 68 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index d799883..9045525 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -48,6 +48,18 @@ graph TD 3. MetaLeX's Snapshot oracle will submit the results onchain to an executor contract, which will have the proposed transaction pending for co-approval 4. ychad.eth will submit co-approval / execute the action through the MetaLeX OS webapp +## Deployment + +1. Run the deploy script + ```bash + forge script scripts/yearnBorg.s.sol --rpc-url --optimize --optimizer-runs 200 --use solc:0.8.20 --via-ir --broadcast + ``` + +2. If got the following errors, force clean the cache with flag `--force` + ``` + Error: buffer overrun while deserializing + ``` + ## Tests ### Integration Tests diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 55dcbd8..0bd1472 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -26,9 +26,10 @@ contract MockFailSafeImplant { } } -contract YearnBorgDeployScript is Script, SafeTxHelper { +contract YearnBorgDeployScript is Script { // Configs: BORG Core + IGnosisSafe ychadSafe = IGnosisSafe(0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52); // ychad.eth string borgIdentifier = "Yearn BORG"; // TODO WIP Ask for confirmation borgCore.borgModes borgMode = borgCore.borgModes.unrestricted; uint256 borgType = 0x3; // TODO WIP Ask for confirmation @@ -39,6 +40,8 @@ contract YearnBorgDeployScript is Script, SafeTxHelper { uint256 snapShotThreshold = 2; uint256 snapShotPendingProposalLimit = 3; address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00; + + SafeTxHelper safeTxHelper; borgCore core; BorgAuth coreAuth; @@ -46,48 +49,41 @@ contract YearnBorgDeployScript is Script, SafeTxHelper { ejectImplant eject; SnapShotExecutor snapShotExecutor; - constructor() SafeTxHelper( - // TODO test -// 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52, // ychad.eth - 0xa2536225f0c0979D119E1877100f514179339700, // test - - // TODO deprecated: This is no longer useful for non 1/1 multisig - vm.envUint("PRIVATE_KEY_BORG_MEMBER_A") // Signer - ) {} - + /// @dev For running from `forge script`. Provide the deployer private key through env var. function run() public returns(borgCore, ejectImplant, SnapShotExecutor) { + return run(vm.envUint("DEPLOYER_PRIVATE_KEY")); + } + + /// @dev For running in tests + function run(uint256 deployerPrivateKey) public returns(borgCore, ejectImplant, SnapShotExecutor) { console2.log("block number:", block.number); console2.log("Configs:"); console2.log(" BORG name:", borgIdentifier); console2.log(" BORG mode:", uint8(borgMode)); console2.log(" BORG type:", borgType); - console2.log(" Safe Multisig:", address(safe)); + console2.log(" Safe Multisig:", address(ychadSafe)); console2.log(" Snapshot waiting period (secs.):", snapShotWaitingPeriod); console2.log(" Snapshot threshold:", snapShotThreshold); console2.log(" Snapshot pending proposal limit:", snapShotPendingProposalLimit); - // TODO test -// uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY_DEPLOYER"); -// address deployerAddress = vm.addr(deployerPrivateKey); -// -// console2.log("deployer:", deployerAddress); -// -// vm.startBroadcast(deployerPrivateKey); - - address deployerAddress = vm.addr(signerPrivateKey); + address deployerAddress = vm.addr(deployerPrivateKey); console2.log("Deployer:", deployerAddress); - vm.startBroadcast(signerPrivateKey); + // Initialize Safe tx helper with deployer's private key for now + // TODO WIP: This will change since the deployer is not the signer of ychad.eth in real world + safeTxHelper = new SafeTxHelper(ychadSafe, deployerPrivateKey); + + vm.startBroadcast(deployerPrivateKey); // Core coreAuth = new BorgAuth(); - core = new borgCore(coreAuth, borgType, borgMode, borgIdentifier, address(safe)); + core = new borgCore(coreAuth, borgType, borgMode, borgIdentifier, address(ychadSafe)); // SnapShotExecutor - snapShotExecutor = new SnapShotExecutor(coreAuth, address(safe), address(oracle), snapShotWaitingPeriod, snapShotThreshold, snapShotPendingProposalLimit); + snapShotExecutor = new SnapShotExecutor(coreAuth, address(ychadSafe), address(oracle), snapShotWaitingPeriod, snapShotThreshold, snapShotPendingProposalLimit); // Add modules @@ -95,22 +91,22 @@ contract YearnBorgDeployScript is Script, SafeTxHelper { // TODO WIP Use mock failSafe eject = new ejectImplant( ejectAuth, - address(safe), + address(ychadSafe), address(new MockFailSafeImplant()), // _failSafe true, // _allowManagement true // _allowEjection ); // TODO Staged due to external signers - executeSingle(getAddModuleData(address(eject))); + safeTxHelper.executeSingle(safeTxHelper.getAddModuleData(address(eject))); // TODO Staged due to external signers // Set the core as the guard for the Safe - executeSingle(getSetGuardData(address(core))); + safeTxHelper.executeSingle(safeTxHelper.getSetGuardData(address(core))); // We have done everything that requires owner role // Transferring core ownership to the Safe itself - coreAuth.updateRole(address(safe), coreAuth.OWNER_ROLE()); + coreAuth.updateRole(address(ychadSafe), coreAuth.OWNER_ROLE()); coreAuth.zeroOwner(); // Transferring eject implant ownership to SnapShotExecutor ejectAuth.updateRole(address(snapShotExecutor), ejectAuth.OWNER_ROLE()); diff --git a/test/libraries/safeTxHelper.sol b/test/libraries/safeTxHelper.sol index 8f42059..721f22d 100644 --- a/test/libraries/safeTxHelper.sol +++ b/test/libraries/safeTxHelper.sol @@ -6,13 +6,16 @@ import {CommonBase} from "forge-std/Base.sol"; import {BaseAllocation} from "metavest/BaseAllocation.sol"; import "./safe.t.sol"; +// TODO Similar codes are used in other test files as well, consider refactoring and merging them here contract SafeTxHelper is CommonBase { IGnosisSafe safe; uint256 signerPrivateKey; + address signer; - constructor(address _safe, uint256 _signerPrivateKey) { - safe = IGnosisSafe(_safe); + constructor(IGnosisSafe _safe, uint256 _signerPrivateKey) { + safe = _safe; signerPrivateKey = _signerPrivateKey; + signer = vm.addr(signerPrivateKey); } function createTestBatch(address core) public returns (GnosisTransaction[] memory) { diff --git a/test/yearnBorg.t.sol b/test/yearnBorg.t.sol index 8a6c3d1..49a9665 100644 --- a/test/yearnBorg.t.sol +++ b/test/yearnBorg.t.sol @@ -2,7 +2,7 @@ pragma solidity 0.8.20; import "forge-std/Test.sol"; -import {console} from "forge-std/console.sol"; +import {console2} from "forge-std/console2.sol"; import {YearnBorgDeployScript} from "../scripts/yearnBorg.s.sol"; import {YearnBorgAcceptanceTest} from "./yearnBorgAcceptance.t.sol"; @@ -10,8 +10,12 @@ contract YearnBorgTest is YearnBorgAcceptanceTest { function setUp() public override { // Assume Ethereum mainnet fork after block 22268905 + // Change ychad.eth threshold and add test owner so we can run tests + vm.prank(address(ychadSafe)); + ychadSafe.addOwnerWithThreshold(testSigner, 1); + // Run deploy script and override with the newly deployed contract addresses - (core, eject, snapShotExecutor) = (new YearnBorgDeployScript()).run(); + (core, eject, snapShotExecutor) = (new YearnBorgDeployScript()).run(testSignerPrivateKey); } // The acceptance tests will run against the overridden setup diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 6506c19..1188f1e 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -10,26 +10,24 @@ import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; import {SafeTxHelper} from "./libraries/safeTxHelper.sol"; import "./libraries/safe.t.sol"; -contract YearnBorgAcceptanceTest is Test, SafeTxHelper { +contract YearnBorgAcceptanceTest is Test { + ERC20 weth = ERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); - ERC20 weth = ERC20(0x4200000000000000000000000000000000000006); - - address safeSigner1 = 0x48E2a0d849c8F3c815ec1B0c0A9bC076d840c107; // TODO Replace it with ychad.eth signer - uint256 safeThreshold = 1; // TODO Replace it with ychad.eth threshold + IGnosisSafe ychadSafe = IGnosisSafe(0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52); // ychad.eth address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00; - address alice = vm.addr(1); + uint256 testSignerPrivateKey = 1; + address testSigner = vm.addr(testSignerPrivateKey); + + address alice = vm.addr(2); + SafeTxHelper safeTxHelper = new SafeTxHelper(ychadSafe, testSignerPrivateKey); + borgCore core; ejectImplant eject; SnapShotExecutor snapShotExecutor; - constructor() SafeTxHelper( - 0xa2536225f0c0979D119E1877100f514179339700, // Safe Multisig - vm.envUint("PRIVATE_KEY_BORG_MEMBER_A") // Signer - ) {} - /// If run directly, it will test against the predefined deployment. This way it can be run reliably in CICD. /// Furthermore, one could override it for dynamic integration tests. function setUp() public virtual { @@ -58,7 +56,7 @@ contract YearnBorgAcceptanceTest is Test, SafeTxHelper { // Verify core auth roles { uint256 ownerRole = coreAuth.OWNER_ROLE(); - coreAuth.onlyRole(ownerRole, address(safe)); + coreAuth.onlyRole(ownerRole, address(ychadSafe)); } // Verify eject auth roles @@ -66,8 +64,8 @@ contract YearnBorgAcceptanceTest is Test, SafeTxHelper { uint256 ownerRole = ejectAuth.OWNER_ROLE(); ejectAuth.onlyRole(ownerRole, address(snapShotExecutor)); // Verify not owners - vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(safe))); - ejectAuth.onlyRole(ownerRole, address(safe)); + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(ychadSafe))); + ejectAuth.onlyRole(ownerRole, address(ychadSafe)); } } @@ -81,17 +79,17 @@ contract YearnBorgAcceptanceTest is Test, SafeTxHelper { /// @dev Safe normal operations should be unrestricted function testSafeOpUnrestricted() public { { - uint256 balanceBefore = safeSigner1.balance; - deal(address(safe), 1 ether); - executeSingle(getNativeTransferData(safeSigner1, 1 ether)); - vm.assertEq(safeSigner1.balance - balanceBefore, 1 ether); + uint256 balanceBefore = alice.balance; + deal(address(ychadSafe), 1 ether); + safeTxHelper.executeSingle(safeTxHelper.getNativeTransferData(alice, 1 ether)); + vm.assertEq(alice.balance - balanceBefore, 1 ether); } { - uint256 balanceBefore = weth.balanceOf(safeSigner1); - deal(address(weth), address(safe), 1 ether); - executeSingle(getTransferData(address(weth), safeSigner1, 1 ether)); - vm.assertEq(weth.balanceOf(safeSigner1) - balanceBefore, 1 ether); + uint256 balanceBefore = weth.balanceOf(alice); + deal(address(weth), address(ychadSafe), 1 ether); + safeTxHelper.executeSingle(safeTxHelper.getTransferData(address(weth), alice, 1 ether)); + vm.assertEq(weth.balanceOf(alice) - balanceBefore, 1 ether); } // TODO How to do it when Safe is not 1/1? @@ -99,22 +97,23 @@ contract YearnBorgAcceptanceTest is Test, SafeTxHelper { /// @dev Safe signers should be able to self-resign function testSelfEject() public { - vm.assertTrue(safe.isOwner(safeSigner1), "Should be Safe signer"); + vm.assertTrue(ychadSafe.isOwner(testSigner), "Should be Safe signer"); // Self-resign without changing threshold + uint256 thresholdBefore = ychadSafe.getThreshold(); - vm.prank(safeSigner1); + vm.prank(testSigner); eject.selfEject(false); - vm.assertFalse(safe.isOwner(safeSigner1), "Should not be Safe signer"); - vm.assertEq(safe.getThreshold(), safeThreshold, "Threshold should not change"); + vm.assertFalse(ychadSafe.isOwner(testSigner), "Should not be Safe signer"); + vm.assertEq(ychadSafe.getThreshold(), thresholdBefore, "Threshold should not change"); // TODO Test with reduce = true } /// @dev Normal Member Management workflow should succeed function testMemberManagement() public { - vm.assertFalse(safe.isOwner(alice), "Should not be Safe signer"); + vm.assertFalse(ychadSafe.isOwner(alice), "Should not be Safe signer"); vm.prank(oracle); bytes32 proposalId = snapShotExecutor.propose( @@ -133,7 +132,7 @@ contract YearnBorgAcceptanceTest is Test, SafeTxHelper { ); // Should fail within waiting period - executeSingle( + safeTxHelper.executeSingle( GnosisTransaction({ to: address(snapShotExecutor), value: 0, @@ -150,13 +149,13 @@ contract YearnBorgAcceptanceTest is Test, SafeTxHelper { snapShotExecutor.execute(proposalId); // Should succeed if executed from Safe - executeSingle(GnosisTransaction({ + safeTxHelper.executeSingle(GnosisTransaction({ to: address(snapShotExecutor), value: 0, data: executeCalldata })); - vm.assertTrue(safe.isOwner(alice), "Should be Safe signer"); + vm.assertTrue(ychadSafe.isOwner(alice), "Should be Safe signer"); } /// @dev Non-oracle should not be able to propose @@ -170,11 +169,12 @@ contract YearnBorgAcceptanceTest is Test, SafeTxHelper { ); } - /// @dev Safe should not be able to add/remove signer itself - function test_RevertIf_DirectMemberManagement() public { - executeSingle( - getAddOwnerData(alice), // tx - abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData - ); - } +// /// @dev Safe should not be able to add/remove signer itself +// function test_RevertIf_DirectMemberManagement() public { +// safeTxHelper.executeSingle( +// safeTxHelper.getAddOwnerData(alice), // tx +// abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData +// ); +// // TODO It does not revert! +// } } From c199382008aeb557ae995423963999a33a758f96 Mon Sep 17 00:00:00 2001 From: detoo Date: Tue, 15 Apr 2025 14:24:11 -0700 Subject: [PATCH 10/52] feat: Deploy scripts to accommodate external Safe TXs --- scripts/yearnBorg.s.sol | 48 +++++++++++++------------- scripts/yearnBorgPost.s.sol | 60 +++++++++++++++++++++++++++++++++ test/libraries/safeTxHelper.sol | 32 +++++++++++++++++- test/yearnBorg.t.sol | 15 +++++++-- test/yearnBorgAcceptance.t.sol | 10 ++++-- 5 files changed, 134 insertions(+), 31 deletions(-) create mode 100644 scripts/yearnBorgPost.s.sol diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 0bd1472..281631b 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -14,7 +14,7 @@ import {SignatureCondition} from "../src/libs/conditions/signatureCondition.sol" import {BorgAuth} from "../src/libs/auth.sol"; import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; import {SafeTxHelper} from "../test/libraries/safeTxHelper.sol"; -import {IGnosisSafe, GnosisTransaction} from "../test/libraries/safe.t.sol"; +import {IGnosisSafe, GnosisTransaction, IMultiSendCallOnly} from "../test/libraries/safe.t.sol"; contract MockFailSafeImplant { uint256 public immutable IMPLANT_ID = 0; @@ -27,6 +27,10 @@ contract MockFailSafeImplant { } contract YearnBorgDeployScript is Script { + // Safe 1.3.0 Multi Send Call Only @ Ethereum mainnet + // https://github.com/safe-global/safe-deployments?tab=readme-ov-file + IMultiSendCallOnly multiSendCallOnly = IMultiSendCallOnly(0x40A2aCCbd92BCA938b02010E17A5b8929b49130D); + // Configs: BORG Core IGnosisSafe ychadSafe = IGnosisSafe(0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52); // ychad.eth @@ -50,15 +54,13 @@ contract YearnBorgDeployScript is Script { SnapShotExecutor snapShotExecutor; /// @dev For running from `forge script`. Provide the deployer private key through env var. - function run() public returns(borgCore, ejectImplant, SnapShotExecutor) { + function run() public returns(borgCore, ejectImplant, SnapShotExecutor, GnosisTransaction[] memory) { return run(vm.envUint("DEPLOYER_PRIVATE_KEY")); } /// @dev For running in tests - function run(uint256 deployerPrivateKey) public returns(borgCore, ejectImplant, SnapShotExecutor) { - console2.log("block number:", block.number); - - console2.log("Configs:"); + function run(uint256 deployerPrivateKey) public returns(borgCore, ejectImplant, SnapShotExecutor, GnosisTransaction[] memory) { + console2.log("Deploy Configs:"); console2.log(" BORG name:", borgIdentifier); console2.log(" BORG mode:", uint8(borgMode)); console2.log(" BORG type:", borgType); @@ -72,7 +74,7 @@ contract YearnBorgDeployScript is Script { // Initialize Safe tx helper with deployer's private key for now // TODO WIP: This will change since the deployer is not the signer of ychad.eth in real world - safeTxHelper = new SafeTxHelper(ychadSafe, deployerPrivateKey); + safeTxHelper = new SafeTxHelper(ychadSafe, multiSendCallOnly, deployerPrivateKey); vm.startBroadcast(deployerPrivateKey); @@ -88,7 +90,6 @@ contract YearnBorgDeployScript is Script { // Add modules ejectAuth = new BorgAuth(); - // TODO WIP Use mock failSafe eject = new ejectImplant( ejectAuth, address(ychadSafe), @@ -97,21 +98,6 @@ contract YearnBorgDeployScript is Script { true // _allowEjection ); - // TODO Staged due to external signers - safeTxHelper.executeSingle(safeTxHelper.getAddModuleData(address(eject))); - - // TODO Staged due to external signers - // Set the core as the guard for the Safe - safeTxHelper.executeSingle(safeTxHelper.getSetGuardData(address(core))); - - // We have done everything that requires owner role - // Transferring core ownership to the Safe itself - coreAuth.updateRole(address(ychadSafe), coreAuth.OWNER_ROLE()); - coreAuth.zeroOwner(); - // Transferring eject implant ownership to SnapShotExecutor - ejectAuth.updateRole(address(snapShotExecutor), ejectAuth.OWNER_ROLE()); - ejectAuth.zeroOwner(); - vm.stopBroadcast(); console2.log("Deployed addresses:"); @@ -121,6 +107,20 @@ contract YearnBorgDeployScript is Script { console2.log(" Eject Auth: ", address(ejectAuth)); console2.log(" SnapShotExecutor: ", address(snapShotExecutor)); - return (core, eject, snapShotExecutor); + GnosisTransaction[] memory safeTxs = new GnosisTransaction[](2); + safeTxs[0] = safeTxHelper.getAddModuleData(address(eject)); + safeTxs[1] = safeTxHelper.getSetGuardData(address(core)); + + console2.log("Safe TXs:"); + for (uint256 i = 0 ; i < safeTxs.length ; i++) { + console2.log(" #", i); + console2.log(" to:", safeTxs[i].to); + console2.log(" value:", safeTxs[i].value); + console2.log(" data:"); + console2.logBytes(safeTxs[i].data); + console2.log(""); + } + + return (core, eject, snapShotExecutor, safeTxs); } } diff --git a/scripts/yearnBorgPost.s.sol b/scripts/yearnBorgPost.s.sol new file mode 100644 index 0000000..1bef1ef --- /dev/null +++ b/scripts/yearnBorgPost.s.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; +import {borgCore} from "../src/borgCore.sol"; +import {ejectImplant} from "../src/implants/ejectImplant.sol"; +import {optimisticGrantImplant} from "../src/implants/optimisticGrantImplant.sol"; +import {daoVoteGrantImplant} from "../src/implants/daoVoteGrantImplant.sol"; +import {daoVetoGrantImplant} from "../src/implants/daoVetoGrantImplant.sol"; +import {daoVetoImplant} from "../src/implants/daoVetoImplant.sol"; +import {daoVoteImplant} from "../src/implants/daoVoteImplant.sol"; +import {SignatureCondition} from "../src/libs/conditions/signatureCondition.sol"; +import {BorgAuth} from "../src/libs/auth.sol"; +import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; +import {SafeTxHelper} from "../test/libraries/safeTxHelper.sol"; +import {IGnosisSafe, GnosisTransaction, IMultiSendCallOnly} from "../test/libraries/safe.t.sol"; +import {YearnBorgDeployScript} from "./yearnBorg.s.sol"; + +contract YearnBorgPostDeployScript is Script { + + IGnosisSafe ychadSafe = IGnosisSafe(0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52); // ychad.eth + + /// @dev For running from `forge script`. Provide the deployer private key through env var. + function run() public { + return run( + vm.envUint("DEPLOYER_PRIVATE_KEY"), + // Note: update these values for each deployment + borgCore(address(0)), + ejectImplant(address(0)), + SnapShotExecutor(address(0)) + ); + } + + /// @dev For running in tests + function run(uint256 deployerPrivateKey, borgCore core, ejectImplant eject, SnapShotExecutor snapShotExecutor) public { + console2.log("Post-deploy Configs:"); + console2.log(" Safe Multisig:", address(ychadSafe)); + console2.log(" Core: ", address(core)); + console2.log(" Eject Implant: ", address(eject)); + console2.log(" SnapShotExecutor: ", address(snapShotExecutor)); + + address deployerAddress = vm.addr(deployerPrivateKey); + console2.log("Deployer:", deployerAddress); + + vm.startBroadcast(deployerPrivateKey); + + // Transferring core ownership to the Safe itself + BorgAuth coreAuth = core.AUTH(); + coreAuth.updateRole(address(ychadSafe), coreAuth.OWNER_ROLE()); + coreAuth.zeroOwner(); + + // Transferring eject implant ownership to SnapShotExecutor + BorgAuth ejectAuth = eject.AUTH(); + ejectAuth.updateRole(address(snapShotExecutor), ejectAuth.OWNER_ROLE()); + ejectAuth.zeroOwner(); + + vm.stopBroadcast(); + } +} diff --git a/test/libraries/safeTxHelper.sol b/test/libraries/safeTxHelper.sol index 721f22d..fd72203 100644 --- a/test/libraries/safeTxHelper.sol +++ b/test/libraries/safeTxHelper.sol @@ -9,11 +9,13 @@ import "./safe.t.sol"; // TODO Similar codes are used in other test files as well, consider refactoring and merging them here contract SafeTxHelper is CommonBase { IGnosisSafe safe; + IMultiSendCallOnly multiSendCallOnly; uint256 signerPrivateKey; address signer; - constructor(IGnosisSafe _safe, uint256 _signerPrivateKey) { + constructor(IGnosisSafe _safe, IMultiSendCallOnly _multiSendCallOnly, uint256 _signerPrivateKey) { safe = _safe; + multiSendCallOnly = _multiSendCallOnly; signerPrivateKey = _signerPrivateKey; signer = vm.addr(signerPrivateKey); } @@ -269,6 +271,34 @@ contract SafeTxHelper is CommonBase { return signature; } + function getBatchExecutionData( + GnosisTransaction[] memory batch + ) public view returns (bytes memory) { + bytes memory transactions = new bytes(0); + for (uint256 i = 0; i < batch.length; i++) { + transactions = abi.encodePacked( + transactions, + uint8(0), + batch[i].to, + batch[i].value, + batch[i].data.length, + batch[i].data + ); + } + + bytes memory data = abi.encodeWithSelector( + multiSendCallOnly.multiSend.selector, + transactions + ); + return data; + } + + function executeBatch(GnosisTransaction[] memory batch) public { + bytes memory data = getBatchExecutionData(batch); + // Note it does not handle native ETH values as there is no such need so far + executeData(address(multiSendCallOnly), 1, data, 0, ""); + } + function executeSingle(GnosisTransaction memory tx) public { executeData(tx.to, 0, tx.data, tx.value, ""); } diff --git a/test/yearnBorg.t.sol b/test/yearnBorg.t.sol index 49a9665..4c5ab43 100644 --- a/test/yearnBorg.t.sol +++ b/test/yearnBorg.t.sol @@ -4,18 +4,27 @@ pragma solidity 0.8.20; import "forge-std/Test.sol"; import {console2} from "forge-std/console2.sol"; import {YearnBorgDeployScript} from "../scripts/yearnBorg.s.sol"; +import {YearnBorgPostDeployScript} from "../scripts/yearnBorgPost.s.sol"; import {YearnBorgAcceptanceTest} from "./yearnBorgAcceptance.t.sol"; +import {GnosisTransaction} from "../test/libraries/safe.t.sol"; contract YearnBorgTest is YearnBorgAcceptanceTest { function setUp() public override { // Assume Ethereum mainnet fork after block 22268905 - // Change ychad.eth threshold and add test owner so we can run tests + // Simulate changing ychad.eth threshold and adding the test owner so we can run tests vm.prank(address(ychadSafe)); ychadSafe.addOwnerWithThreshold(testSigner, 1); - // Run deploy script and override with the newly deployed contract addresses - (core, eject, snapShotExecutor) = (new YearnBorgDeployScript()).run(testSignerPrivateKey); + // MetaLex to deploy new BORG contracts and generate corresponding Safe txs for ychad.eth + GnosisTransaction[] memory safeTxs; + (core, eject, snapShotExecutor, safeTxs) = (new YearnBorgDeployScript()).run(testSignerPrivateKey); + + // Simulate ychad.eth executing the provided Safe TXs (set guard & add module) + safeTxHelper.executeBatch(safeTxs); + + // MetaLex to finish the deployment + (new YearnBorgPostDeployScript()).run(testSignerPrivateKey, core, eject, snapShotExecutor); } // The acceptance tests will run against the overridden setup diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 1188f1e..a271fb3 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -8,10 +8,14 @@ import {ejectImplant} from "../src/implants/ejectImplant.sol"; import {BorgAuth} from "../src/libs/auth.sol"; import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; import {SafeTxHelper} from "./libraries/safeTxHelper.sol"; -import "./libraries/safe.t.sol"; +import {IGnosisSafe, GnosisTransaction, IMultiSendCallOnly} from "../test/libraries/safe.t.sol"; contract YearnBorgAcceptanceTest is Test { - ERC20 weth = ERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); + ERC20 weth = ERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); // Ethereum mainnet + + // Safe 1.3.0 Multi Send Call Only @ Ethereum mainnet + // https://github.com/safe-global/safe-deployments?tab=readme-ov-file + IMultiSendCallOnly multiSendCallOnly = IMultiSendCallOnly(0x40A2aCCbd92BCA938b02010E17A5b8929b49130D); IGnosisSafe ychadSafe = IGnosisSafe(0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52); // ychad.eth @@ -22,7 +26,7 @@ contract YearnBorgAcceptanceTest is Test { address alice = vm.addr(2); - SafeTxHelper safeTxHelper = new SafeTxHelper(ychadSafe, testSignerPrivateKey); + SafeTxHelper safeTxHelper = new SafeTxHelper(ychadSafe, multiSendCallOnly, testSignerPrivateKey); borgCore core; ejectImplant eject; From 379986fccd694b9bbd5b96e802ac3dd4330d140e Mon Sep 17 00:00:00 2001 From: detoo Date: Tue, 15 Apr 2025 14:33:44 -0700 Subject: [PATCH 11/52] feat: Simplify deployment process --- scripts/yearnBorg.s.sol | 20 ++++++++++--- scripts/yearnBorgPost.s.sol | 60 ------------------------------------- test/yearnBorg.t.sol | 4 --- 3 files changed, 16 insertions(+), 68 deletions(-) delete mode 100644 scripts/yearnBorgPost.s.sol diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 281631b..998f390 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -72,9 +72,11 @@ contract YearnBorgDeployScript is Script { address deployerAddress = vm.addr(deployerPrivateKey); console2.log("Deployer:", deployerAddress); - // Initialize Safe tx helper with deployer's private key for now - // TODO WIP: This will change since the deployer is not the signer of ychad.eth in real world - safeTxHelper = new SafeTxHelper(ychadSafe, multiSendCallOnly, deployerPrivateKey); + safeTxHelper = new SafeTxHelper( + ychadSafe, + multiSendCallOnly, + deployerPrivateKey // No-op. We are not supposed to sign any Safe tx here + ); vm.startBroadcast(deployerPrivateKey); @@ -98,6 +100,14 @@ contract YearnBorgDeployScript is Script { true // _allowEjection ); + // Transferring core ownership to the Safe itself + coreAuth.updateRole(address(ychadSafe), coreAuth.OWNER_ROLE()); + coreAuth.zeroOwner(); + + // Transferring eject implant ownership to SnapShotExecutor + ejectAuth.updateRole(address(snapShotExecutor), ejectAuth.OWNER_ROLE()); + ejectAuth.zeroOwner(); + vm.stopBroadcast(); console2.log("Deployed addresses:"); @@ -107,9 +117,11 @@ contract YearnBorgDeployScript is Script { console2.log(" Eject Auth: ", address(ejectAuth)); console2.log(" SnapShotExecutor: ", address(snapShotExecutor)); + // Prepare Safe TXs for ychad.eth to execute + GnosisTransaction[] memory safeTxs = new GnosisTransaction[](2); safeTxs[0] = safeTxHelper.getAddModuleData(address(eject)); - safeTxs[1] = safeTxHelper.getSetGuardData(address(core)); + safeTxs[1] = safeTxHelper.getSetGuardData(address(core)); // Note we must set guard last because it may block ychad.eth from adding any more modules console2.log("Safe TXs:"); for (uint256 i = 0 ; i < safeTxs.length ; i++) { diff --git a/scripts/yearnBorgPost.s.sol b/scripts/yearnBorgPost.s.sol deleted file mode 100644 index 1bef1ef..0000000 --- a/scripts/yearnBorgPost.s.sol +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; - -import {Script} from "forge-std/Script.sol"; -import {console2} from "forge-std/console2.sol"; -import {borgCore} from "../src/borgCore.sol"; -import {ejectImplant} from "../src/implants/ejectImplant.sol"; -import {optimisticGrantImplant} from "../src/implants/optimisticGrantImplant.sol"; -import {daoVoteGrantImplant} from "../src/implants/daoVoteGrantImplant.sol"; -import {daoVetoGrantImplant} from "../src/implants/daoVetoGrantImplant.sol"; -import {daoVetoImplant} from "../src/implants/daoVetoImplant.sol"; -import {daoVoteImplant} from "../src/implants/daoVoteImplant.sol"; -import {SignatureCondition} from "../src/libs/conditions/signatureCondition.sol"; -import {BorgAuth} from "../src/libs/auth.sol"; -import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; -import {SafeTxHelper} from "../test/libraries/safeTxHelper.sol"; -import {IGnosisSafe, GnosisTransaction, IMultiSendCallOnly} from "../test/libraries/safe.t.sol"; -import {YearnBorgDeployScript} from "./yearnBorg.s.sol"; - -contract YearnBorgPostDeployScript is Script { - - IGnosisSafe ychadSafe = IGnosisSafe(0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52); // ychad.eth - - /// @dev For running from `forge script`. Provide the deployer private key through env var. - function run() public { - return run( - vm.envUint("DEPLOYER_PRIVATE_KEY"), - // Note: update these values for each deployment - borgCore(address(0)), - ejectImplant(address(0)), - SnapShotExecutor(address(0)) - ); - } - - /// @dev For running in tests - function run(uint256 deployerPrivateKey, borgCore core, ejectImplant eject, SnapShotExecutor snapShotExecutor) public { - console2.log("Post-deploy Configs:"); - console2.log(" Safe Multisig:", address(ychadSafe)); - console2.log(" Core: ", address(core)); - console2.log(" Eject Implant: ", address(eject)); - console2.log(" SnapShotExecutor: ", address(snapShotExecutor)); - - address deployerAddress = vm.addr(deployerPrivateKey); - console2.log("Deployer:", deployerAddress); - - vm.startBroadcast(deployerPrivateKey); - - // Transferring core ownership to the Safe itself - BorgAuth coreAuth = core.AUTH(); - coreAuth.updateRole(address(ychadSafe), coreAuth.OWNER_ROLE()); - coreAuth.zeroOwner(); - - // Transferring eject implant ownership to SnapShotExecutor - BorgAuth ejectAuth = eject.AUTH(); - ejectAuth.updateRole(address(snapShotExecutor), ejectAuth.OWNER_ROLE()); - ejectAuth.zeroOwner(); - - vm.stopBroadcast(); - } -} diff --git a/test/yearnBorg.t.sol b/test/yearnBorg.t.sol index 4c5ab43..025b4fd 100644 --- a/test/yearnBorg.t.sol +++ b/test/yearnBorg.t.sol @@ -4,7 +4,6 @@ pragma solidity 0.8.20; import "forge-std/Test.sol"; import {console2} from "forge-std/console2.sol"; import {YearnBorgDeployScript} from "../scripts/yearnBorg.s.sol"; -import {YearnBorgPostDeployScript} from "../scripts/yearnBorgPost.s.sol"; import {YearnBorgAcceptanceTest} from "./yearnBorgAcceptance.t.sol"; import {GnosisTransaction} from "../test/libraries/safe.t.sol"; @@ -22,9 +21,6 @@ contract YearnBorgTest is YearnBorgAcceptanceTest { // Simulate ychad.eth executing the provided Safe TXs (set guard & add module) safeTxHelper.executeBatch(safeTxs); - - // MetaLex to finish the deployment - (new YearnBorgPostDeployScript()).run(testSignerPrivateKey, core, eject, snapShotExecutor); } // The acceptance tests will run against the overridden setup From c184272e6495d3c565a85fb7b746ef5868c7ce81 Mon Sep 17 00:00:00 2001 From: detoo Date: Tue, 15 Apr 2025 14:55:06 -0700 Subject: [PATCH 12/52] chore: Update README and remove unused comments --- README-yearnBorg.md | 66 +++++++++++++++++++++++++--------- test/yearnBorgAcceptance.t.sol | 2 -- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index 9045525..852bf73 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -1,52 +1,69 @@ # Yearn BORG -## BORG Architectures +## BORG Architectures (TBD) ```mermaid graph TD ychad[ychad.eth
6/9 signers] - ychadSigner[ychad.eth signer] + ychadSigner[Signer EOA] yearnDaoVoting[Yearn DAO Voting Snapshot] oracleAddr[oracle] borg{{Yearn BORG
BORG Core}} - - ejectImplant{{Eject Implant}} + + subgraph implants["Implants (Modules)"] + ejectImplant{{Eject Implant}} + sudoImplant{{"(WIP) Sudo Implant"}} + end snapshotExecutor[SnapshotExecutor] - ychad -->|"owner / guard by"| borg - ychad -->|"owner / execute()"| snapshotExecutor + ychad -->|"owner
guard by"| borg + ychad -->|"owner
execute(proposal)"| snapshotExecutor + + %% implants -->|modules| ychad ychadSigner -->|signer| ychad ychadSigner -->|"selfEject()"| ejectImplant - oracleAddr -->|"oracle / propose(member management func)"| snapshotExecutor + oracleAddr -->|"oracle
propose(admin operation)"| snapshotExecutor oracleAddr -->|monitor| yearnDaoVoting - ejectImplant -->|module| ychad - - snapshotExecutor -->|"owner / member management func()"| ejectImplant + snapshotExecutor -->|"owner
guard & module management operation()"| sudoImplant + snapshotExecutor -->|"owner
member management operation()"| ejectImplant %% Styling (optional, Mermaid supports limited styling) classDef default fill:#191918,stroke:#fff,stroke-width:2px,color:#fff; classDef borg fill:#191918,stroke:#E1FE52,stroke-width:2px,color:#E1FE52; + classDef yearn fill:#191918,stroke:#2C68DB,stroke-width:2px,color:#2C68DB; classDef safe fill:#191918,stroke:#76FB8D,stroke-width:2px,color:#76FB8D; classDef todo fill:#191918,stroke:#F09B4A,stroke-width:2px,color:#F09B4A; class borg borg; class ejectImplant borg; + class sudoImplant borg; class snapshotExecutor borg; class oracleAddr borg; - class ychad safe; + class ychad yearn; + class ychadSigner yearn; + class yearnDaoVoting yearn; ``` -## Member Management Workflow +## Restricted Admin Workflows (TBD) + +`ychad.eth` will be prohibited from unilaterally performing the following admin operations: + +- Add / remove / swap signers +- Set Guards +- Add / disable Modules + +Except existing signers, Guard (BORG Core) and Modules (BORG Implants), +all coming operations as listed above will require approval of both `ychad.eth` and DAO, with process as such: -1. Action is initiated on the MetaLeX OS webapp +1. Operation is initiated on the MetaLeX OS webapp 2. A Snapshot proposal will be submitted via API using Yearn's existing voting settings -3. MetaLeX's Snapshot oracle will submit the results onchain to an executor contract, which will have the proposed transaction pending for co-approval -4. ychad.eth will submit co-approval / execute the action through the MetaLeX OS webapp +3. MetaLeX's Snapshot oracle will submit the results onchain to an executor contract (`SnapShotExecutor`), which will have the proposed transaction pending for co-approval +4. `ychad.eth` will approve by executing the operation through the MetaLeX OS webapp ## Deployment @@ -58,7 +75,24 @@ graph TD 2. If got the following errors, force clean the cache with flag `--force` ``` Error: buffer overrun while deserializing + ``` + +3. Take notes of the output Safe TXs (for setting guard & adding modules), for examples: ``` + Safe TXs: + # 0 + to: 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52 + value: 0 + data: + 0x610b5925000000000000000000000000777b947b1821c34ee94d7d09c82e56f8008a0e08 + + # 1 + to: 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52 + value: 0 + data: + 0xe19a9dd9000000000000000000000000bc19387f5b8ae73fad41cd2294f928a735c60534 + ``` +4. Ask `ychad.eth` to sign and execute the Safe TXs ## Tests @@ -72,7 +106,7 @@ forge test --optimize --optimizer-runs 200 --use solc:0.8.20 --via-ir --fork-url ### Acceptance Tests -Verify the specified deployment results. +Verify a specific deployment results. ```bash forge test --optimize --optimizer-runs 200 --use solc:0.8.20 --via-ir --fork-url --fork-block-number --mc YearnBorgAcceptanceTest diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index a271fb3..978d28a 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -95,8 +95,6 @@ contract YearnBorgAcceptanceTest is Test { safeTxHelper.executeSingle(safeTxHelper.getTransferData(address(weth), alice, 1 ether)); vm.assertEq(weth.balanceOf(alice) - balanceBefore, 1 ether); } - - // TODO How to do it when Safe is not 1/1? } /// @dev Safe signers should be able to self-resign From 8b9af67d93b1ee04ae9ce8a09d0d790e39973d5e Mon Sep 17 00:00:00 2001 From: detoo Date: Wed, 16 Apr 2025 11:57:28 -0700 Subject: [PATCH 13/52] feat: Simplify SnapShotExecutor's cancel process. Add tests --- README-yearnBorg.md | 12 ++ scripts/yearnBorg.s.sol | 8 +- src/libs/governance/snapShotExecutor.sol | 73 ++------ test/snapShotExecutor.t.sol | 206 +++++++++++++++++++++++ test/yearnBorgAcceptance.t.sol | 4 +- 5 files changed, 236 insertions(+), 67 deletions(-) create mode 100644 test/snapShotExecutor.t.sol diff --git a/README-yearnBorg.md b/README-yearnBorg.md index 852bf73..d48301a 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -65,6 +65,18 @@ all coming operations as listed above will require approval of both `ychad.eth` 3. MetaLeX's Snapshot oracle will submit the results onchain to an executor contract (`SnapShotExecutor`), which will have the proposed transaction pending for co-approval 4. `ychad.eth` will approve by executing the operation through the MetaLeX OS webapp +## Key Parameters + +| ID | Value | Descriptions | +|--------------------------------|------------|---------------------------------------------------------| +| `borgIdentifier` | Yearn BORG | BORG name | +| `borgMode` | blacklist | Every operation is allowed unless blacklisted | +| `borgType` | 3 | | +| `snapShotWaitingPeriod` | 3 days | Waiting period before a proposal can be executed | +| `snapShotCancelPeriod` | 2 days | Extra waiting period before a proposal can be cancelled | +| `snapShotPendingProposalLimit` | 3 | Maximum pending proposals | +| `oracle` | `address` | MetaLeX Snapshot oracle | + ## Deployment 1. Run the deploy script diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 998f390..919a372 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -35,13 +35,13 @@ contract YearnBorgDeployScript is Script { IGnosisSafe ychadSafe = IGnosisSafe(0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52); // ychad.eth string borgIdentifier = "Yearn BORG"; // TODO WIP Ask for confirmation - borgCore.borgModes borgMode = borgCore.borgModes.unrestricted; + borgCore.borgModes borgMode = borgCore.borgModes.blacklist; uint256 borgType = 0x3; // TODO WIP Ask for confirmation // Configs: SnapShowExecutor uint256 snapShotWaitingPeriod = 3 days; - uint256 snapShotThreshold = 2; + uint256 snapShotCancelPeriod = 2 days; uint256 snapShotPendingProposalLimit = 3; address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00; @@ -66,7 +66,7 @@ contract YearnBorgDeployScript is Script { console2.log(" BORG type:", borgType); console2.log(" Safe Multisig:", address(ychadSafe)); console2.log(" Snapshot waiting period (secs.):", snapShotWaitingPeriod); - console2.log(" Snapshot threshold:", snapShotThreshold); + console2.log(" Snapshot cancel period (secs.):", snapShotCancelPeriod); console2.log(" Snapshot pending proposal limit:", snapShotPendingProposalLimit); address deployerAddress = vm.addr(deployerPrivateKey); @@ -87,7 +87,7 @@ contract YearnBorgDeployScript is Script { // SnapShotExecutor - snapShotExecutor = new SnapShotExecutor(coreAuth, address(ychadSafe), address(oracle), snapShotWaitingPeriod, snapShotThreshold, snapShotPendingProposalLimit); + snapShotExecutor = new SnapShotExecutor(coreAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelPeriod, snapShotPendingProposalLimit); // Add modules diff --git a/src/libs/governance/snapShotExecutor.sol b/src/libs/governance/snapShotExecutor.sol index c53d35c..a5087bb 100644 --- a/src/libs/governance/snapShotExecutor.sol +++ b/src/libs/governance/snapShotExecutor.sol @@ -6,14 +6,12 @@ import "openzeppelin/contracts/utils/Address.sol"; contract SnapShotExecutor is BorgAuthACL { - address public borgSafe; - address public oracle; + address public oracle; // TODO Need to be transferrable for future on-chain governance upgrades uint256 public waitingPeriod; - uint256 public threshold; - uint256 public postVetoWaitingPeriod; // TODO Review against clearBorg version + uint256 public cancelPeriod; uint256 public pendingProposalCount; uint256 public pendingProposalLimit; - + struct proposal { address target; uint256 value; @@ -24,42 +22,33 @@ contract SnapShotExecutor is BorgAuthACL { error SnapShotExecutor_NotAuthorized(); error SnapShotExecutor_InvalidProposal(); - error SnapShotExecutor_ExecutionFailed(); - error SnapShotExecutor_ZeroAddress(); error SnapShotExecutor_WaitingPeriod(); error SnapShotExeuctor_InvalidParams(); - error SnapShotExecutor_AlreadyVoted(); error SnapShotExecutor_TooManyPendingProposals(); //events event ProposalCreated(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); event ProposalExecuted(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp, bool success); event ProposalCanceled(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); - event VotedToCancel(address indexed voter, bytes32 proposalId); mapping(bytes32 => proposal) public pendingProposals; - mapping(bytes32 => address[]) public cancelVotes; modifier onlyOracle() { if (msg.sender != oracle) revert SnapShotExecutor_NotAuthorized(); _; } - constructor(BorgAuth _auth, address _borgSafe, address _oracle, uint256 _waitingPeriod, uint256 _threshold, uint256 _pendingProposals) BorgAuthACL(_auth) { - if(_borgSafe == address(0) || _oracle == address(0)) revert SnapShotExecutor_ZeroAddress(); - borgSafe = _borgSafe; + constructor(BorgAuth _auth, address _oracle, uint256 _waitingPeriod, uint256 _cancelPeriod, uint256 _pendingProposals) BorgAuthACL(_auth) { oracle = _oracle; if(_waitingPeriod < 1 minutes) revert SnapShotExeuctor_InvalidParams(); waitingPeriod = _waitingPeriod; - if(_threshold < 2) revert SnapShotExeuctor_InvalidParams(); - threshold = _threshold; + if(_cancelPeriod < 1 minutes) revert SnapShotExeuctor_InvalidParams(); + cancelPeriod = _cancelPeriod; pendingProposalLimit = _pendingProposals; } function propose(address target, uint256 value, bytes calldata cdata, string memory description) external onlyOracle() returns (bytes32) { - // TODO Review against clearBorg version - if(block.timestamp < postVetoWaitingPeriod) revert SnapShotExecutor_WaitingPeriod(); - if(pendingProposalCount>pendingProposalLimit) revert SnapShotExecutor_TooManyPendingProposals(); + if(pendingProposalCount >= pendingProposalLimit) revert SnapShotExecutor_TooManyPendingProposals(); bytes32 proposalId = keccak256(abi.encodePacked(target, value, cdata, description)); pendingProposals[proposalId] = proposal(target, value, cdata, description, block.timestamp + waitingPeriod); pendingProposalCount++; @@ -71,56 +60,18 @@ contract SnapShotExecutor is BorgAuthACL { proposal memory p = pendingProposals[proposalId]; if (p.timestamp > block.timestamp) revert SnapShotExecutor_WaitingPeriod(); if(p.target == address(0)) revert SnapShotExecutor_InvalidProposal(); - (bool success, bytes memory returndata) = p.target.call{value: p.value}(p.cdata); + (bool success, ) = p.target.call{value: p.value}(p.cdata); emit ProposalExecuted(proposalId, p.target, p.value, p.cdata, p.description, p.timestamp, success); pendingProposalCount--; delete pendingProposals[proposalId]; } - // TODO Review against clearBorg version - function voteToCancel(bytes32 proposalId) external { - if(pendingProposals[proposalId].timestamp < block.timestamp) revert SnapShotExecutor_InvalidProposal(); - if(msg.sender != borgSafe) - { - address adapter = AUTH.roleAdapters(AUTH.OWNER_ROLE()); - if(adapter == address(0)) revert SnapShotExecutor_NotAuthorized(); - if (!(IAuthAdapter(adapter).isAuthorized(msg.sender) >= AUTH.OWNER_ROLE())) revert SnapShotExecutor_NotAuthorized(); - } - if(alreadyVotedCheck(proposalId)) revert SnapShotExecutor_AlreadyVoted(); - cancelVotes[proposalId].push(msg.sender); - emit VotedToCancel(msg.sender, proposalId); - - //Check if the proposal should be canceled - bool hasBORG = (msg.sender == borgSafe); - if(!hasBORG) { - for(uint256 i = 0; i < cancelVotes[proposalId].length; i++) { - if(cancelVotes[proposalId][i] == borgSafe) { - hasBORG = true; - break; - } - } - } - if(cancelVotes[proposalId].length >= threshold && hasBORG) { - cancel(proposalId); - } - } - - // TODO Review against clearBorg version - function alreadyVotedCheck(bytes32 proposalId) internal view returns (bool) { - for(uint256 i = 0; i < cancelVotes[proposalId].length; i++) { - if(cancelVotes[proposalId][i] == msg.sender) { - return true; - } - } - return false; - } - - // TODO Review against clearBorg version - function cancel(bytes32 proposalId) internal { + function cancel(bytes32 proposalId) external { proposal memory p = pendingProposals[proposalId]; - delete pendingProposals[proposalId]; - postVetoWaitingPeriod = block.timestamp + 5 minutes; + if (p.timestamp + cancelPeriod > block.timestamp) revert SnapShotExecutor_WaitingPeriod(); + if(p.target == address(0)) revert SnapShotExecutor_InvalidProposal(); pendingProposalCount--; + delete pendingProposals[proposalId]; emit ProposalCanceled(proposalId, p.target, p.value, p.cdata, p.description, p.timestamp); } diff --git a/test/snapShotExecutor.t.sol b/test/snapShotExecutor.t.sol new file mode 100644 index 0000000..c001f4e --- /dev/null +++ b/test/snapShotExecutor.t.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import "forge-std/Test.sol"; +import "solady/tokens/ERC20.sol"; +import {borgCore} from "../src/borgCore.sol"; +import {ejectImplant} from "../src/implants/ejectImplant.sol"; +import {BorgAuth} from "../src/libs/auth.sol"; +import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; +import {IGnosisSafe, GnosisTransaction, IMultiSendCallOnly} from "../test/libraries/safe.t.sol"; + +contract SnapShotExecutorTest is Test { + + address owner = vm.addr(1); + address oracle = vm.addr(2); + address alice = vm.addr(3); + + BorgAuth auth; + SnapShotExecutor snapShotExecutor; + + event ProposalCreated(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); + event ProposalExecuted(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp, bool success); + event ProposalCanceled(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); + + function setUp() public virtual { + auth = new BorgAuth(); + snapShotExecutor = new SnapShotExecutor( + auth, + oracle, + 3 days, // waitingPeriod + 2 days, // cancelPeriod + 3 // pendingProposalLimit + ); + + // Transferring auth ownership + auth.updateRole(owner, auth.OWNER_ROLE()); + auth.zeroOwner(); + } + + /// @dev Metadata should meet specs + function testMeta() public view { + assertEq(snapShotExecutor.oracle(), oracle, "Unexpected oracle address"); + assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); + assertEq(snapShotExecutor.cancelPeriod(), 2 days, "Unexpected cancelPeriod"); + assertEq(snapShotExecutor.pendingProposalCount(), 0, "Unexpected pendingProposalCount"); + assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); + } + + /// @dev BorgAuth instances should be properly assigned and configured + function testAuth() public { + assertEq(address(snapShotExecutor.AUTH()), address(auth), "Unexpected SnapShotExecutor auth"); + + uint256 ownerRole = auth.OWNER_ROLE(); + + // Verify owners + auth.onlyRole(ownerRole, owner); + + // Verify not owners + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(this))); + auth.onlyRole(ownerRole, address(this)); + } + + /// @dev Normal proposal workflow should pass + function testNormalProposal() public { + deal(address(snapShotExecutor), 1 ether); + + // Proposal by oracle should pass + + vm.prank(oracle); + vm.expectEmit(); + emit ProposalCreated( + keccak256(abi.encodePacked(alice, uint256(1 ether), "", "Send alice 1 ether")), + alice, 1 ether, "", "Send alice 1 ether", block.timestamp + 3 days + ); + bytes32 proposalId = snapShotExecutor.propose( + address(alice), // target + 1 ether, // value + "", // cdata + "Send alice 1 ether" + ); + assertEq(snapShotExecutor.pendingProposalCount(), 1, "Expect 1 pending proposal"); + (address target, uint256 value, bytes memory cdata, string memory description, uint256 timestamp) = snapShotExecutor.pendingProposals(proposalId); + assertEq(target, alice, "Expect valid pending proposal details"); + assertEq(value, 1 ether, "Expect valid pending proposal details"); + assertEq(cdata, "", "Expect valid pending proposal details"); + assertEq(description, "Send alice 1 ether", "Expect valid pending proposal details"); + assertEq(timestamp, block.timestamp + 3 days, "Expect valid pending proposal details"); + + // execute() should fail within waiting period + + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_WaitingPeriod.selector)); + vm.prank(owner); + snapShotExecutor.execute(proposalId); + + // After waiting period + skip(snapShotExecutor.waitingPeriod()); + + // execute() should fail if not executed from owner + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, auth.OWNER_ROLE(), address(this))); + snapShotExecutor.execute(proposalId); + + // execute() should succeed if executed from owner + + vm.expectEmit(); + emit ProposalExecuted(proposalId, alice, 1 ether, "", "Send alice 1 ether", timestamp, true); + vm.prank(owner); + snapShotExecutor.execute(proposalId); + + assertEq(alice.balance, 1 ether, "alice should receive 1 ether"); + assertEq(snapShotExecutor.pendingProposalCount(), 0, "Expect 0 pending proposal"); + { + (address newTarget, , , , ) = snapShotExecutor.pendingProposals(proposalId); + assertEq(newTarget, address(0), "Expect cleared pending proposal"); + } + } + + /// @dev Non-oracle should not be able to propose + function test_RevertIf_NotOracleProposal() public { + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); + snapShotExecutor.propose( + address(alice), // target + 0, // value + "", // cdata + "Arbitrary instruction" + ); + } + + /// @dev Proposal can be cancelled by anyone after waiting + cancel period + function testCancelProposal() public { + deal(address(snapShotExecutor), 1 ether); + + vm.prank(oracle); + bytes32 proposalId = snapShotExecutor.propose( + address(alice), // target + 1 ether, // value + "", // cdata + "Send alice 1 ether" + ); + (, , , , uint256 timestamp) = snapShotExecutor.pendingProposals(proposalId); + + // cancel() should fail within waiting period + + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_WaitingPeriod.selector)); + snapShotExecutor.cancel(proposalId); + + // After waiting period + skip(snapShotExecutor.waitingPeriod()); + + // cancel() should fail within cancel period + + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_WaitingPeriod.selector)); + snapShotExecutor.cancel(proposalId); + + // After cancel period + skip(snapShotExecutor.cancelPeriod()); + + // cancel() should succeed now + + vm.expectEmit(); + emit ProposalCanceled(proposalId, alice, 1 ether, "", "Send alice 1 ether", timestamp); + snapShotExecutor.cancel(proposalId); + + assertEq(address(snapShotExecutor).balance, 1 ether, "Proposal should not be executed"); + assertEq(snapShotExecutor.pendingProposalCount(), 0, "Expect 0 pending proposal"); + { + (address newTarget, , , , ) = snapShotExecutor.pendingProposals(proposalId); + assertEq(newTarget, address(0), "Expect cleared pending proposal"); + } + } + + /// @dev Pending proposal limit should be enforced + function test_RevertIf_ExceedPendingProposalLimit() public { + vm.startPrank(oracle); + + snapShotExecutor.propose( + address(alice), // target + 0, // value + "", // cdata + "Arbitrary instruction" + ); + snapShotExecutor.propose( + address(alice), // target + 0, // value + "", // cdata + "Arbitrary instruction" + ); + snapShotExecutor.propose( + address(alice), // target + 0, // value + "", // cdata + "Arbitrary instruction" + ); + + // Should failed due to the limit + + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_TooManyPendingProposals.selector)); + snapShotExecutor.propose( + address(alice), // target + 0, // value + "", // cdata + "Arbitrary instruction" + ); + + vm.stopPrank(); + } +} diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 978d28a..4e90656 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -46,7 +46,7 @@ contract YearnBorgAcceptanceTest is Test { function testBorgMeta() public { assertEq(core.id(), "Yearn BORG", "Unexpected BORG ID"); assertEq(core.borgType(), 0x3, "Unexpected BORG Core type"); - assertEq(uint8(core.borgMode()), uint8(borgCore.borgModes.unrestricted), "Unexpected BORG Core mode"); + assertEq(uint8(core.borgMode()), uint8(borgCore.borgModes.blacklist), "Unexpected BORG Core mode"); } /// @dev BorgAuth instances should be proper assigned and configured @@ -76,7 +76,7 @@ contract YearnBorgAcceptanceTest is Test { function testSnapShotExecutorMeta() public { assertEq(snapShotExecutor.oracle(), oracle, "Unexpected oracle"); assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); - assertEq(snapShotExecutor.threshold(), 2, "Unexpected threshold"); + assertEq(snapShotExecutor.cancelPeriod(), 2 days, "Unexpected cancelPeriod"); assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); } From 06a8fb5a628be41ffdbf41f9ae8e19b49f1b9754 Mon Sep 17 00:00:00 2001 From: detoo Date: Wed, 16 Apr 2025 14:59:24 -0700 Subject: [PATCH 14/52] chore: Plan for on-chain governance transition --- README-yearnBorg.md | 23 +++++++++++++++++++++-- scripts/yearnBorg.s.sol | 2 +- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index d48301a..7422ce8 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -62,8 +62,27 @@ all coming operations as listed above will require approval of both `ychad.eth` 1. Operation is initiated on the MetaLeX OS webapp 2. A Snapshot proposal will be submitted via API using Yearn's existing voting settings -3. MetaLeX's Snapshot oracle will submit the results onchain to an executor contract (`SnapShotExecutor`), which will have the proposed transaction pending for co-approval -4. `ychad.eth` will approve by executing the operation through the MetaLeX OS webapp +3. MetaLeX's Snapshot oracle (`oracle`) will submit the results onchain to an executor contract (`SnapShotExecutor`), which will have the proposed transaction pending for co-approval +4. `ychad.eth` will co-approve it by executing the operation through the MetaLeX OS webapp + +### Future On-chain Governance Transition + +The veYFI Snapshot governance will be replaced with on-chain governance at some point, at which we will implement a bridge contract `YearnGovExecutor` +that looks up voting results on Yearn's on-chain governance contract and performs the following: +- Verify the vote is passed +- Extract and execute its instructions (specifically `target`, `value`, `calldata`) + +The transition process is as follows: + +1. Deploy `YearnGovExecutor` and set `ychad.eth` as its owner +2. A Snapshot proposal will be submitted to replace `SnapShotExecutor` with `YearnGovExecutor`. + More specifically, it is done by transferring `SudoImplant`'s and `EjectImplant`'s owner to `YearnGovExecutor` + +After the transition, the co-approval process will become: + +1. Operation is initiated on the MetaLeX OS webapp +2. An on-chain proposal will be submitted to Yearn governance contract +3. Once the vote passed, `ychad.eth` will co-approve it by executing the operation through MetaLex OS webapp ## Key Parameters diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 919a372..409921a 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -40,7 +40,7 @@ contract YearnBorgDeployScript is Script { // Configs: SnapShowExecutor - uint256 snapShotWaitingPeriod = 3 days; + uint256 snapShotWaitingPeriod = 3 days; // TODO Is it still necessary? uint256 snapShotCancelPeriod = 2 days; uint256 snapShotPendingProposalLimit = 3; address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00; From c0597f048a6fb85ac37d7487d6b2784faa8132e1 Mon Sep 17 00:00:00 2001 From: detoo Date: Wed, 16 Apr 2025 15:20:14 -0700 Subject: [PATCH 15/52] chore: Simplify on-chain governance architectures --- README-yearnBorg.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index 7422ce8..c64a180 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -67,22 +67,21 @@ all coming operations as listed above will require approval of both `ychad.eth` ### Future On-chain Governance Transition -The veYFI Snapshot governance will be replaced with on-chain governance at some point, at which we will implement a bridge contract `YearnGovExecutor` -that looks up voting results on Yearn's on-chain governance contract and performs the following: -- Verify the vote is passed -- Extract and execute its instructions (specifically `target`, `value`, `calldata`) +The veYFI Snapshot governance will be replaced with on-chain governance at some point. Let's call the contract `YearnGovExecutor`. +To integrate with the co-approval process, `YearnGovExecutor` must satisfy: +- Each proposal should have generic transaction fields (`target`, `value`, `calldata`) or equivalents so that `YearnGovExecutor` knows how to execute after the proposal is passed +- Proposals related to the BORG [Restricted Admin Workflows](#restricted-admin-workflows-tbd) should be exclusively executed by `ychad.eth` because that's how the co-approval process is enforced The transition process is as follows: -1. Deploy `YearnGovExecutor` and set `ychad.eth` as its owner -2. A Snapshot proposal will be submitted to replace `SnapShotExecutor` with `YearnGovExecutor`. +1. A final Snapshot proposal will be submitted to replace `SnapShotExecutor` with `YearnGovExecutor`. More specifically, it is done by transferring `SudoImplant`'s and `EjectImplant`'s owner to `YearnGovExecutor` After the transition, the co-approval process will become: 1. Operation is initiated on the MetaLeX OS webapp -2. An on-chain proposal will be submitted to Yearn governance contract -3. Once the vote passed, `ychad.eth` will co-approve it by executing the operation through MetaLex OS webapp +2. An on-chain proposal will be submitted to `YearnGovExecutor` +3. Once the vote passed, `ychad.eth` will co-approve it by executing the operation through the MetaLeX OS webapp ## Key Parameters From 383e4a92d81a32d81e5351b809181397e2884c27 Mon Sep 17 00:00:00 2001 From: detoo Date: Wed, 16 Apr 2025 19:32:34 -0700 Subject: [PATCH 16/52] wip: feat: Restrict admin operations --- scripts/yearnBorg.s.sol | 51 ++++++++++++++++++++++++++++++++- test/libraries/safeTxHelper.sol | 49 +++++++++++++++++++++++++++++++ test/yearnBorgAcceptance.t.sol | 49 +++++++++++++++++++++++++------ 3 files changed, 140 insertions(+), 9 deletions(-) diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 409921a..db12abb 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -85,7 +85,56 @@ contract YearnBorgDeployScript is Script { coreAuth = new BorgAuth(); core = new borgCore(coreAuth, borgType, borgMode, borgIdentifier, address(ychadSafe)); - // SnapShotExecutor + // Restrict admin operations + + // Safe.OwnerManager + core.addFullAccessOrBlockContract(address(ychadSafe)); + core.addPolicyMethod(address(ychadSafe), "addOwnerWithThreshold(address,uint256)"); + core.addPolicyMethod(address(ychadSafe), "removeOwner(address,address,uint256)"); + core.addPolicyMethod(address(ychadSafe), "swapOwner(address,address,address)"); + core.addPolicyMethod(address(ychadSafe), "changeThreshold(uint256)"); + + // Safe.GuardManager + core.addPolicyMethod(address(ychadSafe), "setGuard(address)"); + + // Safe.ModuleManager + core.addPolicyMethod(address(ychadSafe), "enableModule(address)"); + core.addPolicyMethod(address(ychadSafe), "disableModule(address,address)"); + + // BORG admin + + bytes32[] memory matches = new bytes32[](1); + matches[0] = keccak256(abi.encodePacked(address(ychadSafe))); + + // Not allowed to remove ychad.eth itself from policy + core.addExactMatchParameterConstraint( + address(core), + "removeContract(address)", + borgCore.ParamType.ADDRESS, + matches, + 16, // 4 + (32 - 20) = 16 + 20 // length of an address + ); + // Not allowed to remove any ychad.eth function from policy + core.addExactMatchParameterConstraint( + address(core), + "removePolicyMethod(address,string)", + borgCore.ParamType.ADDRESS, + matches, + 16, // 4 + (32 - 20) = 16 + 20 // length of an address + ); + // Not allowed to remove any ychad.eth function parameter constraint from policy + core.addExactMatchParameterConstraint( + address(core), + "removeParameterConstraint(address,string,uint256)", + borgCore.ParamType.ADDRESS, + matches, + 16, // 4 + (32 - 20) = 16 + 20 // length of an address + ); + + // Create SnapShotExecutor snapShotExecutor = new SnapShotExecutor(coreAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelPeriod, snapShotPendingProposalLimit); diff --git a/test/libraries/safeTxHelper.sol b/test/libraries/safeTxHelper.sol index fd72203..1a93012 100644 --- a/test/libraries/safeTxHelper.sol +++ b/test/libraries/safeTxHelper.sol @@ -3,8 +3,11 @@ pragma solidity 0.8.20; import {CommonBase} from "forge-std/Base.sol"; +import {OwnerManager} from "safe-contracts/base/OwnerManager.sol"; +import {ModuleManager} from "safe-contracts/base/ModuleManager.sol"; import {BaseAllocation} from "metavest/BaseAllocation.sol"; import "./safe.t.sol"; +import {borgCore} from "../../src/borgCore.sol"; // TODO Similar codes are used in other test files as well, consider refactoring and merging them here contract SafeTxHelper is CommonBase { @@ -65,6 +68,15 @@ contract SafeTxHelper is CommonBase { return txData; } + function getDisableModuleData(address prevModule, address module) public view returns (GnosisTransaction memory) { + bytes memory cdata = abi.encodeWithSelector( + ModuleManager.disableModule.selector, + prevModule, + module + ); + return GnosisTransaction({to: address(safe), value: 0, data: cdata}); + } + function getSetGuardData(address core) public view returns (GnosisTransaction memory) { bytes4 funcSig = bytes4( keccak256("setGuard(address)") @@ -153,6 +165,34 @@ contract SafeTxHelper is CommonBase { return txData; } + function getRemoveOwnerData(address prevOwner, address owner) public view returns (GnosisTransaction memory) { + bytes memory cdata = abi.encodeWithSelector( + OwnerManager.removeOwner.selector, + prevOwner, + owner, + 1 + ); + return GnosisTransaction({to: address(safe), value: 0, data: cdata}); + } + + function getSwapOwnerData(address prevOwner, address oldOwner, address newOwner) public view returns (GnosisTransaction memory) { + bytes memory cdata = abi.encodeWithSelector( + OwnerManager.swapOwner.selector, + prevOwner, + oldOwner, + newOwner + ); + return GnosisTransaction({to: address(safe), value: 0, data: cdata}); + } + + function getChangeThresholdData(uint256 threshold) public view returns (GnosisTransaction memory) { + bytes memory cdata = abi.encodeWithSelector( + OwnerManager.changeThreshold.selector, + threshold + ); + return GnosisTransaction({to: address(safe), value: 0, data: cdata}); + } + function getAddRecipientGuardData(address to, address allow, uint256 amount) public view returns (GnosisTransaction memory) { bytes4 addRecipientMethod = bytes4( keccak256("addRecipient(address,uint256)") @@ -193,6 +233,15 @@ contract SafeTxHelper is CommonBase { return txData; } + function getRemovePolicyMethodGuardData(address to, address allow, string memory methodSignature) public view returns (GnosisTransaction memory) { + bytes memory cdata = abi.encodeWithSelector( + borgCore.removePolicyMethod.selector, + allow, + methodSignature + ); + return GnosisTransaction({to: to, value: 0, data: cdata}); + } + function getCreateGrantData(address opGrant, address token, address rec, uint256 amount) public view returns (GnosisTransaction memory) { bytes4 funcSig = bytes4( keccak256("createDirectGrant(address,address,uint256)") diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 4e90656..045a73d 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -171,12 +171,45 @@ contract YearnBorgAcceptanceTest is Test { ); } -// /// @dev Safe should not be able to add/remove signer itself -// function test_RevertIf_DirectMemberManagement() public { -// safeTxHelper.executeSingle( -// safeTxHelper.getAddOwnerData(alice), // tx -// abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData -// ); -// // TODO It does not revert! -// } + /// @dev Safe should not be able to unilaterally perform restricted admin operations without DAO approval + function test_RevertIf_DirectAdminOperations() public { + // Safe.OwnerManager + + safeTxHelper.executeSingle( + safeTxHelper.getAddOwnerData(alice), // tx + abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData + ); + safeTxHelper.executeSingle( + safeTxHelper.getRemoveOwnerData(address(0x1), testSigner), // tx + abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData + ); + safeTxHelper.executeSingle( + safeTxHelper.getSwapOwnerData(address(0x1), testSigner, alice), // tx + abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData + ); + safeTxHelper.executeSingle( + safeTxHelper.getChangeThresholdData(2), // tx + abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData + ); + + // Safe.GuardManager + + safeTxHelper.executeSingle( + safeTxHelper.getSetGuardData(address(0)), // tx + abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData + ); + + // Safe.ModuleManager + + safeTxHelper.executeSingle( + safeTxHelper.getAddModuleData(address(0)), // tx + abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData + ); + safeTxHelper.executeSingle( + safeTxHelper.getDisableModuleData(address(0), address(eject)), // tx + abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData + ); + + // TODO BORG admin + } } From 43cb2de22af299f8aac47deb8f7f16e25df889ea Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 17 Apr 2025 09:25:47 -0700 Subject: [PATCH 17/52] feat: Restrict admin operations. Add tests --- README-yearnBorg.md | 5 +++-- src/borgCore.sol | 15 +++++-------- test/libraries/safeTxHelper.sol | 33 +++++++++++++++++++++------- test/yearnBorgAcceptance.t.sol | 39 +++++++++++++++++++++++++++++++-- 4 files changed, 71 insertions(+), 21 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index c64a180..cf0d185 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -53,11 +53,12 @@ graph TD `ychad.eth` will be prohibited from unilaterally performing the following admin operations: -- Add / remove / swap signers +- Add / remove / swap signers / change threshold - Set Guards +- Remove rules from Guard - Add / disable Modules -Except existing signers, Guard (BORG Core) and Modules (BORG Implants), +Except existing signers, Modules (BORG Implants), Guard (BORG Core) and its set rules, all coming operations as listed above will require approval of both `ychad.eth` and DAO, with process as such: 1. Operation is initiated on the MetaLeX OS webapp diff --git a/src/borgCore.sol b/src/borgCore.sol index f3a0c5f..eeee599 100644 --- a/src/borgCore.sol +++ b/src/borgCore.sol @@ -196,7 +196,7 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { revert BORG_CORE_DelegateCallNotAuthorized(); } - if(!isMethodCallAllowed(to, data)) + if(isMethodCallMatched(to, data)) revert BORG_CORE_MethodNotAuthorized(); if (!_checkCooldown(to, bytes4(data[:4]))) { @@ -235,7 +235,7 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { revert BORG_CORE_DelegateCallNotAuthorized(); } if(!policy[to].fullAccessOrBlock) - if(!isMethodCallAllowed(to, data)) + if(!isMethodCallMatched(to, data)) revert BORG_CORE_MethodNotAuthorized(); //Check Cooldown if (!_checkCooldown(to, bytes4(data[:4]))) { @@ -629,11 +629,11 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { emit ParameterConstraintRemoved(_contract, _methodSignature, _byteOffset); } - /// @dev Function to check if a contract method call is allowed + /// @dev Function to check if a contract method call matches the pattern /// @param _contract address, the address of the contract /// @param _methodCallData bytes, the data of the method call /// @return bool, true if the method call is allowed - function isMethodCallAllowed( + function isMethodCallMatched( address _contract, bytes calldata _methodCallData ) public view returns (bool) { @@ -641,12 +641,9 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { bytes4 methodSelector = bytes4(_methodCallData[:4]); MethodConstraint storage methodConstraint = policy[_contract].methods[methodSelector]; - if (!methodConstraint.enabled && borgMode == borgModes.whitelist) + if (!methodConstraint.enabled) { return false; - - - if(methodConstraint.enabled && methodConstraint.paramOffsets.length == 0 && borgMode == borgModes.blacklist) - return false; + } // Iterate through the whitelist constraints for the method for (uint256 i = 0; i < methodConstraint.paramOffsets.length;) { diff --git a/test/libraries/safeTxHelper.sol b/test/libraries/safeTxHelper.sol index 1a93012..508f2ec 100644 --- a/test/libraries/safeTxHelper.sol +++ b/test/libraries/safeTxHelper.sol @@ -90,6 +90,13 @@ contract SafeTxHelper is CommonBase { return txData; } + function getGetThresholdData() public view returns (GnosisTransaction memory) { + bytes memory cdata = abi.encodeWithSelector( + OwnerManager.getThreshold.selector + ); + return GnosisTransaction({to: address(safe), value: 0, data: cdata}); + } + function getNativeTransferData(address to, uint256 amount) public view returns (GnosisTransaction memory) { // Send the value with no data GnosisTransaction memory txData = GnosisTransaction({to: to, value: amount, data: ""}); @@ -193,55 +200,65 @@ contract SafeTxHelper is CommonBase { return GnosisTransaction({to: address(safe), value: 0, data: cdata}); } - function getAddRecipientGuardData(address to, address allow, uint256 amount) public view returns (GnosisTransaction memory) { + function getAddRecipientGuardData(address to, address _contract, uint256 amount) public view returns (GnosisTransaction memory) { bytes4 addRecipientMethod = bytes4( keccak256("addRecipient(address,uint256)") ); bytes memory recData = abi.encodeWithSelector( addRecipientMethod, - address(allow), + address(_contract), amount ); GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: recData}); return txData; } - function getRemoveRecepientGuardData(address to, address allow) public view returns (GnosisTransaction memory) { + function getRemoveRecepientGuardData(address to, address _contract) public view returns (GnosisTransaction memory) { bytes4 removeRecepientMethod = bytes4( keccak256("removeRecepient(address)") ); bytes memory recData = abi.encodeWithSelector( removeRecepientMethod, - address(allow) + address(_contract) ); GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: recData}); return txData; } - function getRemoveContractGuardData(address to, address allow) public view returns (GnosisTransaction memory) { + function getRemoveContractGuardData(address to, address _contract) public view returns (GnosisTransaction memory) { bytes4 removeContractMethod = bytes4( keccak256("removeContract(address)") ); bytes memory recData = abi.encodeWithSelector( removeContractMethod, - address(allow) + address(_contract) ); GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: recData}); return txData; } - function getRemovePolicyMethodGuardData(address to, address allow, string memory methodSignature) public view returns (GnosisTransaction memory) { + function getRemovePolicyMethodGuardData(address to, address _contract, string memory methodSignature) public view returns (GnosisTransaction memory) { bytes memory cdata = abi.encodeWithSelector( borgCore.removePolicyMethod.selector, - allow, + _contract, methodSignature ); return GnosisTransaction({to: to, value: 0, data: cdata}); } + function getRemoveParameterConstraintGuardData(address to, address _contract, string memory methodSignature, uint256 byteOffset) public view returns (GnosisTransaction memory) { + bytes memory cdata = abi.encodeWithSelector( + borgCore.removeParameterConstraint.selector, + _contract, + methodSignature, + byteOffset + ); + return GnosisTransaction({to: to, value: 0, data: cdata}); + } + function getCreateGrantData(address opGrant, address token, address rec, uint256 amount) public view returns (GnosisTransaction memory) { bytes4 funcSig = bytes4( keccak256("createDirectGrant(address,address,uint256)") diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 045a73d..e105dd9 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -171,8 +171,27 @@ contract YearnBorgAcceptanceTest is Test { ); } + /// @dev Safe should be able to unilaterally perform non-restricted admin operations without DAO approval + function testAllowedAdminOperations() public { + // The test cases are NOT exhaustive + + // Safe + safeTxHelper.executeSingle(safeTxHelper.getGetThresholdData()); + + // BORG admin + safeTxHelper.executeSingle(safeTxHelper.getAddRecipientGuardData( + address(core), // to + alice, // recipient + 1 ether // amount + )); + (, uint256 transactionLimit) = core.policyRecipients(alice); + assertEq(transactionLimit, 1 ether, "Recipient policy should be set"); + } + /// @dev Safe should not be able to unilaterally perform restricted admin operations without DAO approval - function test_RevertIf_DirectAdminOperations() public { + function test_RevertIf_RestrictedAdminOperations() public { + // The test cases are exhaustive + // Safe.OwnerManager safeTxHelper.executeSingle( @@ -210,6 +229,22 @@ contract YearnBorgAcceptanceTest is Test { abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData ); - // TODO BORG admin + // BORG admin + + // Not allowed to remove ychad.eth itself from policy + safeTxHelper.executeSingle( + safeTxHelper.getRemoveContractGuardData(address(core), address(ychadSafe)), // tx + abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData + ); + // Not allowed to remove any ychad.eth function from policy + safeTxHelper.executeSingle( + safeTxHelper.getRemovePolicyMethodGuardData(address(core), address(ychadSafe), "func()"), // tx + abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData + ); + // Not allowed to remove any ychad.eth function parameter constraint from policy + safeTxHelper.executeSingle( + safeTxHelper.getRemoveParameterConstraintGuardData(address(core), address(ychadSafe), "func(address)", 16), // tx + abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData + ); } } From b318b70664343f5c6ea41870fb7cc08282fe258e Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 17 Apr 2025 11:19:32 -0700 Subject: [PATCH 18/52] feat: Update yearn BORG ownership and admin permissions --- README-yearnBorg.md | 6 ++-- scripts/yearnBorg.s.sol | 45 +++++------------------------ src/borgCore.sol | 15 ++++++---- test/yearnBorgAcceptance.t.sol | 53 ++++++++++++++-------------------- 4 files changed, 42 insertions(+), 77 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index cf0d185..4fa0d79 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -18,8 +18,9 @@ graph TD end snapshotExecutor[SnapshotExecutor] + + borg -->|"guard"| ychad - ychad -->|"owner
guard by"| borg ychad -->|"owner
execute(proposal)"| snapshotExecutor %% implants -->|modules| ychad @@ -54,9 +55,8 @@ graph TD `ychad.eth` will be prohibited from unilaterally performing the following admin operations: - Add / remove / swap signers / change threshold -- Set Guards -- Remove rules from Guard - Add / disable Modules +- Set Guards Except existing signers, Modules (BORG Implants), Guard (BORG Core) and its set rules, all coming operations as listed above will require approval of both `ychad.eth` and DAO, with process as such: diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index db12abb..ebc3b72 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -101,42 +101,10 @@ contract YearnBorgDeployScript is Script { core.addPolicyMethod(address(ychadSafe), "enableModule(address)"); core.addPolicyMethod(address(ychadSafe), "disableModule(address,address)"); - // BORG admin - - bytes32[] memory matches = new bytes32[](1); - matches[0] = keccak256(abi.encodePacked(address(ychadSafe))); - - // Not allowed to remove ychad.eth itself from policy - core.addExactMatchParameterConstraint( - address(core), - "removeContract(address)", - borgCore.ParamType.ADDRESS, - matches, - 16, // 4 + (32 - 20) = 16 - 20 // length of an address - ); - // Not allowed to remove any ychad.eth function from policy - core.addExactMatchParameterConstraint( - address(core), - "removePolicyMethod(address,string)", - borgCore.ParamType.ADDRESS, - matches, - 16, // 4 + (32 - 20) = 16 - 20 // length of an address - ); - // Not allowed to remove any ychad.eth function parameter constraint from policy - core.addExactMatchParameterConstraint( - address(core), - "removeParameterConstraint(address,string,uint256)", - borgCore.ParamType.ADDRESS, - matches, - 16, // 4 + (32 - 20) = 16 - 20 // length of an address - ); - // Create SnapShotExecutor - snapShotExecutor = new SnapShotExecutor(coreAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelPeriod, snapShotPendingProposalLimit); + BorgAuth executorAuth = new BorgAuth(); + snapShotExecutor = new SnapShotExecutor(executorAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelPeriod, snapShotPendingProposalLimit); // Add modules @@ -149,11 +117,14 @@ contract YearnBorgDeployScript is Script { true // _allowEjection ); - // Transferring core ownership to the Safe itself - coreAuth.updateRole(address(ychadSafe), coreAuth.OWNER_ROLE()); + // Burn core ownership coreAuth.zeroOwner(); - // Transferring eject implant ownership to SnapShotExecutor + // Transfer executor ownership to ychad.eth + executorAuth.updateRole(address(ychadSafe), executorAuth.OWNER_ROLE()); + executorAuth.zeroOwner(); + + // Transfer eject implant ownership to SnapShotExecutor ejectAuth.updateRole(address(snapShotExecutor), ejectAuth.OWNER_ROLE()); ejectAuth.zeroOwner(); diff --git a/src/borgCore.sol b/src/borgCore.sol index eeee599..914ba05 100644 --- a/src/borgCore.sol +++ b/src/borgCore.sol @@ -196,7 +196,7 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { revert BORG_CORE_DelegateCallNotAuthorized(); } - if(isMethodCallMatched(to, data)) + if(!isMethodCallAllowed(to, data)) revert BORG_CORE_MethodNotAuthorized(); if (!_checkCooldown(to, bytes4(data[:4]))) { @@ -235,7 +235,7 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { revert BORG_CORE_DelegateCallNotAuthorized(); } if(!policy[to].fullAccessOrBlock) - if(!isMethodCallMatched(to, data)) + if(!isMethodCallAllowed(to, data)) revert BORG_CORE_MethodNotAuthorized(); //Check Cooldown if (!_checkCooldown(to, bytes4(data[:4]))) { @@ -629,11 +629,11 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { emit ParameterConstraintRemoved(_contract, _methodSignature, _byteOffset); } - /// @dev Function to check if a contract method call matches the pattern + /// @dev Function to check if a contract method call is allowed /// @param _contract address, the address of the contract /// @param _methodCallData bytes, the data of the method call /// @return bool, true if the method call is allowed - function isMethodCallMatched( + function isMethodCallAllowed( address _contract, bytes calldata _methodCallData ) public view returns (bool) { @@ -641,9 +641,12 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { bytes4 methodSelector = bytes4(_methodCallData[:4]); MethodConstraint storage methodConstraint = policy[_contract].methods[methodSelector]; - if (!methodConstraint.enabled) { + if (!methodConstraint.enabled && borgMode == borgModes.whitelist) + return false; + + + if(methodConstraint.enabled && methodConstraint.paramOffsets.length == 0 && borgMode == borgModes.blacklist) return false; - } // Iterate through the whitelist constraints for the method for (uint256 i = 0; i < methodConstraint.paramOffsets.length;) { diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index e105dd9..1cf74a0 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -21,6 +21,8 @@ contract YearnBorgAcceptanceTest is Test { address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00; + address deployer = address(0); // TODO Update after deployment + uint256 testSignerPrivateKey = 1; address testSigner = vm.addr(testSignerPrivateKey); @@ -37,9 +39,9 @@ contract YearnBorgAcceptanceTest is Test { function setUp() public virtual { // Assume Ethereum mainnet fork after block 22268905 - core = borgCore(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO WIP - eject = ejectImplant(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO WIP - snapShotExecutor = SnapShotExecutor(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO WIP + core = borgCore(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO Update after deployment + eject = ejectImplant(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO Update after deployment + snapShotExecutor = SnapShotExecutor(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO Update after deployment } /// @dev BORG Core metadata should meet specs @@ -52,15 +54,29 @@ contract YearnBorgAcceptanceTest is Test { /// @dev BorgAuth instances should be proper assigned and configured function testAuth() public { BorgAuth coreAuth = core.AUTH(); + BorgAuth executorAuth = snapShotExecutor.AUTH(); BorgAuth ejectAuth = eject.AUTH(); assertNotEq(address(coreAuth), address(ejectAuth), "Core auth instance should not be the same as Eject Implant's"); - assertEq(address(snapShotExecutor.AUTH()), address(coreAuth), "SnapShotExecutor auth should be core auth"); + assertNotEq(address(coreAuth), address(executorAuth), "Core auth instance should not be the same as executor's"); // Verify core auth roles { uint256 ownerRole = coreAuth.OWNER_ROLE(); + // Verify not owners + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(ychadSafe))); coreAuth.onlyRole(ownerRole, address(ychadSafe)); + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(deployer))); + coreAuth.onlyRole(ownerRole, address(deployer)); + } + + // Verify executor auth roles + { + uint256 ownerRole = executorAuth.OWNER_ROLE(); + executorAuth.onlyRole(ownerRole, address(ychadSafe)); + // Verify not owners + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(deployer))); + executorAuth.onlyRole(ownerRole, address(deployer)); } // Verify eject auth roles @@ -70,6 +86,8 @@ contract YearnBorgAcceptanceTest is Test { // Verify not owners vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(ychadSafe))); ejectAuth.onlyRole(ownerRole, address(ychadSafe)); + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(deployer))); + ejectAuth.onlyRole(ownerRole, address(deployer)); } } @@ -177,15 +195,6 @@ contract YearnBorgAcceptanceTest is Test { // Safe safeTxHelper.executeSingle(safeTxHelper.getGetThresholdData()); - - // BORG admin - safeTxHelper.executeSingle(safeTxHelper.getAddRecipientGuardData( - address(core), // to - alice, // recipient - 1 ether // amount - )); - (, uint256 transactionLimit) = core.policyRecipients(alice); - assertEq(transactionLimit, 1 ether, "Recipient policy should be set"); } /// @dev Safe should not be able to unilaterally perform restricted admin operations without DAO approval @@ -228,23 +237,5 @@ contract YearnBorgAcceptanceTest is Test { safeTxHelper.getDisableModuleData(address(0), address(eject)), // tx abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData ); - - // BORG admin - - // Not allowed to remove ychad.eth itself from policy - safeTxHelper.executeSingle( - safeTxHelper.getRemoveContractGuardData(address(core), address(ychadSafe)), // tx - abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData - ); - // Not allowed to remove any ychad.eth function from policy - safeTxHelper.executeSingle( - safeTxHelper.getRemovePolicyMethodGuardData(address(core), address(ychadSafe), "func()"), // tx - abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData - ); - // Not allowed to remove any ychad.eth function parameter constraint from policy - safeTxHelper.executeSingle( - safeTxHelper.getRemoveParameterConstraintGuardData(address(core), address(ychadSafe), "func(address)", 16), // tx - abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData - ); } } From db99ecc171aa296e89f7ad2df2abe8fd33551313 Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 17 Apr 2025 13:50:49 -0700 Subject: [PATCH 19/52] feat: Implement sudoImplant for DAO/BORG co-approval on admin operations --- scripts/yearnBorg.s.sol | 39 +++++++----- src/implants/sudoImplant.sol | 107 ++++++++++++++++++++++++++++++++ test/libraries/safe.t.sol | 2 + test/libraries/safeTxHelper.sol | 9 +++ test/yearnBorg.t.sol | 2 +- test/yearnBorgAcceptance.t.sol | 107 ++++++++++++++++++++++++++------ 6 files changed, 231 insertions(+), 35 deletions(-) create mode 100644 src/implants/sudoImplant.sol diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index ebc3b72..c0075c0 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -5,6 +5,7 @@ import {Script} from "forge-std/Script.sol"; import {console2} from "forge-std/console2.sol"; import {borgCore} from "../src/borgCore.sol"; import {ejectImplant} from "../src/implants/ejectImplant.sol"; +import {sudoImplant} from "../src/implants/sudoImplant.sol"; import {optimisticGrantImplant} from "../src/implants/optimisticGrantImplant.sol"; import {daoVoteGrantImplant} from "../src/implants/daoVoteGrantImplant.sol"; import {daoVetoGrantImplant} from "../src/implants/daoVetoGrantImplant.sol"; @@ -48,18 +49,21 @@ contract YearnBorgDeployScript is Script { SafeTxHelper safeTxHelper; borgCore core; - BorgAuth coreAuth; - BorgAuth ejectAuth; ejectImplant eject; + sudoImplant sudo; SnapShotExecutor snapShotExecutor; + BorgAuth coreAuth; + BorgAuth executorAuth; + BorgAuth implantAuth; + /// @dev For running from `forge script`. Provide the deployer private key through env var. - function run() public returns(borgCore, ejectImplant, SnapShotExecutor, GnosisTransaction[] memory) { + function run() public returns(borgCore, ejectImplant, sudoImplant, SnapShotExecutor, GnosisTransaction[] memory) { return run(vm.envUint("DEPLOYER_PRIVATE_KEY")); } /// @dev For running in tests - function run(uint256 deployerPrivateKey) public returns(borgCore, ejectImplant, SnapShotExecutor, GnosisTransaction[] memory) { + function run(uint256 deployerPrivateKey) public returns(borgCore, ejectImplant, sudoImplant, SnapShotExecutor, GnosisTransaction[] memory) { console2.log("Deploy Configs:"); console2.log(" BORG name:", borgIdentifier); console2.log(" BORG mode:", uint8(borgMode)); @@ -103,19 +107,23 @@ contract YearnBorgDeployScript is Script { // Create SnapShotExecutor - BorgAuth executorAuth = new BorgAuth(); + executorAuth = new BorgAuth(); snapShotExecutor = new SnapShotExecutor(executorAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelPeriod, snapShotPendingProposalLimit); // Add modules - ejectAuth = new BorgAuth(); + implantAuth = new BorgAuth(); eject = new ejectImplant( - ejectAuth, + implantAuth, address(ychadSafe), address(new MockFailSafeImplant()), // _failSafe true, // _allowManagement true // _allowEjection ); + sudo = new sudoImplant( + implantAuth, + address(ychadSafe) + ); // Burn core ownership coreAuth.zeroOwner(); @@ -125,23 +133,26 @@ contract YearnBorgDeployScript is Script { executorAuth.zeroOwner(); // Transfer eject implant ownership to SnapShotExecutor - ejectAuth.updateRole(address(snapShotExecutor), ejectAuth.OWNER_ROLE()); - ejectAuth.zeroOwner(); + implantAuth.updateRole(address(snapShotExecutor), implantAuth.OWNER_ROLE()); + implantAuth.zeroOwner(); vm.stopBroadcast(); console2.log("Deployed addresses:"); console2.log(" Core: ", address(core)); - console2.log(" Core Auth: ", address(coreAuth)); console2.log(" Eject Implant: ", address(eject)); - console2.log(" Eject Auth: ", address(ejectAuth)); + console2.log(" Sudo Implant: ", address(sudo)); console2.log(" SnapShotExecutor: ", address(snapShotExecutor)); + console2.log(" Core Auth: ", address(coreAuth)); + console2.log(" Executor Auth: ", address(executorAuth)); + console2.log(" Implant Auth: ", address(implantAuth)); // Prepare Safe TXs for ychad.eth to execute - GnosisTransaction[] memory safeTxs = new GnosisTransaction[](2); + GnosisTransaction[] memory safeTxs = new GnosisTransaction[](3); safeTxs[0] = safeTxHelper.getAddModuleData(address(eject)); - safeTxs[1] = safeTxHelper.getSetGuardData(address(core)); // Note we must set guard last because it may block ychad.eth from adding any more modules + safeTxs[1] = safeTxHelper.getAddModuleData(address(sudo)); + safeTxs[2] = safeTxHelper.getSetGuardData(address(core)); // Note we must set guard last because it may block ychad.eth from adding any more modules console2.log("Safe TXs:"); for (uint256 i = 0 ; i < safeTxs.length ; i++) { @@ -153,6 +164,6 @@ contract YearnBorgDeployScript is Script { console2.log(""); } - return (core, eject, snapShotExecutor, safeTxs); + return (core, eject, sudo, snapShotExecutor, safeTxs); } } diff --git a/src/implants/sudoImplant.sol b/src/implants/sudoImplant.sol new file mode 100644 index 0000000..d3d1145 --- /dev/null +++ b/src/implants/sudoImplant.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import {GuardManager} from "safe-contracts/base/GuardManager.sol"; +import {ModuleManager} from "safe-contracts/base/ModuleManager.sol"; +import "../interfaces/ISafe.sol"; +import "../libs/auth.sol"; +import "../libs/conditions/conditionManager.sol"; +import "./baseImplant.sol"; +import "../interfaces/IBaseImplant.sol"; + +/// @title sudoImplant - allows the DAO to have admin controls (ex. `setGuard`, `enableModule`) over the BORG members on chain safe access. +/// @author MetaLeX Labs, Inc. + +contract sudoImplant is BaseImplant { + // BORG Safe Implant ID + uint256 public immutable IMPLANT_ID = 7; + + // Errors and Events + error sudoImplant_ConditionsNotMet(); + error sudoImplant_FailedTransaction(); + error sudoImplant_ModuleNotFound(); + + event GuardChanged(address indexed newGuard); + event ModuleEnabled(address indexed module); + event ModuleDisabled(address indexed module); + + /// @param _auth initialize authorization parameters for this contract, including applicable conditions + /// @param _borgSafe address of the applicable BORG's Gnosis Safe which is adding this ejectImplant + constructor(BorgAuth _auth, address _borgSafe) BaseImplant(_auth, _borgSafe) {} + + /// @notice Set new Transaction Guard for the Safe (implant owner-only) + /// @param newGuard The address of the guard to be used or the 0 address to disable the guard + function setGuard(address newGuard) public onlyOwner conditionCheck { + if (!checkConditions("")) revert sudoImplant_ConditionsNotMet(); + + bool success = ISafe(BORG_SAFE).execTransactionFromModule( + BORG_SAFE, + 0, + abi.encodeWithSelector( + GuardManager.setGuard.selector, + newGuard + ), + Enum.Operation.Call + ); + if(!success) + revert sudoImplant_FailedTransaction(); + + emit GuardChanged(newGuard); + } + + /// @notice Enables a module for the Safe. for the Safe (implant owner-only) + /// @param module Module to be whitelisted + function enableModule(address module) public onlyOwner conditionCheck { + if (!checkConditions("")) revert sudoImplant_ConditionsNotMet(); + + bool success = ISafe(BORG_SAFE).execTransactionFromModule( + BORG_SAFE, + 0, + abi.encodeWithSelector( + ModuleManager.enableModule.selector, + module + ), + Enum.Operation.Call + ); + if(!success) + revert sudoImplant_FailedTransaction(); + + emit ModuleEnabled(module); + } + + /// @notice Disables a module for the Safe. for the Safe (implant owner-only) + /// @param module Module to be removed + function disableModule(address module) public onlyOwner conditionCheck { + if (!checkConditions("")) revert sudoImplant_ConditionsNotMet(); + + // Find prevModule on the linked list + address prevModule = address(0x1); + while (true) { + (address[] memory array, ) = ISafe(BORG_SAFE).getModulesPaginated(prevModule, 1); + + if (array.length == 0 || array[0] == address(0) || array[0] == address(0x1)) { + revert sudoImplant_ModuleNotFound(); + } else if (array[0] == module) { + break; + } + + prevModule = array[0]; + } + + bool success = ISafe(BORG_SAFE).execTransactionFromModule( + BORG_SAFE, + 0, + abi.encodeWithSelector( + ModuleManager.disableModule.selector, + prevModule, + module + ), + Enum.Operation.Call + ); + if(!success) + revert sudoImplant_FailedTransaction(); + + emit ModuleDisabled(module); + } +} + diff --git a/test/libraries/safe.t.sol b/test/libraries/safe.t.sol index b54a2ad..5f2ff0c 100644 --- a/test/libraries/safe.t.sol +++ b/test/libraries/safe.t.sol @@ -9,6 +9,8 @@ interface IGnosisSafe { function getOwners() external view returns (address[] memory); + function isModuleEnabled(address module) external view returns (bool); + function setGuard(address guard) external; function addOwnerWithThreshold(address owner, uint256 threshold) external; diff --git a/test/libraries/safeTxHelper.sol b/test/libraries/safeTxHelper.sol index 508f2ec..c37f420 100644 --- a/test/libraries/safeTxHelper.sol +++ b/test/libraries/safeTxHelper.sol @@ -307,6 +307,15 @@ contract SafeTxHelper is CommonBase { return txData; } + function getGuard(address safe) external view returns (address guard) { + // Workaround since getGuard() is not public: + // https://github.com/safe-global/safe-smart-account/blob/c4859f4182be9d3fad0e5b5853c26a013c8b43a2/contracts/base/GuardManager.sol#L83-L97 + + // keccak256("guard_manager.guard.address") + bytes32 GUARD_STORAGE_SLOT = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + return address(uint160(uint256(vm.load(safe, GUARD_STORAGE_SLOT)))); + } + function getSignature( address to, uint256 value, diff --git a/test/yearnBorg.t.sol b/test/yearnBorg.t.sol index 025b4fd..29e1fa9 100644 --- a/test/yearnBorg.t.sol +++ b/test/yearnBorg.t.sol @@ -17,7 +17,7 @@ contract YearnBorgTest is YearnBorgAcceptanceTest { // MetaLex to deploy new BORG contracts and generate corresponding Safe txs for ychad.eth GnosisTransaction[] memory safeTxs; - (core, eject, snapShotExecutor, safeTxs) = (new YearnBorgDeployScript()).run(testSignerPrivateKey); + (core, eject, sudo, snapShotExecutor, safeTxs) = (new YearnBorgDeployScript()).run(testSignerPrivateKey); // Simulate ychad.eth executing the provided Safe TXs (set guard & add module) safeTxHelper.executeBatch(safeTxs); diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 1cf74a0..1d8dd79 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -5,6 +5,7 @@ import "forge-std/Test.sol"; import "solady/tokens/ERC20.sol"; import {borgCore} from "../src/borgCore.sol"; import {ejectImplant} from "../src/implants/ejectImplant.sol"; +import {sudoImplant} from "../src/implants/sudoImplant.sol"; import {BorgAuth} from "../src/libs/auth.sol"; import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; import {SafeTxHelper} from "./libraries/safeTxHelper.sol"; @@ -32,6 +33,7 @@ contract YearnBorgAcceptanceTest is Test { borgCore core; ejectImplant eject; + sudoImplant sudo; SnapShotExecutor snapShotExecutor; /// If run directly, it will test against the predefined deployment. This way it can be run reliably in CICD. @@ -41,6 +43,7 @@ contract YearnBorgAcceptanceTest is Test { core = borgCore(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO Update after deployment eject = ejectImplant(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO Update after deployment + sudo = sudoImplant(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO Update after deployment snapShotExecutor = SnapShotExecutor(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF); // TODO Update after deployment } @@ -53,12 +56,14 @@ contract YearnBorgAcceptanceTest is Test { /// @dev BorgAuth instances should be proper assigned and configured function testAuth() public { + assertEq(address(eject.AUTH()), address(sudo.AUTH()), "All implant's auth should be the same"); + BorgAuth coreAuth = core.AUTH(); BorgAuth executorAuth = snapShotExecutor.AUTH(); - BorgAuth ejectAuth = eject.AUTH(); + BorgAuth implantAuth = eject.AUTH(); - assertNotEq(address(coreAuth), address(ejectAuth), "Core auth instance should not be the same as Eject Implant's"); assertNotEq(address(coreAuth), address(executorAuth), "Core auth instance should not be the same as executor's"); + assertNotEq(address(coreAuth), address(implantAuth), "Core auth instance should not be the same as implant's"); // Verify core auth roles { @@ -79,15 +84,15 @@ contract YearnBorgAcceptanceTest is Test { executorAuth.onlyRole(ownerRole, address(deployer)); } - // Verify eject auth roles + // Verify implant auth roles { - uint256 ownerRole = ejectAuth.OWNER_ROLE(); - ejectAuth.onlyRole(ownerRole, address(snapShotExecutor)); + uint256 ownerRole = implantAuth.OWNER_ROLE(); + implantAuth.onlyRole(ownerRole, address(snapShotExecutor)); // Verify not owners vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(ychadSafe))); - ejectAuth.onlyRole(ownerRole, address(ychadSafe)); + implantAuth.onlyRole(ownerRole, address(ychadSafe)); vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(deployer))); - ejectAuth.onlyRole(ownerRole, address(deployer)); + implantAuth.onlyRole(ownerRole, address(deployer)); } } @@ -127,11 +132,9 @@ contract YearnBorgAcceptanceTest is Test { vm.assertFalse(ychadSafe.isOwner(testSigner), "Should not be Safe signer"); vm.assertEq(ychadSafe.getThreshold(), thresholdBefore, "Threshold should not change"); - - // TODO Test with reduce = true } - /// @dev Normal Member Management workflow should succeed + /// @dev Member Management should succeed given DAO and ychad.eth's co-approval function testMemberManagement() public { vm.assertFalse(ychadSafe.isOwner(alice), "Should not be Safe signer"); @@ -151,21 +154,48 @@ contract YearnBorgAcceptanceTest is Test { proposalId ); - // Should fail within waiting period - safeTxHelper.executeSingle( - GnosisTransaction({ - to: address(snapShotExecutor), - value: 0, - data: executeCalldata - }), - "GS013" // expectRevertData + // After waiting period + skip(snapShotExecutor.waitingPeriod()); + + // Should fail if not executed from Safe + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, snapShotExecutor.AUTH().OWNER_ROLE(), address(this))); + snapShotExecutor.execute(proposalId); + + // Should succeed if executed from Safe + safeTxHelper.executeSingle(GnosisTransaction({ + to: address(snapShotExecutor), + value: 0, + data: executeCalldata + })); + + vm.assertTrue(ychadSafe.isOwner(alice), "Should be Safe signer"); + } + + /// @dev Guard Management should succeed given DAO and ychad.eth's co-approval + function testGuardManagement() public { + vm.assertEq(safeTxHelper.getGuard(address(ychadSafe)), address(core), "BORG core should be Guard of ychad.eth"); + + vm.prank(oracle); + bytes32 proposalId = snapShotExecutor.propose( + address(sudo), // target + 0, // value + abi.encodeWithSelector( + sudoImplant.setGuard.selector, + address(0) // newGuard + ), // cdata + "Remove Guard" + ); + + bytes memory executeCalldata = abi.encodeWithSelector( + snapShotExecutor.execute.selector, + proposalId ); // After waiting period skip(snapShotExecutor.waitingPeriod()); // Should fail if not executed from Safe - vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, core.AUTH().OWNER_ROLE(), address(this))); + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, snapShotExecutor.AUTH().OWNER_ROLE(), address(this))); snapShotExecutor.execute(proposalId); // Should succeed if executed from Safe @@ -175,7 +205,44 @@ contract YearnBorgAcceptanceTest is Test { data: executeCalldata })); - vm.assertTrue(ychadSafe.isOwner(alice), "Should be Safe signer"); + vm.assertEq(safeTxHelper.getGuard(address(ychadSafe)), address(0), "ychad.eth should have no Guard"); + } + + /// @dev Module Management should succeed given DAO and ychad.eth's co-approval + function testModuleManagement() public { + vm.assertTrue(ychadSafe.isModuleEnabled(address(eject)), "ejectImplant should be enabled"); + + vm.prank(oracle); + bytes32 proposalId = snapShotExecutor.propose( + address(sudo), // target + 0, // value + abi.encodeWithSelector( + sudoImplant.disableModule.selector, + address(eject) // module + ), // cdata + "Disable Eject Implant" + ); + + bytes memory executeCalldata = abi.encodeWithSelector( + snapShotExecutor.execute.selector, + proposalId + ); + + // After waiting period + skip(snapShotExecutor.waitingPeriod()); + + // Should fail if not executed from Safe + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, snapShotExecutor.AUTH().OWNER_ROLE(), address(this))); + snapShotExecutor.execute(proposalId); + + // Should succeed if executed from Safe + safeTxHelper.executeSingle(GnosisTransaction({ + to: address(snapShotExecutor), + value: 0, + data: executeCalldata + })); + + vm.assertFalse(ychadSafe.isModuleEnabled(address(eject)), "ejectImplant should be disabled"); } /// @dev Non-oracle should not be able to propose From 59b2214bdc0ec16147b6dbce84bad8a864c18d56 Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 17 Apr 2025 14:29:21 -0700 Subject: [PATCH 20/52] chore: Update comments --- .gitignore | 1 + README-yearnBorg.md | 28 +++++++++++++++--------- src/libs/governance/snapShotExecutor.sol | 2 +- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index d1b5248..512e4b6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ test-command.txt broadcast/ .DS_Store +.idea/ /broadcast_bk diff --git a/README-yearnBorg.md b/README-yearnBorg.md index 4fa0d79..c662f13 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -1,6 +1,6 @@ # Yearn BORG -## BORG Architectures (TBD) +## BORG Architectures ```mermaid graph TD @@ -14,14 +14,14 @@ graph TD subgraph implants["Implants (Modules)"] ejectImplant{{Eject Implant}} - sudoImplant{{"(WIP) Sudo Implant"}} + sudoImplant{{Sudo Implant}} end snapshotExecutor[SnapshotExecutor] borg -->|"guard"| ychad - ychad -->|"owner
execute(proposal)"| snapshotExecutor + ychad -->|"owner
execute(proposalId)"| snapshotExecutor %% implants -->|modules| ychad @@ -50,7 +50,7 @@ graph TD class yearnDaoVoting yearn; ``` -## Restricted Admin Workflows (TBD) +## Restricted Admin Workflows `ychad.eth` will be prohibited from unilaterally performing the following admin operations: @@ -63,20 +63,22 @@ all coming operations as listed above will require approval of both `ychad.eth` 1. Operation is initiated on the MetaLeX OS webapp 2. A Snapshot proposal will be submitted via API using Yearn's existing voting settings -3. MetaLeX's Snapshot oracle (`oracle`) will submit the results onchain to an executor contract (`SnapShotExecutor`), which will have the proposed transaction pending for co-approval -4. `ychad.eth` will co-approve it by executing the operation through the MetaLeX OS webapp +3. MetaLeX's Snapshot oracle (`oracle`) will submit the results on-chain to an executor contract (`SnapShotExecutor`), which will have the proposed transaction pending for co-approval +4. After waiting period, `ychad.eth` can co-approve it by executing the operation through the MetaLeX OS webapp +5. After an extra waiting period, anyone can cancel the proposal if it hasn't been executed ### Future On-chain Governance Transition -The veYFI Snapshot governance will be replaced with on-chain governance at some point. Let's call the contract `YearnGovExecutor`. +The veYFI Snapshot governance will be replaced with on-chain governance at some point (ex. `YearnGovExecutor`). To integrate with the co-approval process, `YearnGovExecutor` must satisfy: - Each proposal should have generic transaction fields (`target`, `value`, `calldata`) or equivalents so that `YearnGovExecutor` knows how to execute after the proposal is passed -- Proposals related to the BORG [Restricted Admin Workflows](#restricted-admin-workflows-tbd) should be exclusively executed by `ychad.eth` because that's how the co-approval process is enforced +- Proposals related to the BORG [Restricted Admin Workflows](#restricted-admin-workflows) should be exclusively executed by `ychad.eth` so it enforces the co-approval requirements -The transition process is as follows: +The transition process from Snapshot to on-chain governance is listed as follows: 1. A final Snapshot proposal will be submitted to replace `SnapShotExecutor` with `YearnGovExecutor`. More specifically, it is done by transferring `SudoImplant`'s and `EjectImplant`'s owner to `YearnGovExecutor` +2. Once co-approved and executed by `ychad.eth`, the transition process is complete After the transition, the co-approval process will become: @@ -115,12 +117,18 @@ After the transition, the co-approval process will become: to: 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52 value: 0 data: - 0x610b5925000000000000000000000000777b947b1821c34ee94d7d09c82e56f8008a0e08 + 0x610b59250000000000000000000000006faa027c062868424287af2faef3ddaca802bff7 # 1 to: 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52 value: 0 data: + 0x610b5925000000000000000000000000a21f6d7aa0b320b8669caef53f790b1a2ac838d7 + + # 2 + to: 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52 + value: 0 + data: 0xe19a9dd9000000000000000000000000bc19387f5b8ae73fad41cd2294f928a735c60534 ``` 4. Ask `ychad.eth` to sign and execute the Safe TXs diff --git a/src/libs/governance/snapShotExecutor.sol b/src/libs/governance/snapShotExecutor.sol index a5087bb..b199a4e 100644 --- a/src/libs/governance/snapShotExecutor.sol +++ b/src/libs/governance/snapShotExecutor.sol @@ -6,7 +6,7 @@ import "openzeppelin/contracts/utils/Address.sol"; contract SnapShotExecutor is BorgAuthACL { - address public oracle; // TODO Need to be transferrable for future on-chain governance upgrades + address public oracle; uint256 public waitingPeriod; uint256 public cancelPeriod; uint256 public pendingProposalCount; From ee6ecaa6f0dfcd72fec5a0083b18d422897a55ad Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 17 Apr 2025 22:29:36 -0700 Subject: [PATCH 21/52] test: Add sudoImplant tests --- test/sudoImplant.t.sol | 222 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 test/sudoImplant.t.sol diff --git a/test/sudoImplant.t.sol b/test/sudoImplant.t.sol new file mode 100644 index 0000000..7140799 --- /dev/null +++ b/test/sudoImplant.t.sol @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; +import {borgCore} from "../src/borgCore.sol"; +import {sudoImplant} from "../src/implants/sudoImplant.sol"; +import {BorgAuth} from "../src/libs/auth.sol"; +import {ConditionManager} from "../src/libs/conditions/conditionManager.sol"; +import {SignatureCondition} from "../src/libs/conditions/signatureCondition.sol"; +import {SafeTxHelper} from "./libraries/safeTxHelper.sol"; +import {IGnosisSafe, GnosisTransaction, IMultiSendCallOnly} from "./libraries/safe.t.sol"; + +contract SudoImplantTest is Test { + + uint256 testSignerPrivateKey = 1; + address testSigner = vm.addr(testSignerPrivateKey); + address owner = vm.addr(2); + address globalConditionSigner = vm.addr(3); + address funcConditionSigner = vm.addr(4); + + IGnosisSafe safe = IGnosisSafe(0xee1927e3Dbba7f261806e3B39FDE9aFacaA8cde7); // Sepolia testnet @ 6124182 + + // Safe 1.3.0 Multi Send Call Only @ Sepolia + // https://github.com/safe-global/safe-deployments?tab=readme-ov-file + IMultiSendCallOnly multiSendCallOnly = IMultiSendCallOnly(0x40A2aCCbd92BCA938b02010E17A5b8929b49130D); + + SafeTxHelper safeTxHelper = new SafeTxHelper(safe, multiSendCallOnly, testSignerPrivateKey); + + BorgAuth auth; + borgCore core; + sudoImplant sudo; + SignatureCondition globalCondition; + SignatureCondition funcCondition; + + event GuardChanged(address indexed newGuard); + event ModuleEnabled(address indexed module); + event ModuleDisabled(address indexed module); + + function setUp() public virtual { + // Simulate changing Safe threshold and adding the test owner so we can run tests + vm.prank(address(safe)); + safe.addOwnerWithThreshold(testSigner, 1); + + auth = new BorgAuth(); + core = new borgCore(auth, 0x3, borgCore.borgModes.unrestricted, "Test BORG", address(safe)); + sudo = new sudoImplant(auth, address(safe)); + + { + address[] memory signers = new address[](1); + signers[0] = address(globalConditionSigner); + globalCondition = new SignatureCondition(signers, 1, SignatureCondition.Logic.AND); + + } + { + address[] memory signers = new address[](1); + signers[0] = address(funcConditionSigner); + funcCondition = new SignatureCondition(signers, 1, SignatureCondition.Logic.AND); + } + + sudo.addCondition(ConditionManager.Logic.AND, address(globalCondition)); + sudo.addConditionToFunction( + ConditionManager.Logic.AND, + address(funcCondition), + sudoImplant.setGuard.selector + ); + sudo.addConditionToFunction( + ConditionManager.Logic.AND, + address(funcCondition), + sudoImplant.enableModule.selector + ); + sudo.addConditionToFunction( + ConditionManager.Logic.AND, + address(funcCondition), + sudoImplant.disableModule.selector + ); + + // Transferring auth ownership + auth.updateRole(owner, auth.OWNER_ROLE()); + auth.zeroOwner(); + + // Add module + safeTxHelper.executeSingle(safeTxHelper.getAddModuleData(address(sudo))); + safeTxHelper.executeSingle(safeTxHelper.getSetGuardData(address(core))); + } + + /// @dev Metadata should meet specs + function testMeta() public view { + assertEq(sudo.IMPLANT_ID(), 7, "Unexpected IMPLANT_ID"); + } + + /// @dev Normal set Guard should succeed + function testSetGuard() public { + assertEq(safeTxHelper.getGuard(address(safe)), address(core), "Safe should have Guard set"); + + // Non-owner should not be authorized + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, auth.OWNER_ROLE(), address(this))); + sudo.setGuard(address(0)); + + // Function condition not met + vm.expectRevert(abi.encodeWithSelector(ConditionManager.ConditionManager_ConditionNotMet.selector)); + vm.prank(owner); + sudo.setGuard(address(0)); + + // Function condition is met + vm.prank(funcConditionSigner); + funcCondition.sign(); + + // Global condition not met + vm.expectRevert(abi.encodeWithSelector(sudoImplant.sudoImplant_ConditionsNotMet.selector)); + vm.prank(owner); + sudo.setGuard(address(0)); + + // Global condition is met + vm.prank(globalConditionSigner); + globalCondition.sign(); + + // Otherwise it should succeed + vm.expectEmit(); + emit GuardChanged(address(0)); + vm.prank(owner); + sudo.setGuard(address(0)); + + assertEq(safeTxHelper.getGuard(address(safe)), address(0), "Safe should have no Guard set"); + } + + /// @dev Normal enable Module should succeed + function testEnableModule() public { + assertFalse(safe.isModuleEnabled(address(2)), "Module should not be enabled"); + + // Non-owner should not be authorized + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, auth.OWNER_ROLE(), address(this))); + sudo.enableModule(address(2)); + + // Function condition not met + vm.expectRevert(abi.encodeWithSelector(ConditionManager.ConditionManager_ConditionNotMet.selector)); + vm.prank(owner); + sudo.enableModule(address(2)); + + // Function condition is met + vm.prank(funcConditionSigner); + funcCondition.sign(); + + // Global condition not met + vm.expectRevert(abi.encodeWithSelector(sudoImplant.sudoImplant_ConditionsNotMet.selector)); + vm.prank(owner); + sudo.enableModule(address(2)); + + // Global condition is met + vm.prank(globalConditionSigner); + globalCondition.sign(); + + // Otherwise it should succeed + vm.expectEmit(); + emit ModuleEnabled(address(2)); + vm.prank(owner); + sudo.enableModule(address(2)); + + assertTrue(safe.isModuleEnabled(address(2)), "Module should be enabled"); + } + + /// @dev Normal disable Module should succeed + function testDisableModule() public { + assertTrue(safe.isModuleEnabled(address(sudo)), "Module should be enabled"); + + // Non-owner should not be authorized + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, auth.OWNER_ROLE(), address(this))); + sudo.disableModule(address(sudo)); + + // Function condition not met + vm.expectRevert(abi.encodeWithSelector(ConditionManager.ConditionManager_ConditionNotMet.selector)); + vm.prank(owner); + sudo.disableModule(address(sudo)); + + // Function condition is met + vm.prank(funcConditionSigner); + funcCondition.sign(); + + // Global condition not met + vm.expectRevert(abi.encodeWithSelector(sudoImplant.sudoImplant_ConditionsNotMet.selector)); + vm.prank(owner); + sudo.disableModule(address(sudo)); + + // Global condition is met + vm.prank(globalConditionSigner); + globalCondition.sign(); + + // Otherwise it should succeed + vm.expectEmit(); + emit ModuleDisabled(address(sudo)); + vm.prank(owner); + sudo.disableModule(address(sudo)); + + assertFalse(safe.isModuleEnabled(address(sudo)), "Module should be disabled"); + } + + /// @dev Should revert if module not found when disabling modules + function test_RevertIf_ModuleNotFound() public { + // Function condition is met + vm.prank(funcConditionSigner); + funcCondition.sign(); + + // Global condition is met + vm.prank(globalConditionSigner); + globalCondition.sign(); + + // Should revert if module not enabled + vm.expectRevert(abi.encodeWithSelector(sudoImplant.sudoImplant_ModuleNotFound.selector)); + vm.prank(owner); + sudo.disableModule(address(2)); + + // Should revert if invalid modules + + vm.expectRevert(abi.encodeWithSelector(sudoImplant.sudoImplant_ModuleNotFound.selector)); + vm.prank(owner); + sudo.disableModule(address(1)); + + vm.expectRevert(abi.encodeWithSelector(sudoImplant.sudoImplant_ModuleNotFound.selector)); + vm.prank(owner); + sudo.disableModule(address(0)); + } +} From 23a527bda4435ef0b9485c001a2ac555d0474d68 Mon Sep 17 00:00:00 2001 From: detoo Date: Fri, 18 Apr 2025 10:22:01 -0700 Subject: [PATCH 22/52] chore: Add more comments and README --- README-yearnBorg.md | 49 +++++++++++++++++++++++++++++++---------- scripts/yearnBorg.s.sol | 2 +- src/borgCore.sol | 8 +++++++ 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index c662f13..902cc7e 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -2,6 +2,14 @@ ## BORG Architectures +| Entity | Descriptions | +|-------------------|------------------------------------------------------------------------------------------------------------------------------| +| BORG Core | A Safe Guard contract restricting `ychad.eth`'s administrative authority | +| Eject Implant | A Safe Module contract for `ychad.eth` member management, integrated with Snapshot Executor to enforce DAO co-approval | +| Sudo Implant | A Safe Module contract for `ychad.eth` Guard/Module management, integrated with Snapshot Executor to enforce DAO co-approval | +| Snapshot Executor | A smart contract enabling co-approval between a DAO and `ychad.eth` | +| oracle | A MetaLex service for coordinating Yearn Snapshot voting and recording results on-chain | + ```mermaid graph TD ychad[ychad.eth
6/9 signers] @@ -17,7 +25,7 @@ graph TD sudoImplant{{Sudo Implant}} end - snapshotExecutor[SnapshotExecutor] + snapshotExecutor[Snapshot Executor] borg -->|"guard"| ychad @@ -50,7 +58,7 @@ graph TD class yearnDaoVoting yearn; ``` -## Restricted Admin Workflows +## Restricted Admin Operations `ychad.eth` will be prohibited from unilaterally performing the following admin operations: @@ -58,33 +66,50 @@ graph TD - Add / disable Modules - Set Guards +### Co-approval Workflows + Except existing signers, Modules (BORG Implants), Guard (BORG Core) and its set rules, -all coming operations as listed above will require approval of both `ychad.eth` and DAO, with process as such: +all coming operations listed above will require approval of both `ychad.eth` and DAO, with a process as such: 1. Operation is initiated on the MetaLeX OS webapp 2. A Snapshot proposal will be submitted via API using Yearn's existing voting settings -3. MetaLeX's Snapshot oracle (`oracle`) will submit the results on-chain to an executor contract (`SnapShotExecutor`), which will have the proposed transaction pending for co-approval -4. After waiting period, `ychad.eth` can co-approve it by executing the operation through the MetaLeX OS webapp +3. MetaLeX's Snapshot oracle (`oracle`) will submit the results on-chain to an executor contract (`Snapshot Executor`), which will have the proposed transaction pending for co-approval +4. After a waiting period, `ychad.eth` can co-approve it by executing the operation through the MetaLeX OS webapp 5. After an extra waiting period, anyone can cancel the proposal if it hasn't been executed ### Future On-chain Governance Transition -The veYFI Snapshot governance will be replaced with on-chain governance at some point (ex. `YearnGovExecutor`). -To integrate with the co-approval process, `YearnGovExecutor` must satisfy: -- Each proposal should have generic transaction fields (`target`, `value`, `calldata`) or equivalents so that `YearnGovExecutor` knows how to execute after the proposal is passed -- Proposals related to the BORG [Restricted Admin Workflows](#restricted-admin-workflows) should be exclusively executed by `ychad.eth` so it enforces the co-approval requirements +Yearn's Snapshot governance will be replaced with an on-chain governance at some point (ex. `YearnGovExecutor`). +`YearnGovExecutor` (or its adapter) must satisfy the following requirements to integrate with the co-approval process: +- Each proposal must include generic transaction fields (`target`, `value`, `calldata` or their equivalents) to enable `YearnGovExecutor` to execute the proposal upon approval +- Proposals involving `ychad.eth` [Restricted Admin Operations](#restricted-admin-operations) must be executed solely by `ychad.eth` to enforce co-approval requirements The transition process from Snapshot to on-chain governance is listed as follows: -1. A final Snapshot proposal will be submitted to replace `SnapShotExecutor` with `YearnGovExecutor`. - More specifically, it is done by transferring `SudoImplant`'s and `EjectImplant`'s owner to `YearnGovExecutor` +1. A final Snapshot proposal will be submitted to replace `Snapshot Executor` with `YearnGovExecutor` by transferring ownership of `SudoImplant` and `EjectImplant` to `YearnGovExecutor` 2. Once co-approved and executed by `ychad.eth`, the transition process is complete After the transition, the co-approval process will become: 1. Operation is initiated on the MetaLeX OS webapp 2. An on-chain proposal will be submitted to `YearnGovExecutor` -3. Once the vote passed, `ychad.eth` will co-approve it by executing the operation through the MetaLeX OS webapp +3. Once the vote passed, `ychad.eth` will co-approve it by executing the operation through the MetaLeX OS webapp + +### Module Addition + +New Modules grant `ychad.eth` privileges to bypass Guards restrictions, therefore it requires DAO co-approval via [Co-approval Workflows](#co-approval-workflows). + +### Guard & Module Removal + +In exceptional circumstances, `ychad.eth` can propose the removal of the Guard via [Co-approval Workflows](#co-approval-workflows). +Upon DAO co-approval and execution, `ychad.eth` will no longer face any restriction on administrative operations. + +**⚠️ Warning**: Disabling a Module revokes `ychad.eth`'s priviledges. In particular, disabling `SudoImplant` will permanently eliminate `ychad.eth`'s ability to add new Modules or remove Guards. + +## Member Self-resignation + +A `ychad.eth` member can unilaterally resign by calling `EjectImplant.selfEject(false)` without approval. The Safe contract ensures threshold validity. +Alternatively, the member can call `EjectImplant.selfEject(true)` to resign and simultaneously reduce the threshold by 1 ## Key Parameters diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index c0075c0..8733f83 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -37,7 +37,7 @@ contract YearnBorgDeployScript is Script { IGnosisSafe ychadSafe = IGnosisSafe(0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52); // ychad.eth string borgIdentifier = "Yearn BORG"; // TODO WIP Ask for confirmation borgCore.borgModes borgMode = borgCore.borgModes.blacklist; - uint256 borgType = 0x3; // TODO WIP Ask for confirmation + uint256 borgType = 0x3; // devBORG // Configs: SnapShowExecutor diff --git a/src/borgCore.sol b/src/borgCore.sol index 914ba05..e0f6f5f 100644 --- a/src/borgCore.sol +++ b/src/borgCore.sol @@ -83,7 +83,15 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { string private _daoUri; // URI for the DAO LegalAgreement[] public legalAgreements; // array of legal agreements URIs for this BORG string public constant VERSION = "1.0.0"; // contract version + + // 0x1 securityBORG/eBORG + // 0x2 grantsBORG + // 0x3 devBORG + // 0x4 finBORG + // 0x5 genBORG + // 0x6 bzBORG uint256 public immutable borgType; // type of the BORG + enum borgModes { whitelist, // everything is restricted except what has been whitelisted blacklist, // everything is allowed except contracts and methods that have been blacklisted. Param checks work the same as whitelist From 898c25d206ce22b38019f3dd3f7574f8b4be4e20 Mon Sep 17 00:00:00 2001 From: detoo Date: Fri, 18 Apr 2025 13:55:06 -0700 Subject: [PATCH 23/52] test: on-chain governance transition --- test/yearnBorgAcceptance.t.sol | 161 +++++++++++++++++++++++++++++---- 1 file changed, 143 insertions(+), 18 deletions(-) diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 1d8dd79..5f27f5b 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.20; import "forge-std/Test.sol"; import "solady/tokens/ERC20.sol"; +import {Ownable} from "openzeppelin/contracts/access/Ownable.sol"; import {borgCore} from "../src/borgCore.sol"; import {ejectImplant} from "../src/implants/ejectImplant.sol"; import {sudoImplant} from "../src/implants/sudoImplant.sol"; @@ -11,6 +12,33 @@ import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; import {SafeTxHelper} from "./libraries/safeTxHelper.sol"; import {IGnosisSafe, GnosisTransaction, IMultiSendCallOnly} from "../test/libraries/safe.t.sol"; +contract YearnGovExecutor is Ownable { + struct proposal { + address target; + uint256 value; + bytes cdata; + string description; + } + + mapping(bytes32 => proposal) public pendingProposals; + + constructor(address owner) Ownable(owner) {} + + // Propose for voting + function propose(address target, uint256 value, bytes calldata cdata, string memory description) external returns (bytes32) { + bytes32 proposalId = keccak256(abi.encodePacked(target, value, cdata, description)); + pendingProposals[proposalId] = proposal(target, value, cdata, description); + return proposalId; + } + + // Execute passed proposal (for testing we assume it always passes) + function execute(bytes32 proposalId) payable external onlyOwner() { + proposal memory p = pendingProposals[proposalId]; + (bool success, ) = p.target.call{value: p.value}(p.cdata); + delete pendingProposals[proposalId]; + } +} + contract YearnBorgAcceptanceTest is Test { ERC20 weth = ERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); // Ethereum mainnet @@ -149,11 +177,6 @@ contract YearnBorgAcceptanceTest is Test { "Add Alice as new signer" ); - bytes memory executeCalldata = abi.encodeWithSelector( - snapShotExecutor.execute.selector, - proposalId - ); - // After waiting period skip(snapShotExecutor.waitingPeriod()); @@ -165,7 +188,10 @@ contract YearnBorgAcceptanceTest is Test { safeTxHelper.executeSingle(GnosisTransaction({ to: address(snapShotExecutor), value: 0, - data: executeCalldata + data: abi.encodeWithSelector( + snapShotExecutor.execute.selector, + proposalId + ) })); vm.assertTrue(ychadSafe.isOwner(alice), "Should be Safe signer"); @@ -186,11 +212,6 @@ contract YearnBorgAcceptanceTest is Test { "Remove Guard" ); - bytes memory executeCalldata = abi.encodeWithSelector( - snapShotExecutor.execute.selector, - proposalId - ); - // After waiting period skip(snapShotExecutor.waitingPeriod()); @@ -202,7 +223,10 @@ contract YearnBorgAcceptanceTest is Test { safeTxHelper.executeSingle(GnosisTransaction({ to: address(snapShotExecutor), value: 0, - data: executeCalldata + data: abi.encodeWithSelector( + snapShotExecutor.execute.selector, + proposalId + ) })); vm.assertEq(safeTxHelper.getGuard(address(ychadSafe)), address(0), "ychad.eth should have no Guard"); @@ -223,11 +247,6 @@ contract YearnBorgAcceptanceTest is Test { "Disable Eject Implant" ); - bytes memory executeCalldata = abi.encodeWithSelector( - snapShotExecutor.execute.selector, - proposalId - ); - // After waiting period skip(snapShotExecutor.waitingPeriod()); @@ -239,12 +258,118 @@ contract YearnBorgAcceptanceTest is Test { safeTxHelper.executeSingle(GnosisTransaction({ to: address(snapShotExecutor), value: 0, - data: executeCalldata + data: abi.encodeWithSelector( + snapShotExecutor.execute.selector, + proposalId + ) })); vm.assertFalse(ychadSafe.isModuleEnabled(address(eject)), "ejectImplant should be disabled"); } + /// @dev Transition to on-chain governance should be successful with co-approval + function testOnChainGovernanceTransition() public { + YearnGovExecutor yearnGovExecutor = new YearnGovExecutor(address(ychadSafe)); + + BorgAuth implantAuth = eject.AUTH(); + uint256 ownerRole = implantAuth.OWNER_ROLE(); + + // Should not be owner yet + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(yearnGovExecutor))); + implantAuth.onlyRole(ownerRole, address(yearnGovExecutor)); + + // Simulate on-chain governance transition + { + // Need two proposals for complete owner transfer + + vm.prank(oracle); + bytes32 proposalId0 = snapShotExecutor.propose( + address(implantAuth), // target + 0, // value + abi.encodeWithSelector( + implantAuth.updateRole.selector, + address(yearnGovExecutor), + ownerRole + ), // cdata + "Add yearnGovExecutor as owner" + ); + + vm.prank(oracle); + bytes32 proposalId1 = snapShotExecutor.propose( + address(implantAuth), // target + 0, // value + abi.encodeWithSelector( + implantAuth.zeroOwner.selector, + address(yearnGovExecutor), + ownerRole + ), // cdata + "Remove SnapShotExecutor from owner" + ); + + // After waiting period + skip(snapShotExecutor.waitingPeriod()); + + // Should succeed if executed from Safe + + GnosisTransaction[] memory safeTxs = new GnosisTransaction[](2); + safeTxs[0] = GnosisTransaction({ // Add yearnGovExecutor as owner + to: address(snapShotExecutor), + value: 0, + data: abi.encodeWithSelector( + snapShotExecutor.execute.selector, + proposalId0 + ) + }); + safeTxs[1] = GnosisTransaction({ // Remove SnapShotExecutor as owner + to: address(snapShotExecutor), + value: 0, + data: abi.encodeWithSelector( + snapShotExecutor.execute.selector, + proposalId1 + ) + }); + safeTxHelper.executeBatch(safeTxs); + + // YearnGovExecutor should be the owner now + implantAuth.onlyRole(ownerRole, address(yearnGovExecutor)); + // SnapShotExecutor should no longer be an owner + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(snapShotExecutor))); + implantAuth.onlyRole(ownerRole, address(snapShotExecutor)); + } + + // Simulate adding member through on-chain governance + { + vm.assertFalse(ychadSafe.isOwner(alice), "Should not be Safe signer"); + + // Simulate a proposal (and it is immediately passed) + bytes32 proposalId = yearnGovExecutor.propose( + address(eject), // target + 0, // value + abi.encodeWithSelector( + bytes4(keccak256("addOwner(address)")), + alice // newOwner + ), // cdata + "Add Alice as new signer" + ); + + // Should fail if not executed from ychad.eth + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + yearnGovExecutor.execute(proposalId); + + // Should succeed if executed from ychad.eth + safeTxHelper.executeSingle(GnosisTransaction({ + to: address(yearnGovExecutor), + value: 0, + data: abi.encodeWithSelector( + yearnGovExecutor.execute.selector, + proposalId + ) + })); + + vm.assertTrue(ychadSafe.isOwner(alice), "Should be Safe signer"); + } + } + /// @dev Non-oracle should not be able to propose function test_RevertIf_NotOracle() public { vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); From 41925221ab2bf507467c1e455fecf50f0967a103 Mon Sep 17 00:00:00 2001 From: _g4brielShapir0 Date: Sat, 19 Apr 2025 09:11:46 -0500 Subject: [PATCH 24/52] Update README-yearnBorg.md suggesting some clarifications and adding some questions/discussion points in brackets --- README-yearnBorg.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index 902cc7e..bed5e81 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -58,9 +58,18 @@ graph TD class yearnDaoVoting yearn; ``` +## Initial BORGing of ychad + +To implement the BORG, ychad unilaterally: +- determines initial signer set (i.e., keep existing signers) +- approves/adopts legal agreements (Cayman Foundation) +- installs SAFE modules (BORG implants) and guard (BORG core) + +If desired, can seek prior DAO social approval for these changes (and this is likely best for legitimacy), but no DAO onchain actions or legal actions are required. + ## Restricted Admin Operations -`ychad.eth` will be prohibited from unilaterally performing the following admin operations: +Once ychad is "BORGed", the following actions will require bilateral approval of the DAO and ychad. Onchain, this means 'blacklisting' certain unilateral SAFE operations that would otherwise be possible, instead requiring DAO/ychad co-approval of such actions: - Add / remove / swap signers / change threshold - Add / disable Modules @@ -68,15 +77,16 @@ graph TD ### Co-approval Workflows -Except existing signers, Modules (BORG Implants), Guard (BORG Core) and its set rules, -all coming operations listed above will require approval of both `ychad.eth` and DAO, with a process as such: +The process for bilateral `ychad.eth` / DAO approvals will be as follows: -1. Operation is initiated on the MetaLeX OS webapp +1. Operation is initiated on the MetaLeX OS webapp [can snapshot's UI also be used as a fallback option?] 2. A Snapshot proposal will be submitted via API using Yearn's existing voting settings -3. MetaLeX's Snapshot oracle (`oracle`) will submit the results on-chain to an executor contract (`Snapshot Executor`), which will have the proposed transaction pending for co-approval +3. MetaLeX's Snapshot oracle (`oracle`) will submit the results on-chain to an executor contract (`Snapshot Executor`), which will have the proposed transaction pending for co-approval [let's discuss fallback options if our oracle were to go offline, let's discuss security measures around oracle] 4. After a waiting period, `ychad.eth` can co-approve it by executing the operation through the MetaLeX OS webapp 5. After an extra waiting period, anyone can cancel the proposal if it hasn't been executed +This essentially means that ychad cannot 'breach' its basic 'agreement' with the DAO by changing the meta-governance rules (ychad signer membership, ychad approval threshold). It also adds an extra security layer as ychad members cannot collude to change these fundamental rules. All other operations would remain under ychad's sole discretion. + ### Future On-chain Governance Transition Yearn's Snapshot governance will be replaced with an on-chain governance at some point (ex. `YearnGovExecutor`). @@ -91,9 +101,9 @@ The transition process from Snapshot to on-chain governance is listed as follows After the transition, the co-approval process will become: -1. Operation is initiated on the MetaLeX OS webapp +1. Operation is initiated on the MetaLeX OS webapp [discuss fallback options] 2. An on-chain proposal will be submitted to `YearnGovExecutor` -3. Once the vote passed, `ychad.eth` will co-approve it by executing the operation through the MetaLeX OS webapp +3. Once the vote passed, `ychad.eth` will co-approve it by executing the operation through the MetaLeX OS webapp [discuss fallback options] ### Module Addition @@ -104,12 +114,12 @@ New Modules grant `ychad.eth` privileges to bypass Guards restrictions, therefor In exceptional circumstances, `ychad.eth` can propose the removal of the Guard via [Co-approval Workflows](#co-approval-workflows). Upon DAO co-approval and execution, `ychad.eth` will no longer face any restriction on administrative operations. -**⚠️ Warning**: Disabling a Module revokes `ychad.eth`'s priviledges. In particular, disabling `SudoImplant` will permanently eliminate `ychad.eth`'s ability to add new Modules or remove Guards. +**⚠️ Warning**: Disabling a Module revokes `ychad.eth`'s privileges. In particular, disabling `SudoImplant` will permanently eliminate `ychad.eth`'s ability to add new Modules or remove Guards. [discuss] ## Member Self-resignation A `ychad.eth` member can unilaterally resign by calling `EjectImplant.selfEject(false)` without approval. The Safe contract ensures threshold validity. -Alternatively, the member can call `EjectImplant.selfEject(true)` to resign and simultaneously reduce the threshold by 1 +Alternatively, the member can call `EjectImplant.selfEject(true)` to resign and simultaneously reduce the threshold by 1 [wouldn't this require DAO co-approval as well since threshold is being changed?] ## Key Parameters From 800b9d9943e308f10baf9036c4b1c7e596e3538e Mon Sep 17 00:00:00 2001 From: detoo Date: Sat, 19 Apr 2025 21:44:20 -0700 Subject: [PATCH 25/52] feat: Block SudoImplant from disabling itself to prevent potential user errors --- src/implants/sudoImplant.sol | 2 ++ test/sudoImplant.t.sol | 22 +++++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/implants/sudoImplant.sol b/src/implants/sudoImplant.sol index d3d1145..3c5cff2 100644 --- a/src/implants/sudoImplant.sol +++ b/src/implants/sudoImplant.sol @@ -20,6 +20,7 @@ contract sudoImplant is BaseImplant { error sudoImplant_ConditionsNotMet(); error sudoImplant_FailedTransaction(); error sudoImplant_ModuleNotFound(); + error sudoImplant_SelfDisablingNotAllowed(); event GuardChanged(address indexed newGuard); event ModuleEnabled(address indexed module); @@ -72,6 +73,7 @@ contract sudoImplant is BaseImplant { /// @notice Disables a module for the Safe. for the Safe (implant owner-only) /// @param module Module to be removed function disableModule(address module) public onlyOwner conditionCheck { + if (module == address(this)) revert sudoImplant_SelfDisablingNotAllowed(); if (!checkConditions("")) revert sudoImplant_ConditionsNotMet(); // Find prevModule on the linked list diff --git a/test/sudoImplant.t.sol b/test/sudoImplant.t.sol index 7140799..7d3b3b1 100644 --- a/test/sudoImplant.t.sol +++ b/test/sudoImplant.t.sol @@ -30,6 +30,7 @@ contract SudoImplantTest is Test { BorgAuth auth; borgCore core; sudoImplant sudo; + address anotherImplant; SignatureCondition globalCondition; SignatureCondition funcCondition; @@ -45,6 +46,7 @@ contract SudoImplantTest is Test { auth = new BorgAuth(); core = new borgCore(auth, 0x3, borgCore.borgModes.unrestricted, "Test BORG", address(safe)); sudo = new sudoImplant(auth, address(safe)); + anotherImplant = address(new sudoImplant(auth, address(safe))); { address[] memory signers = new address[](1); @@ -81,6 +83,7 @@ contract SudoImplantTest is Test { // Add module safeTxHelper.executeSingle(safeTxHelper.getAddModuleData(address(sudo))); + safeTxHelper.executeSingle(safeTxHelper.getAddModuleData(address(anotherImplant))); safeTxHelper.executeSingle(safeTxHelper.getSetGuardData(address(core))); } @@ -161,16 +164,16 @@ contract SudoImplantTest is Test { /// @dev Normal disable Module should succeed function testDisableModule() public { - assertTrue(safe.isModuleEnabled(address(sudo)), "Module should be enabled"); + assertTrue(safe.isModuleEnabled(address(anotherImplant)), "Module should be enabled"); // Non-owner should not be authorized vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, auth.OWNER_ROLE(), address(this))); - sudo.disableModule(address(sudo)); + sudo.disableModule(address(anotherImplant)); // Function condition not met vm.expectRevert(abi.encodeWithSelector(ConditionManager.ConditionManager_ConditionNotMet.selector)); vm.prank(owner); - sudo.disableModule(address(sudo)); + sudo.disableModule(address(anotherImplant)); // Function condition is met vm.prank(funcConditionSigner); @@ -179,19 +182,24 @@ contract SudoImplantTest is Test { // Global condition not met vm.expectRevert(abi.encodeWithSelector(sudoImplant.sudoImplant_ConditionsNotMet.selector)); vm.prank(owner); - sudo.disableModule(address(sudo)); + sudo.disableModule(address(anotherImplant)); // Global condition is met vm.prank(globalConditionSigner); globalCondition.sign(); + // Self-disable is not allowed + vm.expectRevert(abi.encodeWithSelector(sudoImplant.sudoImplant_SelfDisablingNotAllowed.selector)); + vm.prank(owner); + sudo.disableModule(address(sudo)); + // Otherwise it should succeed vm.expectEmit(); - emit ModuleDisabled(address(sudo)); + emit ModuleDisabled(address(anotherImplant)); vm.prank(owner); - sudo.disableModule(address(sudo)); + sudo.disableModule(address(anotherImplant)); - assertFalse(safe.isModuleEnabled(address(sudo)), "Module should be disabled"); + assertFalse(safe.isModuleEnabled(address(anotherImplant)), "Module should be disabled"); } /// @dev Should revert if module not found when disabling modules From 3bee7932ecddaaf26ed54263e3a581a8bb46f931 Mon Sep 17 00:00:00 2001 From: detoo Date: Sat, 19 Apr 2025 23:03:20 -0700 Subject: [PATCH 26/52] test: Revise on-chain governance transition to prevent potential user errors --- test/yearnBorgAcceptance.t.sol | 55 +++++++++++++++++----------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 5f27f5b..3c8b3fc 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -280,10 +280,9 @@ contract YearnBorgAcceptanceTest is Test { // Simulate on-chain governance transition { - // Need two proposals for complete owner transfer - + // SnapShotExecutor to add YearnGovExecutor as owner vm.prank(oracle); - bytes32 proposalId0 = snapShotExecutor.propose( + bytes32 proposalIdAddOwner = snapShotExecutor.propose( address(implantAuth), // target 0, // value abi.encodeWithSelector( @@ -294,44 +293,44 @@ contract YearnBorgAcceptanceTest is Test { "Add yearnGovExecutor as owner" ); - vm.prank(oracle); - bytes32 proposalId1 = snapShotExecutor.propose( - address(implantAuth), // target - 0, // value - abi.encodeWithSelector( - implantAuth.zeroOwner.selector, - address(yearnGovExecutor), - ownerRole - ), // cdata - "Remove SnapShotExecutor from owner" - ); - // After waiting period skip(snapShotExecutor.waitingPeriod()); // Should succeed if executed from Safe - - GnosisTransaction[] memory safeTxs = new GnosisTransaction[](2); - safeTxs[0] = GnosisTransaction({ // Add yearnGovExecutor as owner + safeTxHelper.executeSingle(GnosisTransaction({ to: address(snapShotExecutor), value: 0, data: abi.encodeWithSelector( snapShotExecutor.execute.selector, - proposalId0 + proposalIdAddOwner ) - }); - safeTxs[1] = GnosisTransaction({ // Remove SnapShotExecutor as owner - to: address(snapShotExecutor), + })); + + // YearnGovExecutor should be an owner now + implantAuth.onlyRole(ownerRole, address(yearnGovExecutor)); + + // YearnGovExecutor to remove SnapShotExecutor's ownership + bytes32 proposalIdRemoveOwner = yearnGovExecutor.propose( + address(implantAuth), // target + 0, // value + abi.encodeWithSelector( + implantAuth.updateRole.selector, + address(snapShotExecutor), + 0 + ), // cdata + "Remove snapShotExecutor ownership" + ); + + // Execute the proposal + safeTxHelper.executeSingle(GnosisTransaction({ + to: address(yearnGovExecutor), value: 0, data: abi.encodeWithSelector( - snapShotExecutor.execute.selector, - proposalId1 + yearnGovExecutor.execute.selector, + proposalIdRemoveOwner ) - }); - safeTxHelper.executeBatch(safeTxs); + })); - // YearnGovExecutor should be the owner now - implantAuth.onlyRole(ownerRole, address(yearnGovExecutor)); // SnapShotExecutor should no longer be an owner vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(snapShotExecutor))); implantAuth.onlyRole(ownerRole, address(snapShotExecutor)); From 7b10491d384504eb33f1b8350df5f0989ffc309a Mon Sep 17 00:00:00 2001 From: detoo Date: Sun, 20 Apr 2025 16:13:02 -0700 Subject: [PATCH 27/52] test: Revise on-chain governance architectures to allow more flexible Yearn Governance designs. Revise README --- README-yearnBorg.md | 67 ++++++++++++++++-- test/yearnBorgAcceptance.t.sol | 122 +++++++++++++++++++++------------ 2 files changed, 138 insertions(+), 51 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index 902cc7e..f442abd 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -79,15 +79,20 @@ all coming operations listed above will require approval of both `ychad.eth` and ### Future On-chain Governance Transition -Yearn's Snapshot governance will be replaced with an on-chain governance at some point (ex. `YearnGovExecutor`). -`YearnGovExecutor` (or its adapter) must satisfy the following requirements to integrate with the co-approval process: -- Each proposal must include generic transaction fields (`target`, `value`, `calldata` or their equivalents) to enable `YearnGovExecutor` to execute the proposal upon approval -- Proposals involving `ychad.eth` [Restricted Admin Operations](#restricted-admin-operations) must be executed solely by `ychad.eth` to enforce co-approval requirements +Yearn's Snapshot-based governance will transition to an on-chain governance system (ex. `YearnGovernance`). +An adapter (`YearnGovernanceAdapter`) will be implemented by MetaLex to manage the implementation details on co-approval process. +To integrate successfully, `YearnGovernance` must meet the following requirements: + +- Each proposal has an unique ID (ex. `proposalId`) +- `YearnGovernanceAdapter` can read the proposal's voting result and verify it is passed +- `YearnGovernanceAdapter` can extract the admin operation (ex. `target`, `value`, `calldata` or equivalent) from the proposal The transition process from Snapshot to on-chain governance is listed as follows: -1. A final Snapshot proposal will be submitted to replace `Snapshot Executor` with `YearnGovExecutor` by transferring ownership of `SudoImplant` and `EjectImplant` to `YearnGovExecutor` -2. Once co-approved and executed by `ychad.eth`, the transition process is complete +1. A final Snapshot proposal will be submitted to grant `YearnGovernanceAdapter` ownership of the implants +2. `ychad.eth` to co-approved and executed the proposal +3. The first on-chain proposal will be submitted to revoke `SnapShotExecutor` ownership of the implants +4. `ychad.eth` to co-approved and executed the proposal. The transition is now complete After the transition, the co-approval process will become: @@ -95,6 +100,56 @@ After the transition, the co-approval process will become: 2. An on-chain proposal will be submitted to `YearnGovExecutor` 3. Once the vote passed, `ychad.eth` will co-approve it by executing the operation through the MetaLeX OS webapp +Below shows the changes of BORG architectures before/after on-chain governance transition: + +```mermaid +graph TD + ychad[ychad.eth
6/9 signers] + + subgraph offChainGovernance["Snapshot Governance (before)"] + yearnDaoVoting[Yearn DAO Voting Snapshot] + oracleAddr[oracle] + snapshotExecutor[Snapshot Executor] + end + + subgraph onChainGovernance["On-chain Governance (after)"] + yearnGovernance[Yearn Governance] + yearnGovernanceAdapter[Yearn Governance Adapter] + end + + subgraph implants["Implants (Modules)"] + ejectImplant{{Eject Implant}} + sudoImplant{{Sudo Implant}} + end + + ychad -->|"owner
execute(proposalId)"| snapshotExecutor + + oracleAddr -->|"oracle
propose(admin operation)"| snapshotExecutor + oracleAddr -->|monitor| yearnDaoVoting + + snapshotExecutor -->|"owner
admin operation()"| implants + + ychad -->|"owner
execute(proposalId)"| yearnGovernanceAdapter + + yearnGovernanceAdapter -->|"verify voting results of proposalId
and extract the admin operation"| yearnGovernance + yearnGovernanceAdapter -->|"owner
admin operation()"| implants + + %% Styling (optional, Mermaid supports limited styling) + classDef default fill:#191918,stroke:#fff,stroke-width:2px,color:#fff; + classDef borg fill:#191918,stroke:#E1FE52,stroke-width:2px,color:#E1FE52; + classDef yearn fill:#191918,stroke:#2C68DB,stroke-width:2px,color:#2C68DB; + classDef safe fill:#191918,stroke:#76FB8D,stroke-width:2px,color:#76FB8D; + classDef todo fill:#191918,stroke:#F09B4A,stroke-width:2px,color:#F09B4A; + class ejectImplant borg; + class sudoImplant borg; + class snapshotExecutor borg; + class oracleAddr borg; + class yearnGovernanceAdapter borg; + class ychad yearn; + class yearnDaoVoting yearn; + class yearnGovernance yearn; +``` + ### Module Addition New Modules grant `ychad.eth` privileges to bypass Guards restrictions, therefore it requires DAO co-approval via [Co-approval Workflows](#co-approval-workflows). diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 3c8b3fc..d1be53c 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -3,39 +3,67 @@ pragma solidity 0.8.20; import "forge-std/Test.sol"; import "solady/tokens/ERC20.sol"; -import {Ownable} from "openzeppelin/contracts/access/Ownable.sol"; import {borgCore} from "../src/borgCore.sol"; import {ejectImplant} from "../src/implants/ejectImplant.sol"; import {sudoImplant} from "../src/implants/sudoImplant.sol"; -import {BorgAuth} from "../src/libs/auth.sol"; +import {BorgAuth, BorgAuthACL} from "../src/libs/auth.sol"; import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; import {SafeTxHelper} from "./libraries/safeTxHelper.sol"; import {IGnosisSafe, GnosisTransaction, IMultiSendCallOnly} from "../test/libraries/safe.t.sol"; -contract YearnGovExecutor is Ownable { - struct proposal { +contract MockYearnGovernance { + struct Proposal { address target; uint256 value; bytes cdata; string description; } - mapping(bytes32 => proposal) public pendingProposals; - - constructor(address owner) Ownable(owner) {} + mapping(bytes32 => Proposal) public proposals; + + // Assume this is how to get a proposal's content (including admin operation's data) + function getProposal(bytes32 proposalId) external returns (Proposal memory) { + return proposals[proposalId]; + } + + // Assume this is how to verify a proposal is passed + function isProposalPassed(bytes32 proposalId) external returns (bool) { + return true; + } - // Propose for voting - function propose(address target, uint256 value, bytes calldata cdata, string memory description) external returns (bytes32) { - bytes32 proposalId = keccak256(abi.encodePacked(target, value, cdata, description)); - pendingProposals[proposalId] = proposal(target, value, cdata, description); + // Assume this is how to propose an admin operation + function propose(Proposal calldata p) external returns (bytes32) { + bytes32 proposalId = keccak256(abi.encodePacked(p.target, p.value, p.cdata, p.description)); + proposals[proposalId] = p; return proposalId; } +} + +contract MockYearnGovernanceAdapter is BorgAuthACL { + error YearnGovernanceAdapter_ProposalNotPassed(bytes32 proposalId); + error YearnGovernanceAdapter_ProposalAlreadyExecuted(bytes32 proposalId); + + MockYearnGovernance yearnGovernance; + mapping(bytes32 => bool) public proposalExecuted; - // Execute passed proposal (for testing we assume it always passes) + constructor(BorgAuth _auth, MockYearnGovernance _yearnGovernance) BorgAuthACL(_auth) { + yearnGovernance = _yearnGovernance; + } + + // Only owner (ychad.eth) is allowed to execute the admin operation. This is part of the co-approval process. function execute(bytes32 proposalId) payable external onlyOwner() { - proposal memory p = pendingProposals[proposalId]; + if (!yearnGovernance.isProposalPassed(proposalId)) { + revert YearnGovernanceAdapter_ProposalNotPassed(proposalId); + } + + if (proposalExecuted[proposalId]) { + revert YearnGovernanceAdapter_ProposalAlreadyExecuted(proposalId); + } + + MockYearnGovernance.Proposal memory p = yearnGovernance.getProposal(proposalId); + proposalExecuted[proposalId] = true; + (bool success, ) = p.target.call{value: p.value}(p.cdata); - delete pendingProposals[proposalId]; } } @@ -269,28 +297,32 @@ contract YearnBorgAcceptanceTest is Test { /// @dev Transition to on-chain governance should be successful with co-approval function testOnChainGovernanceTransition() public { - YearnGovExecutor yearnGovExecutor = new YearnGovExecutor(address(ychadSafe)); + // Yearn to deploy on-chain governance contract + MockYearnGovernance yearnGovernance = new MockYearnGovernance(); + + // MetaLeX to deploy adapter + MockYearnGovernanceAdapter yearnGovernanceAdapter = new MockYearnGovernanceAdapter(snapShotExecutor.AUTH(), yearnGovernance); BorgAuth implantAuth = eject.AUTH(); uint256 ownerRole = implantAuth.OWNER_ROLE(); // Should not be owner yet - vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(yearnGovExecutor))); - implantAuth.onlyRole(ownerRole, address(yearnGovExecutor)); + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(yearnGovernanceAdapter))); + implantAuth.onlyRole(ownerRole, address(yearnGovernanceAdapter)); // Simulate on-chain governance transition { - // SnapShotExecutor to add YearnGovExecutor as owner + // SnapShotExecutor to add yearnGovernanceAdapter as owner vm.prank(oracle); bytes32 proposalIdAddOwner = snapShotExecutor.propose( address(implantAuth), // target 0, // value abi.encodeWithSelector( implantAuth.updateRole.selector, - address(yearnGovExecutor), + address(yearnGovernanceAdapter), ownerRole ), // cdata - "Add yearnGovExecutor as owner" + "Add yearnGovernanceAdapter as owner" ); // After waiting period @@ -306,28 +338,28 @@ contract YearnBorgAcceptanceTest is Test { ) })); - // YearnGovExecutor should be an owner now - implantAuth.onlyRole(ownerRole, address(yearnGovExecutor)); + // yearnGovernanceAdapter should be an owner now + implantAuth.onlyRole(ownerRole, address(yearnGovernanceAdapter)); - // YearnGovExecutor to remove SnapShotExecutor's ownership - bytes32 proposalIdRemoveOwner = yearnGovExecutor.propose( - address(implantAuth), // target - 0, // value - abi.encodeWithSelector( + // YearnGovernance to revoke SnapShotExecutor ownership + bytes32 proposalIdRevokeOwner = yearnGovernance.propose(MockYearnGovernance.Proposal({ + target: address(implantAuth), + value: 0, + cdata: abi.encodeWithSelector( implantAuth.updateRole.selector, address(snapShotExecutor), 0 - ), // cdata - "Remove snapShotExecutor ownership" - ); + ), + description: "Revoke snapShotExecutor ownership" + })); - // Execute the proposal + // Execute the passed proposal safeTxHelper.executeSingle(GnosisTransaction({ - to: address(yearnGovExecutor), + to: address(yearnGovernanceAdapter), value: 0, data: abi.encodeWithSelector( - yearnGovExecutor.execute.selector, - proposalIdRemoveOwner + MockYearnGovernanceAdapter.execute.selector, + proposalIdRevokeOwner ) })); @@ -341,26 +373,26 @@ contract YearnBorgAcceptanceTest is Test { vm.assertFalse(ychadSafe.isOwner(alice), "Should not be Safe signer"); // Simulate a proposal (and it is immediately passed) - bytes32 proposalId = yearnGovExecutor.propose( - address(eject), // target - 0, // value - abi.encodeWithSelector( + bytes32 proposalId = yearnGovernance.propose(MockYearnGovernance.Proposal({ + target: address(eject), + value: 0, + cdata: abi.encodeWithSelector( bytes4(keccak256("addOwner(address)")), alice // newOwner - ), // cdata - "Add Alice as new signer" - ); + ), + description: "Add Alice as new signer" + })); // Should fail if not executed from ychad.eth - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); - yearnGovExecutor.execute(proposalId); + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(this))); + yearnGovernanceAdapter.execute(proposalId); // Should succeed if executed from ychad.eth safeTxHelper.executeSingle(GnosisTransaction({ - to: address(yearnGovExecutor), + to: address(yearnGovernanceAdapter), value: 0, data: abi.encodeWithSelector( - yearnGovExecutor.execute.selector, + MockYearnGovernanceAdapter.execute.selector, proposalId ) })); From 5e017857373b37e08972b50d1ff027ab8b152ab5 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 21 Apr 2025 14:05:03 -0700 Subject: [PATCH 28/52] test: Revise tests and descriptions --- scripts/yearnBorg.s.sol | 8 ++++---- test/yearnBorgAcceptance.t.sol | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 8733f83..8077610 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -17,13 +17,13 @@ import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; import {SafeTxHelper} from "../test/libraries/safeTxHelper.sol"; import {IGnosisSafe, GnosisTransaction, IMultiSendCallOnly} from "../test/libraries/safe.t.sol"; -contract MockFailSafeImplant { +contract PlaceholderFailSafeImplant { uint256 public immutable IMPLANT_ID = 0; - error MockFailSafeImplant_UnexpectedTrigger(); + error PlaceholderFailSafeImplant_UnexpectedTrigger(); function recoverSafeFunds() external { - revert MockFailSafeImplant_UnexpectedTrigger(); + revert PlaceholderFailSafeImplant_UnexpectedTrigger(); } } @@ -116,7 +116,7 @@ contract YearnBorgDeployScript is Script { eject = new ejectImplant( implantAuth, address(ychadSafe), - address(new MockFailSafeImplant()), // _failSafe + address(new PlaceholderFailSafeImplant()), // Placeholder because Yearn BORG does not use failSafe true, // _allowManagement true // _allowEjection ); diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index d1be53c..ee3d24c 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -159,6 +159,12 @@ contract YearnBorgAcceptanceTest is Test { assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); } + function testEjectImplantMeta() public { + assertEq(eject.failSafeSignerThreshold(), 0, "Unexpected failSafeSignerThreshold"); + assertTrue(eject.ALLOW_AUTH_MANAGEMENT(), "Auth management should be allowed"); + assertTrue(eject.ALLOW_AUTH_EJECT(), "Auth ejection should be allowed"); + } + /// @dev Safe normal operations should be unrestricted function testSafeOpUnrestricted() public { { From 34d9a196a535dedd4ba37062fd5a4c6e980d42da Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 21 Apr 2025 16:34:14 -0700 Subject: [PATCH 29/52] feat: snapShotExecutor oracle deadman switch and transfer --- scripts/yearnBorg.s.sol | 3 +- src/libs/governance/snapShotExecutor.sol | 33 ++++++++++-- test/snapShotExecutor.t.sol | 68 +++++++++++++++++++++++- test/yearnBorgAcceptance.t.sol | 1 + 4 files changed, 99 insertions(+), 6 deletions(-) diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 8077610..3d09a15 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -44,6 +44,7 @@ contract YearnBorgDeployScript is Script { uint256 snapShotWaitingPeriod = 3 days; // TODO Is it still necessary? uint256 snapShotCancelPeriod = 2 days; uint256 snapShotPendingProposalLimit = 3; + uint256 snapShotTtl = 30 days; address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00; SafeTxHelper safeTxHelper; @@ -108,7 +109,7 @@ contract YearnBorgDeployScript is Script { // Create SnapShotExecutor executorAuth = new BorgAuth(); - snapShotExecutor = new SnapShotExecutor(executorAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelPeriod, snapShotPendingProposalLimit); + snapShotExecutor = new SnapShotExecutor(executorAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelPeriod, snapShotPendingProposalLimit, snapShotTtl); // Add modules diff --git a/src/libs/governance/snapShotExecutor.sol b/src/libs/governance/snapShotExecutor.sol index b199a4e..5a22eb2 100644 --- a/src/libs/governance/snapShotExecutor.sol +++ b/src/libs/governance/snapShotExecutor.sol @@ -5,12 +5,15 @@ import "../auth.sol"; import "openzeppelin/contracts/utils/Address.sol"; contract SnapShotExecutor is BorgAuthACL { + uint256 public immutable ORACLE_TTL; address public oracle; + address public pendingOracle; uint256 public waitingPeriod; uint256 public cancelPeriod; uint256 public pendingProposalCount; uint256 public pendingProposalLimit; + uint256 public lastOraclePingTimestamp; struct proposal { address target; @@ -25,6 +28,7 @@ contract SnapShotExecutor is BorgAuthACL { error SnapShotExecutor_WaitingPeriod(); error SnapShotExeuctor_InvalidParams(); error SnapShotExecutor_TooManyPendingProposals(); + error SnapShotExecutor_OracleNotDead(); //events event ProposalCreated(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); @@ -33,18 +37,36 @@ contract SnapShotExecutor is BorgAuthACL { mapping(bytes32 => proposal) public pendingProposals; + /// @dev Check if `msg.sender` is either the oracle or is pending to be one. If it's the latter, transfer it. Also ping for TTL checks. modifier onlyOracle() { - if (msg.sender != oracle) revert SnapShotExecutor_NotAuthorized(); + if (msg.sender != oracle) { + if (msg.sender == pendingOracle) { + // Pending oracle can accept the transfer + oracle = pendingOracle; + pendingOracle = address(0); + } else { + // Not authorized if neither oracle nor pending oracle + revert SnapShotExecutor_NotAuthorized(); + } + } + lastOraclePingTimestamp = block.timestamp; _; } - constructor(BorgAuth _auth, address _oracle, uint256 _waitingPeriod, uint256 _cancelPeriod, uint256 _pendingProposals) BorgAuthACL(_auth) { + modifier onlyDeadOracle() { + if (block.timestamp < lastOraclePingTimestamp + ORACLE_TTL) revert SnapShotExecutor_OracleNotDead(); + _; + } + + constructor(BorgAuth _auth, address _oracle, uint256 _waitingPeriod, uint256 _cancelPeriod, uint256 _pendingProposals, uint256 _oracleTtl) BorgAuthACL(_auth) { oracle = _oracle; if(_waitingPeriod < 1 minutes) revert SnapShotExeuctor_InvalidParams(); waitingPeriod = _waitingPeriod; if(_cancelPeriod < 1 minutes) revert SnapShotExeuctor_InvalidParams(); cancelPeriod = _cancelPeriod; pendingProposalLimit = _pendingProposals; + ORACLE_TTL = _oracleTtl; + lastOraclePingTimestamp = block.timestamp; } function propose(address target, uint256 value, bytes calldata cdata, string memory description) external onlyOracle() returns (bytes32) { @@ -75,4 +97,9 @@ contract SnapShotExecutor is BorgAuthACL { emit ProposalCanceled(proposalId, p.target, p.value, p.cdata, p.description, p.timestamp); } -} \ No newline at end of file + function transferOracle(address newOracle) external onlyOwner() onlyDeadOracle() { + pendingOracle = newOracle; + } + + function ping() external onlyOracle() {} +} diff --git a/test/snapShotExecutor.t.sol b/test/snapShotExecutor.t.sol index c001f4e..40a22b9 100644 --- a/test/snapShotExecutor.t.sol +++ b/test/snapShotExecutor.t.sol @@ -13,7 +13,8 @@ contract SnapShotExecutorTest is Test { address owner = vm.addr(1); address oracle = vm.addr(2); - address alice = vm.addr(3); + address newOracle = vm.addr(3); + address alice = vm.addr(4); BorgAuth auth; SnapShotExecutor snapShotExecutor; @@ -29,7 +30,8 @@ contract SnapShotExecutorTest is Test { oracle, 3 days, // waitingPeriod 2 days, // cancelPeriod - 3 // pendingProposalLimit + 3, // pendingProposalLimit + 30 days // ttl ); // Transferring auth ownership @@ -40,10 +42,13 @@ contract SnapShotExecutorTest is Test { /// @dev Metadata should meet specs function testMeta() public view { assertEq(snapShotExecutor.oracle(), oracle, "Unexpected oracle address"); + assertEq(snapShotExecutor.pendingOracle(), address(0), "Unexpected pending oracle address"); assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); assertEq(snapShotExecutor.cancelPeriod(), 2 days, "Unexpected cancelPeriod"); assertEq(snapShotExecutor.pendingProposalCount(), 0, "Unexpected pendingProposalCount"); assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); + assertEq(snapShotExecutor.ORACLE_TTL(), 30 days, "Unexpected ORACLE_TTL"); + assertEq(snapShotExecutor.lastOraclePingTimestamp(), block.timestamp, "Unexpected lastOraclePingTimestamp"); } /// @dev BorgAuth instances should be properly assigned and configured @@ -203,4 +208,63 @@ contract SnapShotExecutorTest is Test { vm.stopPrank(); } + + /// @dev Ping timestamp should update when oracle is working + function testPing() public { + uint256 lastOraclePingTimestamp = snapShotExecutor.lastOraclePingTimestamp(); + + // Non-oracle shouldn't be able to ping + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); + snapShotExecutor.ping(); + + // Last timestamp should update after a successful ping + skip(1 days); + vm.prank(oracle); + snapShotExecutor.ping(); + assertEq(snapShotExecutor.lastOraclePingTimestamp(), lastOraclePingTimestamp + 1 days); + + // Propose should also ping + skip(1 days); + vm.prank(oracle); + snapShotExecutor.propose( + address(alice), // target + 0, // value + "", // cdata + "Arbitrary instruction" + ); + assertEq(snapShotExecutor.lastOraclePingTimestamp(), lastOraclePingTimestamp + 2 days); + } + + /// @dev Owner should be able to replace oracle if it's dead + function testTransferOracle() public { + skip(snapShotExecutor.ORACLE_TTL()); + vm.prank(owner); + snapShotExecutor.transferOracle(newOracle); + + // Old oracle should still work when the transfer is pending + vm.prank(oracle); + snapShotExecutor.ping(); + assertEq(snapShotExecutor.oracle(), oracle); + + // Non-oracle should still be unauthorized + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); + snapShotExecutor.ping(); + + // Transfer should be done after the new oracle interacts + vm.prank(newOracle); + snapShotExecutor.ping(); + assertEq(snapShotExecutor.oracle(), newOracle); + assertEq(snapShotExecutor.pendingOracle(), address(0)); + // Old oracle should no longer be authorized + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); + vm.prank(oracle); + snapShotExecutor.ping(); + } + + /// @dev Owner should not be able to replace oracle if it's not dead + function test_RevertIf_SetOracleNotDead() public { + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_OracleNotDead.selector)); + vm.prank(owner); + snapShotExecutor.transferOracle(newOracle); + } } diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index ee3d24c..37d0517 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -157,6 +157,7 @@ contract YearnBorgAcceptanceTest is Test { assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); assertEq(snapShotExecutor.cancelPeriod(), 2 days, "Unexpected cancelPeriod"); assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); + assertEq(snapShotExecutor.ORACLE_TTL(), 30 days, "Unexpected ORACLE_TTL"); } function testEjectImplantMeta() public { From 4157544eef8722fb4f29b04bcd54478f6c7ef486 Mon Sep 17 00:00:00 2001 From: detoo Date: Tue, 22 Apr 2025 16:45:30 -0700 Subject: [PATCH 30/52] feat: ejectImplant to support disallowing self-eject with threshold reduction --- scripts/yearnBorg.s.sol | 3 ++- src/implants/ejectImplant.sol | 5 ++++- test/PBVBorg.t.sol | 2 +- test/blackList.t.sol | 2 +- test/borgCore.t.sol | 2 +- test/ejectImplant.t.sol | 2 +- test/grantBorg.t.sol | 2 +- test/signatureCondition.t.sol | 2 +- test/voteBorg.t.sol | 2 +- test/yearnBorgAcceptance.t.sol | 7 +++++++ 10 files changed, 20 insertions(+), 9 deletions(-) diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 3d09a15..c3d1e81 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -119,7 +119,8 @@ contract YearnBorgDeployScript is Script { address(ychadSafe), address(new PlaceholderFailSafeImplant()), // Placeholder because Yearn BORG does not use failSafe true, // _allowManagement - true // _allowEjection + true, // _allowEjection + false // _allowSelfEjectReduce ); sudo = new sudoImplant( implantAuth, diff --git a/src/implants/ejectImplant.sol b/src/implants/ejectImplant.sol index f3faa10..e69fbc2 100644 --- a/src/implants/ejectImplant.sol +++ b/src/implants/ejectImplant.sol @@ -22,6 +22,7 @@ contract ejectImplant is BaseImplant { address public immutable FAIL_SAFE; bool public immutable ALLOW_AUTH_MANAGEMENT; bool public immutable ALLOW_AUTH_EJECT; + bool public immutable ALLOW_AUTH_SELF_EJECT_REDUCE; uint256 public failSafeSignerThreshold; // Errors and Events @@ -40,12 +41,13 @@ contract ejectImplant is BaseImplant { /// @param _auth initialize authorization parameters for this contract, including applicable conditions /// @param _borgSafe address of the applicable BORG's Gnosis Safe which is adding this ejectImplant - constructor(BorgAuth _auth, address _borgSafe, address _failSafe, bool _allowManagement, bool _allowEjection) BaseImplant(_auth, _borgSafe) { + constructor(BorgAuth _auth, address _borgSafe, address _failSafe, bool _allowManagement, bool _allowEjection, bool _allowSelfEjectReduce) BaseImplant(_auth, _borgSafe) { if (IBaseImplant(_failSafe).IMPLANT_ID() != 0) revert ejectImplant_InvalidFailSafeImplant(); FAIL_SAFE = _failSafe; ALLOW_AUTH_MANAGEMENT = _allowManagement; ALLOW_AUTH_EJECT = _allowEjection; + ALLOW_AUTH_SELF_EJECT_REDUCE = _allowSelfEjectReduce; } /// @notice setFailSafeSignerThreshold for the DAO or oversight BORG to set the maximum threshold for the fail safe to be triggered @@ -193,6 +195,7 @@ contract ejectImplant is BaseImplant { /// @param _reduce boolean to reduce the threshold if the owner is the last to self-eject function selfEject(bool _reduce) public conditionCheck { if (!ISafe(BORG_SAFE).isOwner(msg.sender)) revert ejectImplant_NotOwner(); + if(_reduce && !ALLOW_AUTH_SELF_EJECT_REDUCE) revert ejectImplant_ActionNotEnabled(); address[] memory owners = ISafe(BORG_SAFE).getOwners(); address prevOwner = address(0x1); diff --git a/test/PBVBorg.t.sol b/test/PBVBorg.t.sol index 1979f6d..280903e 100644 --- a/test/PBVBorg.t.sol +++ b/test/PBVBorg.t.sol @@ -60,7 +60,7 @@ contract PBVBorgTest is Test { safe = IGnosisSafe(MULTISIG); core = new borgCore(auth, 0x1, borgCore.borgModes.whitelist, 'pbv-borg-testing', address(safe)); failSafe = new failSafeImplant(auth, address(safe), dao); - eject = new ejectImplant(auth, MULTISIG, address(failSafe), false, true); + eject = new ejectImplant(auth, MULTISIG, address(failSafe), false, true, true); //for test: give out some tokens diff --git a/test/blackList.t.sol b/test/blackList.t.sol index ae539b6..9f3b489 100644 --- a/test/blackList.t.sol +++ b/test/blackList.t.sol @@ -50,7 +50,7 @@ contract BlackListTest is Test { mockPerm = new MockPerm(); failSafe = new failSafeImplant(auth, address(safe), dao); - eject = new ejectImplant(auth, MULTISIG, address(failSafe), false, true); + eject = new ejectImplant(auth, MULTISIG, address(failSafe), false, true, true); deal(owner, 2 ether); deal(MULTISIG, 2 ether); diff --git a/test/borgCore.t.sol b/test/borgCore.t.sol index c346a73..dd2200e 100644 --- a/test/borgCore.t.sol +++ b/test/borgCore.t.sol @@ -48,7 +48,7 @@ contract BorgCoreTest is Test { core = new borgCore(auth, 0x1, borgCore.borgModes.whitelist, 'borg-core-testing', address(safe)); failSafe = new failSafeImplant(auth, address(safe), dao); - eject = new ejectImplant(auth, MULTISIG, address(failSafe), false, true); + eject = new ejectImplant(auth, MULTISIG, address(failSafe), false, true, true); deal(owner, 2 ether); deal(MULTISIG, 2 ether); diff --git a/test/ejectImplant.t.sol b/test/ejectImplant.t.sol index 02e440e..81e08fc 100644 --- a/test/ejectImplant.t.sol +++ b/test/ejectImplant.t.sol @@ -49,7 +49,7 @@ contract EjectTest is Test { core = new borgCore(auth, 0x1, borgCore.borgModes.whitelist, "eject-testing", address(safe)); failSafe = new failSafeImplant(auth, address(safe), dao); - eject = new ejectImplant(auth, MULTISIG, address(failSafe), true, true); + eject = new ejectImplant(auth, MULTISIG, address(failSafe), true, true, true); vm.prank(dao); auth.updateRole(address(eject), 99); diff --git a/test/grantBorg.t.sol b/test/grantBorg.t.sol index 250f85e..30a14a5 100644 --- a/test/grantBorg.t.sol +++ b/test/grantBorg.t.sol @@ -97,7 +97,7 @@ contract GrantBorgTest is Test { safe = IGnosisSafe(MULTISIG); core = new borgCore(auth, 0x1, borgCore.borgModes.whitelist, 'grant-bool-testing', address(safe)); failSafe = new failSafeImplant(auth, address(safe), dao); - eject = new ejectImplant(auth, MULTISIG, address(failSafe), false, true); + eject = new ejectImplant(auth, MULTISIG, address(failSafe), false, true, true); opGrant = new optimisticGrantImplant(auth, MULTISIG, address(metaVesTController)); //constructor(Auth _auth, address _borgSafe, uint256 _duration, uint _quorum, uint256 _threshold, uint _cooldown, address _governanceAdapter, address _governanceExecutor, address _metaVesT, address _metaVesTController) vetoGrant = new daoVetoGrantImplant(auth, MULTISIG, 600, 5, 10, 600, address(governanceAdapter), address(mockDao), address(metaVesTController)); diff --git a/test/signatureCondition.t.sol b/test/signatureCondition.t.sol index 6fe0937..bd8f25b 100644 --- a/test/signatureCondition.t.sol +++ b/test/signatureCondition.t.sol @@ -69,7 +69,7 @@ contract SigConditionTest is Test { safe = IGnosisSafe(MULTISIG); core = new borgCore(auth, 0x1, borgCore.borgModes.whitelist, 'sig-condition-testing', address(safe)); failSafe = new failSafeImplant(auth, address(safe), dao); - eject = new ejectImplant(auth, MULTISIG, address(failSafe), false, true); + eject = new ejectImplant(auth, MULTISIG, address(failSafe), false, true, true); //create SignatureCondition.Logic for and SignatureCondition.Logic logic = SignatureCondition.Logic.AND; diff --git a/test/voteBorg.t.sol b/test/voteBorg.t.sol index 79a1f9a..f2f407b 100644 --- a/test/voteBorg.t.sol +++ b/test/voteBorg.t.sol @@ -101,7 +101,7 @@ contract VoteBorgTest is Test { safe = IGnosisSafe(MULTISIG); core = new borgCore(auth, 0x1, borgCore.borgModes.whitelist, 'grant-bool-testing', address(safe)); failSafe = new failSafeImplant(auth, address(safe), dao); - eject = new ejectImplant(auth, MULTISIG, address(failSafe), false, true); + eject = new ejectImplant(auth, MULTISIG, address(failSafe), false, true, true); opGrant = new optimisticGrantImplant(auth, MULTISIG, address(metaVesTController)); //constructor(Auth _auth, address _borgSafe, uint256 _duration, uint _quorum, uint256 _threshold, uint _cooldown, address _governanceAdapter, address _governanceExecutor, address _metaVesT, address _metaVesTController) vetoGrant = new daoVetoGrantImplant(auth, MULTISIG, 600, 5, 10, 600, address(governanceAdapter), address(mockDao), address(metaVesTController)); diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 37d0517..7b05890 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -164,6 +164,7 @@ contract YearnBorgAcceptanceTest is Test { assertEq(eject.failSafeSignerThreshold(), 0, "Unexpected failSafeSignerThreshold"); assertTrue(eject.ALLOW_AUTH_MANAGEMENT(), "Auth management should be allowed"); assertTrue(eject.ALLOW_AUTH_EJECT(), "Auth ejection should be allowed"); + assertFalse(eject.ALLOW_AUTH_SELF_EJECT_REDUCE(), "Auth self-eject with reduce should not be allowed"); } /// @dev Safe normal operations should be unrestricted @@ -190,6 +191,12 @@ contract YearnBorgAcceptanceTest is Test { // Self-resign without changing threshold uint256 thresholdBefore = ychadSafe.getThreshold(); + // Self-resign with threshold reduce should not be allowed + vm.expectRevert(abi.encodeWithSelector(ejectImplant.ejectImplant_ActionNotEnabled.selector)); + vm.prank(testSigner); + eject.selfEject(true); + + // Otherwise, it should pass vm.prank(testSigner); eject.selfEject(false); From 1b0e38193977f6a340e265e8f3f26c6eeeba17d0 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 28 Apr 2025 09:08:51 -0700 Subject: [PATCH 31/52] Revert "test: Revise on-chain governance architectures to allow more flexible Yearn Governance designs. Revise README" This reverts commit 7b10491d384504eb33f1b8350df5f0989ffc309a. --- README-yearnBorg.md | 67 ++---------------- test/yearnBorgAcceptance.t.sol | 122 ++++++++++++--------------------- 2 files changed, 51 insertions(+), 138 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index f442abd..902cc7e 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -79,20 +79,15 @@ all coming operations listed above will require approval of both `ychad.eth` and ### Future On-chain Governance Transition -Yearn's Snapshot-based governance will transition to an on-chain governance system (ex. `YearnGovernance`). -An adapter (`YearnGovernanceAdapter`) will be implemented by MetaLex to manage the implementation details on co-approval process. -To integrate successfully, `YearnGovernance` must meet the following requirements: - -- Each proposal has an unique ID (ex. `proposalId`) -- `YearnGovernanceAdapter` can read the proposal's voting result and verify it is passed -- `YearnGovernanceAdapter` can extract the admin operation (ex. `target`, `value`, `calldata` or equivalent) from the proposal +Yearn's Snapshot governance will be replaced with an on-chain governance at some point (ex. `YearnGovExecutor`). +`YearnGovExecutor` (or its adapter) must satisfy the following requirements to integrate with the co-approval process: +- Each proposal must include generic transaction fields (`target`, `value`, `calldata` or their equivalents) to enable `YearnGovExecutor` to execute the proposal upon approval +- Proposals involving `ychad.eth` [Restricted Admin Operations](#restricted-admin-operations) must be executed solely by `ychad.eth` to enforce co-approval requirements The transition process from Snapshot to on-chain governance is listed as follows: -1. A final Snapshot proposal will be submitted to grant `YearnGovernanceAdapter` ownership of the implants -2. `ychad.eth` to co-approved and executed the proposal -3. The first on-chain proposal will be submitted to revoke `SnapShotExecutor` ownership of the implants -4. `ychad.eth` to co-approved and executed the proposal. The transition is now complete +1. A final Snapshot proposal will be submitted to replace `Snapshot Executor` with `YearnGovExecutor` by transferring ownership of `SudoImplant` and `EjectImplant` to `YearnGovExecutor` +2. Once co-approved and executed by `ychad.eth`, the transition process is complete After the transition, the co-approval process will become: @@ -100,56 +95,6 @@ After the transition, the co-approval process will become: 2. An on-chain proposal will be submitted to `YearnGovExecutor` 3. Once the vote passed, `ychad.eth` will co-approve it by executing the operation through the MetaLeX OS webapp -Below shows the changes of BORG architectures before/after on-chain governance transition: - -```mermaid -graph TD - ychad[ychad.eth
6/9 signers] - - subgraph offChainGovernance["Snapshot Governance (before)"] - yearnDaoVoting[Yearn DAO Voting Snapshot] - oracleAddr[oracle] - snapshotExecutor[Snapshot Executor] - end - - subgraph onChainGovernance["On-chain Governance (after)"] - yearnGovernance[Yearn Governance] - yearnGovernanceAdapter[Yearn Governance Adapter] - end - - subgraph implants["Implants (Modules)"] - ejectImplant{{Eject Implant}} - sudoImplant{{Sudo Implant}} - end - - ychad -->|"owner
execute(proposalId)"| snapshotExecutor - - oracleAddr -->|"oracle
propose(admin operation)"| snapshotExecutor - oracleAddr -->|monitor| yearnDaoVoting - - snapshotExecutor -->|"owner
admin operation()"| implants - - ychad -->|"owner
execute(proposalId)"| yearnGovernanceAdapter - - yearnGovernanceAdapter -->|"verify voting results of proposalId
and extract the admin operation"| yearnGovernance - yearnGovernanceAdapter -->|"owner
admin operation()"| implants - - %% Styling (optional, Mermaid supports limited styling) - classDef default fill:#191918,stroke:#fff,stroke-width:2px,color:#fff; - classDef borg fill:#191918,stroke:#E1FE52,stroke-width:2px,color:#E1FE52; - classDef yearn fill:#191918,stroke:#2C68DB,stroke-width:2px,color:#2C68DB; - classDef safe fill:#191918,stroke:#76FB8D,stroke-width:2px,color:#76FB8D; - classDef todo fill:#191918,stroke:#F09B4A,stroke-width:2px,color:#F09B4A; - class ejectImplant borg; - class sudoImplant borg; - class snapshotExecutor borg; - class oracleAddr borg; - class yearnGovernanceAdapter borg; - class ychad yearn; - class yearnDaoVoting yearn; - class yearnGovernance yearn; -``` - ### Module Addition New Modules grant `ychad.eth` privileges to bypass Guards restrictions, therefore it requires DAO co-approval via [Co-approval Workflows](#co-approval-workflows). diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 7b05890..1c2a69f 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -3,67 +3,39 @@ pragma solidity 0.8.20; import "forge-std/Test.sol"; import "solady/tokens/ERC20.sol"; +import {Ownable} from "openzeppelin/contracts/access/Ownable.sol"; import {borgCore} from "../src/borgCore.sol"; import {ejectImplant} from "../src/implants/ejectImplant.sol"; import {sudoImplant} from "../src/implants/sudoImplant.sol"; -import {BorgAuth, BorgAuthACL} from "../src/libs/auth.sol"; +import {BorgAuth} from "../src/libs/auth.sol"; import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; import {SafeTxHelper} from "./libraries/safeTxHelper.sol"; import {IGnosisSafe, GnosisTransaction, IMultiSendCallOnly} from "../test/libraries/safe.t.sol"; -contract MockYearnGovernance { - struct Proposal { +contract YearnGovExecutor is Ownable { + struct proposal { address target; uint256 value; bytes cdata; string description; } - mapping(bytes32 => Proposal) public proposals; - - // Assume this is how to get a proposal's content (including admin operation's data) - function getProposal(bytes32 proposalId) external returns (Proposal memory) { - return proposals[proposalId]; - } - - // Assume this is how to verify a proposal is passed - function isProposalPassed(bytes32 proposalId) external returns (bool) { - return true; - } + mapping(bytes32 => proposal) public pendingProposals; + + constructor(address owner) Ownable(owner) {} - // Assume this is how to propose an admin operation - function propose(Proposal calldata p) external returns (bytes32) { - bytes32 proposalId = keccak256(abi.encodePacked(p.target, p.value, p.cdata, p.description)); - proposals[proposalId] = p; + // Propose for voting + function propose(address target, uint256 value, bytes calldata cdata, string memory description) external returns (bytes32) { + bytes32 proposalId = keccak256(abi.encodePacked(target, value, cdata, description)); + pendingProposals[proposalId] = proposal(target, value, cdata, description); return proposalId; } -} - -contract MockYearnGovernanceAdapter is BorgAuthACL { - error YearnGovernanceAdapter_ProposalNotPassed(bytes32 proposalId); - error YearnGovernanceAdapter_ProposalAlreadyExecuted(bytes32 proposalId); - - MockYearnGovernance yearnGovernance; - mapping(bytes32 => bool) public proposalExecuted; - constructor(BorgAuth _auth, MockYearnGovernance _yearnGovernance) BorgAuthACL(_auth) { - yearnGovernance = _yearnGovernance; - } - - // Only owner (ychad.eth) is allowed to execute the admin operation. This is part of the co-approval process. + // Execute passed proposal (for testing we assume it always passes) function execute(bytes32 proposalId) payable external onlyOwner() { - if (!yearnGovernance.isProposalPassed(proposalId)) { - revert YearnGovernanceAdapter_ProposalNotPassed(proposalId); - } - - if (proposalExecuted[proposalId]) { - revert YearnGovernanceAdapter_ProposalAlreadyExecuted(proposalId); - } - - MockYearnGovernance.Proposal memory p = yearnGovernance.getProposal(proposalId); - proposalExecuted[proposalId] = true; - + proposal memory p = pendingProposals[proposalId]; (bool success, ) = p.target.call{value: p.value}(p.cdata); + delete pendingProposals[proposalId]; } } @@ -311,32 +283,28 @@ contract YearnBorgAcceptanceTest is Test { /// @dev Transition to on-chain governance should be successful with co-approval function testOnChainGovernanceTransition() public { - // Yearn to deploy on-chain governance contract - MockYearnGovernance yearnGovernance = new MockYearnGovernance(); - - // MetaLeX to deploy adapter - MockYearnGovernanceAdapter yearnGovernanceAdapter = new MockYearnGovernanceAdapter(snapShotExecutor.AUTH(), yearnGovernance); + YearnGovExecutor yearnGovExecutor = new YearnGovExecutor(address(ychadSafe)); BorgAuth implantAuth = eject.AUTH(); uint256 ownerRole = implantAuth.OWNER_ROLE(); // Should not be owner yet - vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(yearnGovernanceAdapter))); - implantAuth.onlyRole(ownerRole, address(yearnGovernanceAdapter)); + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(yearnGovExecutor))); + implantAuth.onlyRole(ownerRole, address(yearnGovExecutor)); // Simulate on-chain governance transition { - // SnapShotExecutor to add yearnGovernanceAdapter as owner + // SnapShotExecutor to add YearnGovExecutor as owner vm.prank(oracle); bytes32 proposalIdAddOwner = snapShotExecutor.propose( address(implantAuth), // target 0, // value abi.encodeWithSelector( implantAuth.updateRole.selector, - address(yearnGovernanceAdapter), + address(yearnGovExecutor), ownerRole ), // cdata - "Add yearnGovernanceAdapter as owner" + "Add yearnGovExecutor as owner" ); // After waiting period @@ -352,28 +320,28 @@ contract YearnBorgAcceptanceTest is Test { ) })); - // yearnGovernanceAdapter should be an owner now - implantAuth.onlyRole(ownerRole, address(yearnGovernanceAdapter)); + // YearnGovExecutor should be an owner now + implantAuth.onlyRole(ownerRole, address(yearnGovExecutor)); - // YearnGovernance to revoke SnapShotExecutor ownership - bytes32 proposalIdRevokeOwner = yearnGovernance.propose(MockYearnGovernance.Proposal({ - target: address(implantAuth), - value: 0, - cdata: abi.encodeWithSelector( + // YearnGovExecutor to remove SnapShotExecutor's ownership + bytes32 proposalIdRemoveOwner = yearnGovExecutor.propose( + address(implantAuth), // target + 0, // value + abi.encodeWithSelector( implantAuth.updateRole.selector, address(snapShotExecutor), 0 - ), - description: "Revoke snapShotExecutor ownership" - })); + ), // cdata + "Remove snapShotExecutor ownership" + ); - // Execute the passed proposal + // Execute the proposal safeTxHelper.executeSingle(GnosisTransaction({ - to: address(yearnGovernanceAdapter), + to: address(yearnGovExecutor), value: 0, data: abi.encodeWithSelector( - MockYearnGovernanceAdapter.execute.selector, - proposalIdRevokeOwner + yearnGovExecutor.execute.selector, + proposalIdRemoveOwner ) })); @@ -387,26 +355,26 @@ contract YearnBorgAcceptanceTest is Test { vm.assertFalse(ychadSafe.isOwner(alice), "Should not be Safe signer"); // Simulate a proposal (and it is immediately passed) - bytes32 proposalId = yearnGovernance.propose(MockYearnGovernance.Proposal({ - target: address(eject), - value: 0, - cdata: abi.encodeWithSelector( + bytes32 proposalId = yearnGovExecutor.propose( + address(eject), // target + 0, // value + abi.encodeWithSelector( bytes4(keccak256("addOwner(address)")), alice // newOwner - ), - description: "Add Alice as new signer" - })); + ), // cdata + "Add Alice as new signer" + ); // Should fail if not executed from ychad.eth - vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(this))); - yearnGovernanceAdapter.execute(proposalId); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + yearnGovExecutor.execute(proposalId); // Should succeed if executed from ychad.eth safeTxHelper.executeSingle(GnosisTransaction({ - to: address(yearnGovernanceAdapter), + to: address(yearnGovExecutor), value: 0, data: abi.encodeWithSelector( - MockYearnGovernanceAdapter.execute.selector, + yearnGovExecutor.execute.selector, proposalId ) })); From 62c231fd89e369b6475fe8bbb6f5219fd72c8679 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 28 Apr 2025 11:12:38 -0700 Subject: [PATCH 32/52] chore: Update README regarding on-chain governance transition process, Module-updates and self-resignation restrictions. --- README-yearnBorg.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index bed5e81..e4e3589 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -89,37 +89,41 @@ This essentially means that ychad cannot 'breach' its basic 'agreement' with the ### Future On-chain Governance Transition -Yearn's Snapshot governance will be replaced with an on-chain governance at some point (ex. `YearnGovExecutor`). -`YearnGovExecutor` (or its adapter) must satisfy the following requirements to integrate with the co-approval process: -- Each proposal must include generic transaction fields (`target`, `value`, `calldata` or their equivalents) to enable `YearnGovExecutor` to execute the proposal upon approval -- Proposals involving `ychad.eth` [Restricted Admin Operations](#restricted-admin-operations) must be executed solely by `ychad.eth` to enforce co-approval requirements +Yearn's Snapshot governance will be replaced with an on-chain governance at some point (ex. `YearnGovExecutor`). +Technically, the transition is done by having `YearnGovExecutor` serve as the new `oracle`. +Therefore, `YearnGovernance` must meet the following requirements: + +- `YearnGovernance` can call `SnapShotExecutor.propose(target, value, cdata, description)`, which contains the instructions of the admin operation The transition process from Snapshot to on-chain governance is listed as follows: -1. A final Snapshot proposal will be submitted to replace `Snapshot Executor` with `YearnGovExecutor` by transferring ownership of `SudoImplant` and `EjectImplant` to `YearnGovExecutor` +1. A final Snapshot proposal will be submitted to assign `YearnGovExecutor` as the new oracle of `Snapshot Executor` 2. Once co-approved and executed by `ychad.eth`, the transition process is complete After the transition, the co-approval process will become: -1. Operation is initiated on the MetaLeX OS webapp [discuss fallback options] +1. Operation is initiated on the MetaLeX OS webapp, or, alternatively, through a third-party UI if the calldata is prepared 2. An on-chain proposal will be submitted to `YearnGovExecutor` -3. Once the vote passed, `ychad.eth` will co-approve it by executing the operation through the MetaLeX OS webapp [discuss fallback options] +3. Once the vote passed, `YearnGovExecutor` will propose the results to the executor contract (`Snapshot Executor`), which will have the proposed transaction pending for co-approval +4. After a waiting period, `ychad.eth` can co-approve it by executing the operation through the MetaLeX OS webapp +5. After an extra waiting period, anyone can cancel the proposal if it hasn't been executed ### Module Addition New Modules grant `ychad.eth` privileges to bypass Guards restrictions, therefore it requires DAO co-approval via [Co-approval Workflows](#co-approval-workflows). -### Guard & Module Removal +### Guard & Module Updates In exceptional circumstances, `ychad.eth` can propose the removal of the Guard via [Co-approval Workflows](#co-approval-workflows). Upon DAO co-approval and execution, `ychad.eth` will no longer face any restriction on administrative operations. -**⚠️ Warning**: Disabling a Module revokes `ychad.eth`'s privileges. In particular, disabling `SudoImplant` will permanently eliminate `ychad.eth`'s ability to add new Modules or remove Guards. [discuss] +Likewise, `ychad.eth` can propose adding or removing Modules through [Co-approval Workflows](#co-approval-workflows) as well. +For safety, it cannot remove the `SudoImplant` Module itself. ## Member Self-resignation -A `ychad.eth` member can unilaterally resign by calling `EjectImplant.selfEject(false)` without approval. The Safe contract ensures threshold validity. -Alternatively, the member can call `EjectImplant.selfEject(true)` to resign and simultaneously reduce the threshold by 1 [wouldn't this require DAO co-approval as well since threshold is being changed?] +A `ychad.eth` member can unilaterally resign by calling `EjectImplant.selfEject(false)` without approval. The Safe contract ensures threshold validity. +Members are prohibited from calling `EjectImplant.selfEject(true)` as it would alter the multisig threshold. Consequently, they cannot self-resign when the remaining member count equals the threshold. ## Key Parameters From 37d38295797d5ab8f1343fbac9480f8733bcc6e0 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 28 Apr 2025 11:38:12 -0700 Subject: [PATCH 33/52] chore: clean up --- README-yearnBorg.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index e4e3589..d9a3553 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -79,9 +79,9 @@ Once ychad is "BORGed", the following actions will require bilateral approval of The process for bilateral `ychad.eth` / DAO approvals will be as follows: -1. Operation is initiated on the MetaLeX OS webapp [can snapshot's UI also be used as a fallback option?] +1. Operation is initiated on the MetaLeX OS webapp 2. A Snapshot proposal will be submitted via API using Yearn's existing voting settings -3. MetaLeX's Snapshot oracle (`oracle`) will submit the results on-chain to an executor contract (`Snapshot Executor`), which will have the proposed transaction pending for co-approval [let's discuss fallback options if our oracle were to go offline, let's discuss security measures around oracle] +3. MetaLeX's Snapshot oracle (`oracle`) will submit the results on-chain to an executor contract (`Snapshot Executor`), which will have the proposed transaction pending for co-approval 4. After a waiting period, `ychad.eth` can co-approve it by executing the operation through the MetaLeX OS webapp 5. After an extra waiting period, anyone can cancel the proposal if it hasn't been executed From 2c53941dd71814f7e0d2aad1b9bf6118c5020e05 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 28 Apr 2025 15:00:38 -0700 Subject: [PATCH 34/52] feat: Allow SnapshotExecutor to transfer oracle through proposal, and use it for on-chain governance transition --- src/libs/governance/snapShotExecutor.sol | 11 ++- test/snapShotExecutor.t.sol | 54 ++++++++++- test/yearnBorgAcceptance.t.sol | 112 +++++++++-------------- 3 files changed, 101 insertions(+), 76 deletions(-) diff --git a/src/libs/governance/snapShotExecutor.sol b/src/libs/governance/snapShotExecutor.sol index 5a22eb2..57a701c 100644 --- a/src/libs/governance/snapShotExecutor.sol +++ b/src/libs/governance/snapShotExecutor.sol @@ -97,7 +97,16 @@ contract SnapShotExecutor is BorgAuthACL { emit ProposalCanceled(proposalId, p.target, p.value, p.cdata, p.description, p.timestamp); } - function transferOracle(address newOracle) external onlyOwner() onlyDeadOracle() { + /// @dev Allow transferring oracle through a proposal. It must be called by `SnapShotExecutor` itself and the only way to do it is through propose()+execute(). + /// The new oracle accepts the transfer by calling any other onlyOracle() function + function transferOracle(address newOracle) external { + if (msg.sender != address(this)) revert SnapShotExecutor_NotAuthorized(); + pendingOracle = newOracle; + } + + /// @dev Called by the owner to salvage dead/non-responding oracle. + /// The new oracle accepts the transfer by calling any other onlyOracle() function + function transferExpiredOracle(address newOracle) external onlyOwner() onlyDeadOracle() { pendingOracle = newOracle; } diff --git a/test/snapShotExecutor.t.sol b/test/snapShotExecutor.t.sol index 40a22b9..6ccfef9 100644 --- a/test/snapShotExecutor.t.sol +++ b/test/snapShotExecutor.t.sol @@ -235,11 +235,55 @@ contract SnapShotExecutorTest is Test { assertEq(snapShotExecutor.lastOraclePingTimestamp(), lastOraclePingTimestamp + 2 days); } - /// @dev Owner should be able to replace oracle if it's dead + /// @dev Should be able to transfer oracle through a proposal function testTransferOracle() public { - skip(snapShotExecutor.ORACLE_TTL()); + // Propose & execute the transfer + vm.prank(oracle); + bytes32 proposalId = snapShotExecutor.propose( + address(snapShotExecutor), // target + 0 ether, // value + abi.encodeWithSelector( + snapShotExecutor.transferOracle.selector, + address(newOracle) + ), // cdata + "Transfer oracle" + ); + skip(snapShotExecutor.waitingPeriod()); // After waiting period vm.prank(owner); + snapShotExecutor.execute(proposalId); + + // Old oracle should still work when the transfer is pending + vm.prank(oracle); + snapShotExecutor.ping(); + assertEq(snapShotExecutor.oracle(), oracle); + + // Non-oracle should still be unauthorized + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); + snapShotExecutor.ping(); + + // Transfer should be done after the new oracle interacts + vm.prank(newOracle); + snapShotExecutor.ping(); + assertEq(snapShotExecutor.oracle(), newOracle); + assertEq(snapShotExecutor.pendingOracle(), address(0)); + // Old oracle should no longer be authorized + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); + vm.prank(oracle); + snapShotExecutor.ping(); + } + + /// @dev Should not be able to transfer oracle if not through a proposal + function test_RevertIf_TransferOracleNotSelf() public { + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); snapShotExecutor.transferOracle(newOracle); + } + + /// @dev Owner should be able to replace dead oracle + function testTransferExpiredOracle() public { + // Let the old oracle expire, then transfer it + skip(snapShotExecutor.ORACLE_TTL()); + vm.prank(owner); + snapShotExecutor.transferExpiredOracle(newOracle); // Old oracle should still work when the transfer is pending vm.prank(oracle); @@ -261,10 +305,10 @@ contract SnapShotExecutorTest is Test { snapShotExecutor.ping(); } - /// @dev Owner should not be able to replace oracle if it's not dead - function test_RevertIf_SetOracleNotDead() public { + /// @dev Owner should not be able to replace an oracle if it's not dead + function test_RevertIf_TransferExpiredOracleNotDead() public { vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_OracleNotDead.selector)); vm.prank(owner); - snapShotExecutor.transferOracle(newOracle); + snapShotExecutor.transferExpiredOracle(newOracle); } } diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 1c2a69f..abdbea2 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -12,30 +12,12 @@ import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; import {SafeTxHelper} from "./libraries/safeTxHelper.sol"; import {IGnosisSafe, GnosisTransaction, IMultiSendCallOnly} from "../test/libraries/safe.t.sol"; -contract YearnGovExecutor is Ownable { - struct proposal { - address target; - uint256 value; - bytes cdata; - string description; - } - - mapping(bytes32 => proposal) public pendingProposals; - - constructor(address owner) Ownable(owner) {} - - // Propose for voting - function propose(address target, uint256 value, bytes calldata cdata, string memory description) external returns (bytes32) { - bytes32 proposalId = keccak256(abi.encodePacked(target, value, cdata, description)); - pendingProposals[proposalId] = proposal(target, value, cdata, description); - return proposalId; - } - - // Execute passed proposal (for testing we assume it always passes) - function execute(bytes32 proposalId) payable external onlyOwner() { - proposal memory p = pendingProposals[proposalId]; - (bool success, ) = p.target.call{value: p.value}(p.cdata); - delete pendingProposals[proposalId]; +/// @dev For demonstration only. We are not opinionated on the implementation details of the actual on-chain governance contract as long as +/// it passes along all necessary instructions through `SnapShotExecutor.propose()` after the voting is passed +contract MockYearnGovExecutor { + // Again, the function signature does not have to be exact + function proposeToSnapshotExecutor(SnapShotExecutor snapShotExecutor, address target, uint256 value, bytes calldata cdata, string memory description) external returns (bytes32) { + return snapShotExecutor.propose(target, value, cdata, description); } } @@ -283,7 +265,7 @@ contract YearnBorgAcceptanceTest is Test { /// @dev Transition to on-chain governance should be successful with co-approval function testOnChainGovernanceTransition() public { - YearnGovExecutor yearnGovExecutor = new YearnGovExecutor(address(ychadSafe)); + MockYearnGovExecutor yearnGovExecutor = new MockYearnGovExecutor(); BorgAuth implantAuth = eject.AUTH(); uint256 ownerRole = implantAuth.OWNER_ROLE(); @@ -294,17 +276,16 @@ contract YearnBorgAcceptanceTest is Test { // Simulate on-chain governance transition { - // SnapShotExecutor to add YearnGovExecutor as owner + // SnapShotExecutor to assign YearnGovExecutor as the new oracle vm.prank(oracle); - bytes32 proposalIdAddOwner = snapShotExecutor.propose( - address(implantAuth), // target + bytes32 proposalIdTransferOracle = snapShotExecutor.propose( + address(snapShotExecutor), // target 0, // value abi.encodeWithSelector( - implantAuth.updateRole.selector, - address(yearnGovExecutor), - ownerRole + snapShotExecutor.transferOracle.selector, + address(yearnGovExecutor) ), // cdata - "Add yearnGovExecutor as owner" + "Set yearnGovExecutor as new oracle" ); // After waiting period @@ -316,48 +297,23 @@ contract YearnBorgAcceptanceTest is Test { value: 0, data: abi.encodeWithSelector( snapShotExecutor.execute.selector, - proposalIdAddOwner + proposalIdTransferOracle ) })); - // YearnGovExecutor should be an owner now - implantAuth.onlyRole(ownerRole, address(yearnGovExecutor)); - - // YearnGovExecutor to remove SnapShotExecutor's ownership - bytes32 proposalIdRemoveOwner = yearnGovExecutor.propose( - address(implantAuth), // target - 0, // value - abi.encodeWithSelector( - implantAuth.updateRole.selector, - address(snapShotExecutor), - 0 - ), // cdata - "Remove snapShotExecutor ownership" - ); - - // Execute the proposal - safeTxHelper.executeSingle(GnosisTransaction({ - to: address(yearnGovExecutor), - value: 0, - data: abi.encodeWithSelector( - yearnGovExecutor.execute.selector, - proposalIdRemoveOwner - ) - })); - - // SnapShotExecutor should no longer be an owner - vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(snapShotExecutor))); - implantAuth.onlyRole(ownerRole, address(snapShotExecutor)); + // YearnGovExecutor should be a pending oracle now, and it will assume the oracle role the next time it interacts with snapShotExecutor + assertEq(snapShotExecutor.pendingOracle(), address(yearnGovExecutor), "yearnGovExecutor should be pending as new oracle"); } // Simulate adding member through on-chain governance { vm.assertFalse(ychadSafe.isOwner(alice), "Should not be Safe signer"); - // Simulate a proposal (and it is immediately passed) - bytes32 proposalId = yearnGovExecutor.propose( + // Assume the voting passed and `yearnGovExecutor` proposes to `snapShotExecutor` + bytes32 proposalId = yearnGovExecutor.proposeToSnapshotExecutor( + snapShotExecutor, address(eject), // target - 0, // value + 0, //value abi.encodeWithSelector( bytes4(keccak256("addOwner(address)")), alice // newOwner @@ -365,16 +321,15 @@ contract YearnBorgAcceptanceTest is Test { "Add Alice as new signer" ); - // Should fail if not executed from ychad.eth - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); - yearnGovExecutor.execute(proposalId); + // After waiting period + skip(snapShotExecutor.waitingPeriod()); - // Should succeed if executed from ychad.eth + // Safe should be able to execute it and add Alice as new signer safeTxHelper.executeSingle(GnosisTransaction({ - to: address(yearnGovExecutor), + to: address(snapShotExecutor), value: 0, data: abi.encodeWithSelector( - yearnGovExecutor.execute.selector, + snapShotExecutor.execute.selector, proposalId ) })); @@ -443,4 +398,21 @@ contract YearnBorgAcceptanceTest is Test { abi.encodeWithSelector(borgCore.BORG_CORE_MethodNotAuthorized.selector) // expectRevertData ); } + + /// @dev Safe should be able to replace a dead oracle + function testTransferExpiredOracle() public { + // Let the old oracle expire, then transfer it + skip(snapShotExecutor.ORACLE_TTL()); + + // Safe should be able to replace the dead oracle unilaterally + safeTxHelper.executeSingle(GnosisTransaction({ + to: address(snapShotExecutor), + value: 0, + data: abi.encodeWithSelector( + snapShotExecutor.transferExpiredOracle.selector, + address(1) // new oracle + ) + })); + assertEq(snapShotExecutor.pendingOracle(), address(1), "New oracle should be pending now"); + } } From 7b6f938bc5446b8ffaeaa7a9bab94411b344f575 Mon Sep 17 00:00:00 2001 From: detoo Date: Tue, 29 Apr 2025 11:56:28 -0700 Subject: [PATCH 35/52] chore: Revise README --- README-yearnBorg.md | 53 +++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index d9a3553..dc697bf 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -2,13 +2,13 @@ ## BORG Architectures -| Entity | Descriptions | -|-------------------|------------------------------------------------------------------------------------------------------------------------------| -| BORG Core | A Safe Guard contract restricting `ychad.eth`'s administrative authority | -| Eject Implant | A Safe Module contract for `ychad.eth` member management, integrated with Snapshot Executor to enforce DAO co-approval | -| Sudo Implant | A Safe Module contract for `ychad.eth` Guard/Module management, integrated with Snapshot Executor to enforce DAO co-approval | -| Snapshot Executor | A smart contract enabling co-approval between a DAO and `ychad.eth` | -| oracle | A MetaLex service for coordinating Yearn Snapshot voting and recording results on-chain | +| Entity | Descriptions | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| BORG Core | A Safe Guard contract restricting ychad's administrative authority | +| Eject Implant | A Safe Module contract for ychad member management, integrated with Snapshot Executor to enforce DAO co-approval | +| Sudo Implant | A Safe Module contract for ychad Guard/Module management, integrated with Snapshot Executor to enforce DAO co-approval | +| Snapshot Executor | A smart contract enabling co-approval between a DAO and ychad | +| oracle | A MetaLex service for coordinating Yearn Snapshot voting and recording results on-chain. It is set to be replaced by Yearn's own on-chain governance contract in the future | ```mermaid graph TD @@ -77,12 +77,12 @@ Once ychad is "BORGed", the following actions will require bilateral approval of ### Co-approval Workflows -The process for bilateral `ychad.eth` / DAO approvals will be as follows: +The process for bilateral ychad / DAO approvals will be as follows: 1. Operation is initiated on the MetaLeX OS webapp 2. A Snapshot proposal will be submitted via API using Yearn's existing voting settings 3. MetaLeX's Snapshot oracle (`oracle`) will submit the results on-chain to an executor contract (`Snapshot Executor`), which will have the proposed transaction pending for co-approval -4. After a waiting period, `ychad.eth` can co-approve it by executing the operation through the MetaLeX OS webapp +4. After a waiting period, ychad can co-approve it by executing the operation through the MetaLeX OS webapp 5. After an extra waiting period, anyone can cancel the proposal if it hasn't been executed This essentially means that ychad cannot 'breach' its basic 'agreement' with the DAO by changing the meta-governance rules (ychad signer membership, ychad approval threshold). It also adds an extra security layer as ychad members cannot collude to change these fundamental rules. All other operations would remain under ychad's sole discretion. @@ -98,44 +98,45 @@ Therefore, `YearnGovernance` must meet the following requirements: The transition process from Snapshot to on-chain governance is listed as follows: 1. A final Snapshot proposal will be submitted to assign `YearnGovExecutor` as the new oracle of `Snapshot Executor` -2. Once co-approved and executed by `ychad.eth`, the transition process is complete +2. Once co-approved and executed by ychad, the transition process is complete After the transition, the co-approval process will become: 1. Operation is initiated on the MetaLeX OS webapp, or, alternatively, through a third-party UI if the calldata is prepared 2. An on-chain proposal will be submitted to `YearnGovExecutor` 3. Once the vote passed, `YearnGovExecutor` will propose the results to the executor contract (`Snapshot Executor`), which will have the proposed transaction pending for co-approval -4. After a waiting period, `ychad.eth` can co-approve it by executing the operation through the MetaLeX OS webapp +4. After a waiting period, ychad can co-approve it by executing the operation through the MetaLeX OS webapp 5. After an extra waiting period, anyone can cancel the proposal if it hasn't been executed ### Module Addition -New Modules grant `ychad.eth` privileges to bypass Guards restrictions, therefore it requires DAO co-approval via [Co-approval Workflows](#co-approval-workflows). +New Modules grant ychad privileges to bypass Guards restrictions, therefore it requires DAO co-approval via [Co-approval Workflows](#co-approval-workflows). ### Guard & Module Updates -In exceptional circumstances, `ychad.eth` can propose the removal of the Guard via [Co-approval Workflows](#co-approval-workflows). -Upon DAO co-approval and execution, `ychad.eth` will no longer face any restriction on administrative operations. +In exceptional circumstances, ychad can propose the removal of the Guard via [Co-approval Workflows](#co-approval-workflows). +Upon DAO co-approval and execution, ychad will no longer face any restriction on administrative operations. -Likewise, `ychad.eth` can propose adding or removing Modules through [Co-approval Workflows](#co-approval-workflows) as well. +Likewise, ychad can propose adding or removing Modules through [Co-approval Workflows](#co-approval-workflows) as well. For safety, it cannot remove the `SudoImplant` Module itself. ## Member Self-resignation -A `ychad.eth` member can unilaterally resign by calling `EjectImplant.selfEject(false)` without approval. The Safe contract ensures threshold validity. +A ychad member can unilaterally resign by calling `EjectImplant.selfEject(false)` without approval. The Safe contract ensures threshold validity. Members are prohibited from calling `EjectImplant.selfEject(true)` as it would alter the multisig threshold. Consequently, they cannot self-resign when the remaining member count equals the threshold. ## Key Parameters -| ID | Value | Descriptions | -|--------------------------------|------------|---------------------------------------------------------| -| `borgIdentifier` | Yearn BORG | BORG name | -| `borgMode` | blacklist | Every operation is allowed unless blacklisted | -| `borgType` | 3 | | -| `snapShotWaitingPeriod` | 3 days | Waiting period before a proposal can be executed | -| `snapShotCancelPeriod` | 2 days | Extra waiting period before a proposal can be cancelled | -| `snapShotPendingProposalLimit` | 3 | Maximum pending proposals | -| `oracle` | `address` | MetaLeX Snapshot oracle | +| ID | Value | Descriptions | +|--------------------------------|------------|---------------------------------------------------------------------------------------------------------------------------| +| `borgIdentifier` | Yearn BORG | BORG name | +| `borgMode` | blacklist | Every operation is allowed unless blacklisted | +| `borgType` | 3 | Dev BORG | +| `snapShotWaitingPeriod` | 3 days | Waiting period before a proposal can be executed | +| `snapShotCancelPeriod` | 2 days | Extra waiting period before a proposal can be cancelled | +| `snapShotPendingProposalLimit` | 3 | Maximum pending proposals | +| `snapShotTtl` | 30 days | Duration of inactivity before an oracle is deemed expired and can be replaced by ychad | +| `oracle` | `address` | MetaLeX Snapshot oracle (or Yearn on-chain governance contract after [transition](#future-on-chain-governance-transition) | ## Deployment @@ -170,7 +171,7 @@ Members are prohibited from calling `EjectImplant.selfEject(true)` as it would a data: 0xe19a9dd9000000000000000000000000bc19387f5b8ae73fad41cd2294f928a735c60534 ``` -4. Ask `ychad.eth` to sign and execute the Safe TXs +4. Ask ychad to sign and execute the Safe TXs ## Tests From 7d451595a7f2dae4f1cd1808df80ed92aa2382d4 Mon Sep 17 00:00:00 2001 From: detoo Date: Tue, 29 Apr 2025 12:10:07 -0700 Subject: [PATCH 36/52] feat: Updatable SnapShotExecutor.oracleTtl in preparation for on-chain governance transition --- src/libs/governance/snapShotExecutor.sol | 33 ++++++++++++++---------- test/snapShotExecutor.t.sol | 29 ++++++++++++++++----- test/yearnBorgAcceptance.t.sol | 12 ++++++--- 3 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/libs/governance/snapShotExecutor.sol b/src/libs/governance/snapShotExecutor.sol index 57a701c..aad9e4b 100644 --- a/src/libs/governance/snapShotExecutor.sol +++ b/src/libs/governance/snapShotExecutor.sol @@ -5,10 +5,11 @@ import "../auth.sol"; import "openzeppelin/contracts/utils/Address.sol"; contract SnapShotExecutor is BorgAuthACL { - uint256 public immutable ORACLE_TTL; address public oracle; + uint256 public oracleTtl; address public pendingOracle; + uint256 public pendingOracleTtl; uint256 public waitingPeriod; uint256 public cancelPeriod; uint256 public pendingProposalCount; @@ -34,27 +35,29 @@ contract SnapShotExecutor is BorgAuthACL { event ProposalCreated(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); event ProposalExecuted(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp, bool success); event ProposalCanceled(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); + event OracleTransferred(address newOracle, uint256 newOracleTtl); mapping(bytes32 => proposal) public pendingProposals; /// @dev Check if `msg.sender` is either the oracle or is pending to be one. If it's the latter, transfer it. Also ping for TTL checks. modifier onlyOracle() { - if (msg.sender != oracle) { - if (msg.sender == pendingOracle) { - // Pending oracle can accept the transfer - oracle = pendingOracle; - pendingOracle = address(0); - } else { - // Not authorized if neither oracle nor pending oracle - revert SnapShotExecutor_NotAuthorized(); - } + if (msg.sender == pendingOracle) { + // Pending oracle can accept the transfer + oracle = pendingOracle; + oracleTtl = pendingOracleTtl; + pendingOracle = address(0); + pendingOracleTtl = 0; + emit OracleTransferred(oracle, oracleTtl); + } else if (msg.sender != oracle) { + // Not authorized if neither oracle nor pending oracle + revert SnapShotExecutor_NotAuthorized(); } lastOraclePingTimestamp = block.timestamp; _; } modifier onlyDeadOracle() { - if (block.timestamp < lastOraclePingTimestamp + ORACLE_TTL) revert SnapShotExecutor_OracleNotDead(); + if (block.timestamp < lastOraclePingTimestamp + oracleTtl) revert SnapShotExecutor_OracleNotDead(); _; } @@ -65,7 +68,7 @@ contract SnapShotExecutor is BorgAuthACL { if(_cancelPeriod < 1 minutes) revert SnapShotExeuctor_InvalidParams(); cancelPeriod = _cancelPeriod; pendingProposalLimit = _pendingProposals; - ORACLE_TTL = _oracleTtl; + oracleTtl = _oracleTtl; lastOraclePingTimestamp = block.timestamp; } @@ -99,15 +102,17 @@ contract SnapShotExecutor is BorgAuthACL { /// @dev Allow transferring oracle through a proposal. It must be called by `SnapShotExecutor` itself and the only way to do it is through propose()+execute(). /// The new oracle accepts the transfer by calling any other onlyOracle() function - function transferOracle(address newOracle) external { + function transferOracle(address newOracle, uint256 newOracleTtl) external { if (msg.sender != address(this)) revert SnapShotExecutor_NotAuthorized(); pendingOracle = newOracle; + pendingOracleTtl = newOracleTtl; } /// @dev Called by the owner to salvage dead/non-responding oracle. /// The new oracle accepts the transfer by calling any other onlyOracle() function - function transferExpiredOracle(address newOracle) external onlyOwner() onlyDeadOracle() { + function transferExpiredOracle(address newOracle, uint256 newOracleTtl) external onlyOwner() onlyDeadOracle() { pendingOracle = newOracle; + pendingOracleTtl = newOracleTtl; } function ping() external onlyOracle() {} diff --git a/test/snapShotExecutor.t.sol b/test/snapShotExecutor.t.sol index 6ccfef9..a6006af 100644 --- a/test/snapShotExecutor.t.sol +++ b/test/snapShotExecutor.t.sol @@ -16,12 +16,16 @@ contract SnapShotExecutorTest is Test { address newOracle = vm.addr(3); address alice = vm.addr(4); + uint256 oracleTtl = 30 days; + uint256 newOracleTtl = 60 days; + BorgAuth auth; SnapShotExecutor snapShotExecutor; event ProposalCreated(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); event ProposalExecuted(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp, bool success); event ProposalCanceled(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); + event OracleTransferred(address newOracle, uint256 newOracleTtl); function setUp() public virtual { auth = new BorgAuth(); @@ -31,7 +35,7 @@ contract SnapShotExecutorTest is Test { 3 days, // waitingPeriod 2 days, // cancelPeriod 3, // pendingProposalLimit - 30 days // ttl + oracleTtl ); // Transferring auth ownership @@ -47,7 +51,7 @@ contract SnapShotExecutorTest is Test { assertEq(snapShotExecutor.cancelPeriod(), 2 days, "Unexpected cancelPeriod"); assertEq(snapShotExecutor.pendingProposalCount(), 0, "Unexpected pendingProposalCount"); assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); - assertEq(snapShotExecutor.ORACLE_TTL(), 30 days, "Unexpected ORACLE_TTL"); + assertEq(snapShotExecutor.oracleTtl(), 30 days, "Unexpected ORACLE_TTL"); assertEq(snapShotExecutor.lastOraclePingTimestamp(), block.timestamp, "Unexpected lastOraclePingTimestamp"); } @@ -244,7 +248,8 @@ contract SnapShotExecutorTest is Test { 0 ether, // value abi.encodeWithSelector( snapShotExecutor.transferOracle.selector, - address(newOracle) + address(newOracle), + newOracleTtl ), // cdata "Transfer oracle" ); @@ -256,16 +261,21 @@ contract SnapShotExecutorTest is Test { vm.prank(oracle); snapShotExecutor.ping(); assertEq(snapShotExecutor.oracle(), oracle); + assertEq(snapShotExecutor.oracleTtl(), oracleTtl); // Non-oracle should still be unauthorized vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); snapShotExecutor.ping(); // Transfer should be done after the new oracle interacts + vm.expectEmit(); + emit OracleTransferred(newOracle, newOracleTtl); vm.prank(newOracle); snapShotExecutor.ping(); assertEq(snapShotExecutor.oracle(), newOracle); + assertEq(snapShotExecutor.oracleTtl(), newOracleTtl); assertEq(snapShotExecutor.pendingOracle(), address(0)); + assertEq(snapShotExecutor.pendingOracleTtl(), 0); // Old oracle should no longer be authorized vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); vm.prank(oracle); @@ -275,30 +285,35 @@ contract SnapShotExecutorTest is Test { /// @dev Should not be able to transfer oracle if not through a proposal function test_RevertIf_TransferOracleNotSelf() public { vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); - snapShotExecutor.transferOracle(newOracle); + snapShotExecutor.transferOracle(newOracle, newOracleTtl); } /// @dev Owner should be able to replace dead oracle function testTransferExpiredOracle() public { // Let the old oracle expire, then transfer it - skip(snapShotExecutor.ORACLE_TTL()); + skip(snapShotExecutor.oracleTtl()); vm.prank(owner); - snapShotExecutor.transferExpiredOracle(newOracle); + snapShotExecutor.transferExpiredOracle(newOracle, newOracleTtl); // Old oracle should still work when the transfer is pending vm.prank(oracle); snapShotExecutor.ping(); assertEq(snapShotExecutor.oracle(), oracle); + assertEq(snapShotExecutor.oracleTtl(), oracleTtl); // Non-oracle should still be unauthorized vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); snapShotExecutor.ping(); // Transfer should be done after the new oracle interacts + vm.expectEmit(); + emit OracleTransferred(newOracle, newOracleTtl); vm.prank(newOracle); snapShotExecutor.ping(); assertEq(snapShotExecutor.oracle(), newOracle); + assertEq(snapShotExecutor.oracleTtl(), newOracleTtl); assertEq(snapShotExecutor.pendingOracle(), address(0)); + assertEq(snapShotExecutor.pendingOracleTtl(), 0); // Old oracle should no longer be authorized vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); vm.prank(oracle); @@ -309,6 +324,6 @@ contract SnapShotExecutorTest is Test { function test_RevertIf_TransferExpiredOracleNotDead() public { vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_OracleNotDead.selector)); vm.prank(owner); - snapShotExecutor.transferExpiredOracle(newOracle); + snapShotExecutor.transferExpiredOracle(newOracle, newOracleTtl); } } diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index abdbea2..6709bd9 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -111,7 +111,7 @@ contract YearnBorgAcceptanceTest is Test { assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); assertEq(snapShotExecutor.cancelPeriod(), 2 days, "Unexpected cancelPeriod"); assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); - assertEq(snapShotExecutor.ORACLE_TTL(), 30 days, "Unexpected ORACLE_TTL"); + assertEq(snapShotExecutor.oracleTtl(), 30 days, "Unexpected ORACLE_TTL"); } function testEjectImplantMeta() public { @@ -283,7 +283,8 @@ contract YearnBorgAcceptanceTest is Test { 0, // value abi.encodeWithSelector( snapShotExecutor.transferOracle.selector, - address(yearnGovExecutor) + address(yearnGovExecutor), + 1095 days // 3 years ), // cdata "Set yearnGovExecutor as new oracle" ); @@ -303,6 +304,7 @@ contract YearnBorgAcceptanceTest is Test { // YearnGovExecutor should be a pending oracle now, and it will assume the oracle role the next time it interacts with snapShotExecutor assertEq(snapShotExecutor.pendingOracle(), address(yearnGovExecutor), "yearnGovExecutor should be pending as new oracle"); + assertEq(snapShotExecutor.pendingOracleTtl(), 1095 days, "Unexpected pending oracle TTL"); } // Simulate adding member through on-chain governance @@ -402,7 +404,7 @@ contract YearnBorgAcceptanceTest is Test { /// @dev Safe should be able to replace a dead oracle function testTransferExpiredOracle() public { // Let the old oracle expire, then transfer it - skip(snapShotExecutor.ORACLE_TTL()); + skip(snapShotExecutor.oracleTtl()); // Safe should be able to replace the dead oracle unilaterally safeTxHelper.executeSingle(GnosisTransaction({ @@ -410,9 +412,11 @@ contract YearnBorgAcceptanceTest is Test { value: 0, data: abi.encodeWithSelector( snapShotExecutor.transferExpiredOracle.selector, - address(1) // new oracle + address(1), // new oracle + 1 days // new oracle TTL ) })); assertEq(snapShotExecutor.pendingOracle(), address(1), "New oracle should be pending now"); + assertEq(snapShotExecutor.pendingOracleTtl(), 1 days, "New oracle TTL should be pending now"); } } From 1d3e514f139c30ffed09dc29ed7ce8161f88f6d5 Mon Sep 17 00:00:00 2001 From: detoo Date: Tue, 29 Apr 2025 14:02:34 -0700 Subject: [PATCH 37/52] feat: Extend SnapShotExecutor cancellation waiting period for more reasonable multisig response --- README-yearnBorg.md | 20 ++++++++++---------- scripts/yearnBorg.s.sol | 2 +- test/yearnBorgAcceptance.t.sol | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index dc697bf..e790c9a 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -127,16 +127,16 @@ Members are prohibited from calling `EjectImplant.selfEject(true)` as it would a ## Key Parameters -| ID | Value | Descriptions | -|--------------------------------|------------|---------------------------------------------------------------------------------------------------------------------------| -| `borgIdentifier` | Yearn BORG | BORG name | -| `borgMode` | blacklist | Every operation is allowed unless blacklisted | -| `borgType` | 3 | Dev BORG | -| `snapShotWaitingPeriod` | 3 days | Waiting period before a proposal can be executed | -| `snapShotCancelPeriod` | 2 days | Extra waiting period before a proposal can be cancelled | -| `snapShotPendingProposalLimit` | 3 | Maximum pending proposals | -| `snapShotTtl` | 30 days | Duration of inactivity before an oracle is deemed expired and can be replaced by ychad | -| `oracle` | `address` | MetaLeX Snapshot oracle (or Yearn on-chain governance contract after [transition](#future-on-chain-governance-transition) | +| ID | Value | Descriptions | +|--------------------------------|------------|----------------------------------------------------------------------------------------------------------------------------| +| `borgIdentifier` | Yearn BORG | BORG name | +| `borgMode` | blacklist | Every operation is allowed unless blacklisted | +| `borgType` | 3 | Dev BORG | +| `snapShotWaitingPeriod` | 3 days | Waiting period before a proposal can be executed | +| `snapShotCancelPeriod` | 7 days | Extra waiting period before a proposal can be cancelled | +| `snapShotPendingProposalLimit` | 3 | Maximum pending proposals | +| `snapShotTtl` | 30 days | Duration of inactivity before an oracle is deemed expired and can be replaced by ychad | +| `oracle` | `address` | MetaLeX Snapshot oracle (or Yearn on-chain governance contract after [transition](#future-on-chain-governance-transition)) | ## Deployment diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index c3d1e81..8399343 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -42,7 +42,7 @@ contract YearnBorgDeployScript is Script { // Configs: SnapShowExecutor uint256 snapShotWaitingPeriod = 3 days; // TODO Is it still necessary? - uint256 snapShotCancelPeriod = 2 days; + uint256 snapShotCancelPeriod = 7 days; uint256 snapShotPendingProposalLimit = 3; uint256 snapShotTtl = 30 days; address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00; diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 6709bd9..dc56eb8 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -109,7 +109,7 @@ contract YearnBorgAcceptanceTest is Test { function testSnapShotExecutorMeta() public { assertEq(snapShotExecutor.oracle(), oracle, "Unexpected oracle"); assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); - assertEq(snapShotExecutor.cancelPeriod(), 2 days, "Unexpected cancelPeriod"); + assertEq(snapShotExecutor.cancelPeriod(), 7 days, "Unexpected cancelPeriod"); assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); assertEq(snapShotExecutor.oracleTtl(), 30 days, "Unexpected ORACLE_TTL"); } From 2f3b49f1833b26b84a278ec00cfc36dca2b9782b Mon Sep 17 00:00:00 2001 From: detoo Date: Tue, 29 Apr 2025 14:20:36 -0700 Subject: [PATCH 38/52] chore: misc code quality improvements --- scripts/yearnBorg.s.sol | 2 +- test/libraries/safeTxHelper.sol | 38 ++++++++++++++++----------------- test/yearnBorg.t.sol | 2 +- test/yearnBorgAcceptance.t.sol | 6 +++--- 4 files changed, 23 insertions(+), 25 deletions(-) diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 8399343..511e9cb 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -22,7 +22,7 @@ contract PlaceholderFailSafeImplant { error PlaceholderFailSafeImplant_UnexpectedTrigger(); - function recoverSafeFunds() external { + function recoverSafeFunds() external pure { revert PlaceholderFailSafeImplant_UnexpectedTrigger(); } } diff --git a/test/libraries/safeTxHelper.sol b/test/libraries/safeTxHelper.sol index c37f420..f1ace13 100644 --- a/test/libraries/safeTxHelper.sol +++ b/test/libraries/safeTxHelper.sol @@ -23,7 +23,7 @@ contract SafeTxHelper is CommonBase { signer = vm.addr(signerPrivateKey); } - function createTestBatch(address core) public returns (GnosisTransaction[] memory) { + function createTestBatch(address core) public view returns (GnosisTransaction[] memory) { GnosisTransaction[] memory batch = new GnosisTransaction[](2); address guyToApprove = address(0xdeadbabe); address token = 0xF17A3fE536F8F7847F1385ec1bC967b2Ca9caE8D; @@ -97,13 +97,13 @@ contract SafeTxHelper is CommonBase { return GnosisTransaction({to: address(safe), value: 0, data: cdata}); } - function getNativeTransferData(address to, uint256 amount) public view returns (GnosisTransaction memory) { + function getNativeTransferData(address to, uint256 amount) public pure returns (GnosisTransaction memory) { // Send the value with no data GnosisTransaction memory txData = GnosisTransaction({to: to, value: amount, data: ""}); return txData; } - function getTransferData(address token, address to, uint256 amount) public view returns (GnosisTransaction memory) { + function getTransferData(address token, address to, uint256 amount) public pure returns (GnosisTransaction memory) { bytes4 transferFunctionSignature = bytes4( keccak256("transfer(address,uint256)") ); @@ -117,7 +117,7 @@ contract SafeTxHelper is CommonBase { return txData; } - function getApproveData(address token, address spender, uint256 amount) public view returns (GnosisTransaction memory) { + function getApproveData(address token, address spender, uint256 amount) public pure returns (GnosisTransaction memory) { bytes4 approveFunctionSignature = bytes4( keccak256("approve(address,uint256)") ); @@ -131,7 +131,7 @@ contract SafeTxHelper is CommonBase { return txData; } - function getAddContractGuardData(address to, address allow, uint256 amount) public view returns (GnosisTransaction memory) { + function getAddContractGuardData(address to, address allow, uint256 amount) public pure returns (GnosisTransaction memory) { bytes4 funcSig = bytes4( keccak256("addContract(address,uint256)") ); @@ -200,7 +200,7 @@ contract SafeTxHelper is CommonBase { return GnosisTransaction({to: address(safe), value: 0, data: cdata}); } - function getAddRecipientGuardData(address to, address _contract, uint256 amount) public view returns (GnosisTransaction memory) { + function getAddRecipientGuardData(address to, address _contract, uint256 amount) public pure returns (GnosisTransaction memory) { bytes4 addRecipientMethod = bytes4( keccak256("addRecipient(address,uint256)") ); @@ -214,7 +214,7 @@ contract SafeTxHelper is CommonBase { return txData; } - function getRemoveRecepientGuardData(address to, address _contract) public view returns (GnosisTransaction memory) { + function getRemoveRecepientGuardData(address to, address _contract) public pure returns (GnosisTransaction memory) { bytes4 removeRecepientMethod = bytes4( keccak256("removeRecepient(address)") ); @@ -227,7 +227,7 @@ contract SafeTxHelper is CommonBase { return txData; } - function getRemoveContractGuardData(address to, address _contract) public view returns (GnosisTransaction memory) { + function getRemoveContractGuardData(address to, address _contract) public pure returns (GnosisTransaction memory) { bytes4 removeContractMethod = bytes4( keccak256("removeContract(address)") ); @@ -240,7 +240,7 @@ contract SafeTxHelper is CommonBase { return txData; } - function getRemovePolicyMethodGuardData(address to, address _contract, string memory methodSignature) public view returns (GnosisTransaction memory) { + function getRemovePolicyMethodGuardData(address to, address _contract, string memory methodSignature) public pure returns (GnosisTransaction memory) { bytes memory cdata = abi.encodeWithSelector( borgCore.removePolicyMethod.selector, _contract, @@ -249,7 +249,7 @@ contract SafeTxHelper is CommonBase { return GnosisTransaction({to: to, value: 0, data: cdata}); } - function getRemoveParameterConstraintGuardData(address to, address _contract, string memory methodSignature, uint256 byteOffset) public view returns (GnosisTransaction memory) { + function getRemoveParameterConstraintGuardData(address to, address _contract, string memory methodSignature, uint256 byteOffset) public pure returns (GnosisTransaction memory) { bytes memory cdata = abi.encodeWithSelector( borgCore.removeParameterConstraint.selector, _contract, @@ -259,7 +259,7 @@ contract SafeTxHelper is CommonBase { return GnosisTransaction({to: to, value: 0, data: cdata}); } - function getCreateGrantData(address opGrant, address token, address rec, uint256 amount) public view returns (GnosisTransaction memory) { + function getCreateGrantData(address opGrant, address token, address rec, uint256 amount) public pure returns (GnosisTransaction memory) { bytes4 funcSig = bytes4( keccak256("createDirectGrant(address,address,uint256)") ); @@ -275,9 +275,7 @@ contract SafeTxHelper is CommonBase { } function getCreateBasicGrantData(address opGrant, address token, address rec, uint256 amount) public view returns (GnosisTransaction memory) { - //Configure the metavest details - uint256 _unlocked = amount/2; - uint256 _vested = amount/2; + // Configure the metavest details BaseAllocation.Milestone[] memory emptyMilestones; BaseAllocation.Allocation memory _metavestDetails = BaseAllocation.Allocation({ tokenStreamTotal: amount, @@ -307,13 +305,13 @@ contract SafeTxHelper is CommonBase { return txData; } - function getGuard(address safe) external view returns (address guard) { + function getGuard(address _safe) external view returns (address guard) { // Workaround since getGuard() is not public: // https://github.com/safe-global/safe-smart-account/blob/c4859f4182be9d3fad0e5b5853c26a013c8b43a2/contracts/base/GuardManager.sol#L83-L97 // keccak256("guard_manager.guard.address") bytes32 GUARD_STORAGE_SLOT = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; - return address(uint160(uint256(vm.load(safe, GUARD_STORAGE_SLOT)))); + return address(uint160(uint256(vm.load(_safe, GUARD_STORAGE_SLOT)))); } function getSignature( @@ -374,12 +372,12 @@ contract SafeTxHelper is CommonBase { executeData(address(multiSendCallOnly), 1, data, 0, ""); } - function executeSingle(GnosisTransaction memory tx) public { - executeData(tx.to, 0, tx.data, tx.value, ""); + function executeSingle(GnosisTransaction memory _tx) public { + executeData(_tx.to, 0, _tx.data, _tx.value, ""); } - function executeSingle(GnosisTransaction memory tx, bytes memory expectRevertData) public { - executeData(tx.to, 0, tx.data, tx.value, expectRevertData); + function executeSingle(GnosisTransaction memory _tx, bytes memory expectRevertData) public { + executeData(_tx.to, 0, _tx.data, _tx.value, expectRevertData); } function executeData( diff --git a/test/yearnBorg.t.sol b/test/yearnBorg.t.sol index 29e1fa9..c6784eb 100644 --- a/test/yearnBorg.t.sol +++ b/test/yearnBorg.t.sol @@ -9,7 +9,7 @@ import {GnosisTransaction} from "../test/libraries/safe.t.sol"; contract YearnBorgTest is YearnBorgAcceptanceTest { function setUp() public override { - // Assume Ethereum mainnet fork after block 22268905 + // Assume Ethereum mainnet fork after block 22377182 // Simulate changing ychad.eth threshold and adding the test owner so we can run tests vm.prank(address(ychadSafe)); diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index dc56eb8..79e2c70 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -58,7 +58,7 @@ contract YearnBorgAcceptanceTest is Test { } /// @dev BORG Core metadata should meet specs - function testBorgMeta() public { + function testBorgMeta() public view { assertEq(core.id(), "Yearn BORG", "Unexpected BORG ID"); assertEq(core.borgType(), 0x3, "Unexpected BORG Core type"); assertEq(uint8(core.borgMode()), uint8(borgCore.borgModes.blacklist), "Unexpected BORG Core mode"); @@ -106,7 +106,7 @@ contract YearnBorgAcceptanceTest is Test { } } - function testSnapShotExecutorMeta() public { + function testSnapShotExecutorMeta() public view { assertEq(snapShotExecutor.oracle(), oracle, "Unexpected oracle"); assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); assertEq(snapShotExecutor.cancelPeriod(), 7 days, "Unexpected cancelPeriod"); @@ -114,7 +114,7 @@ contract YearnBorgAcceptanceTest is Test { assertEq(snapShotExecutor.oracleTtl(), 30 days, "Unexpected ORACLE_TTL"); } - function testEjectImplantMeta() public { + function testEjectImplantMeta() public view { assertEq(eject.failSafeSignerThreshold(), 0, "Unexpected failSafeSignerThreshold"); assertTrue(eject.ALLOW_AUTH_MANAGEMENT(), "Auth management should be allowed"); assertTrue(eject.ALLOW_AUTH_EJECT(), "Auth ejection should be allowed"); From 45c0840d740061d8c912085b6ba5f6123c881faf Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 1 May 2025 09:11:19 -0700 Subject: [PATCH 39/52] feat: Clarify waiting period vs expiry on snapShotExecutor --- src/libs/governance/snapShotExecutor.sol | 25 ++++++++++++------------ test/snapShotExecutor.t.sol | 10 +++++----- test/yearnBorgAcceptance.t.sol | 2 +- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/libs/governance/snapShotExecutor.sol b/src/libs/governance/snapShotExecutor.sol index aad9e4b..e2b1bb3 100644 --- a/src/libs/governance/snapShotExecutor.sol +++ b/src/libs/governance/snapShotExecutor.sol @@ -11,7 +11,7 @@ contract SnapShotExecutor is BorgAuthACL { address public pendingOracle; uint256 public pendingOracleTtl; uint256 public waitingPeriod; - uint256 public cancelPeriod; + uint256 public proposalExpirySeconds; uint256 public pendingProposalCount; uint256 public pendingProposalLimit; uint256 public lastOraclePingTimestamp; @@ -21,20 +21,21 @@ contract SnapShotExecutor is BorgAuthACL { uint256 value; bytes cdata; string description; - uint256 timestamp; + uint256 executableAfter; } error SnapShotExecutor_NotAuthorized(); error SnapShotExecutor_InvalidProposal(); error SnapShotExecutor_WaitingPeriod(); + error SnapShotExecutor_NotExpired(); error SnapShotExeuctor_InvalidParams(); error SnapShotExecutor_TooManyPendingProposals(); error SnapShotExecutor_OracleNotDead(); //events - event ProposalCreated(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); - event ProposalExecuted(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp, bool success); - event ProposalCanceled(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 timestamp); + event ProposalCreated(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 executableAfter); + event ProposalExecuted(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 executableAfter, bool success); + event ProposalCanceled(bytes32 indexed proposalId, address indexed target, uint256 value, bytes cdata, string description, uint256 executableAfter); event OracleTransferred(address newOracle, uint256 newOracleTtl); mapping(bytes32 => proposal) public pendingProposals; @@ -61,12 +62,12 @@ contract SnapShotExecutor is BorgAuthACL { _; } - constructor(BorgAuth _auth, address _oracle, uint256 _waitingPeriod, uint256 _cancelPeriod, uint256 _pendingProposals, uint256 _oracleTtl) BorgAuthACL(_auth) { + constructor(BorgAuth _auth, address _oracle, uint256 _waitingPeriod, uint256 _proposalExpirySeconds, uint256 _pendingProposals, uint256 _oracleTtl) BorgAuthACL(_auth) { oracle = _oracle; if(_waitingPeriod < 1 minutes) revert SnapShotExeuctor_InvalidParams(); waitingPeriod = _waitingPeriod; - if(_cancelPeriod < 1 minutes) revert SnapShotExeuctor_InvalidParams(); - cancelPeriod = _cancelPeriod; + if(_proposalExpirySeconds < 1 minutes) revert SnapShotExeuctor_InvalidParams(); + proposalExpirySeconds = _proposalExpirySeconds; pendingProposalLimit = _pendingProposals; oracleTtl = _oracleTtl; lastOraclePingTimestamp = block.timestamp; @@ -83,21 +84,21 @@ contract SnapShotExecutor is BorgAuthACL { function execute(bytes32 proposalId) payable external onlyOwner() { proposal memory p = pendingProposals[proposalId]; - if (p.timestamp > block.timestamp) revert SnapShotExecutor_WaitingPeriod(); + if (p.executableAfter > block.timestamp) revert SnapShotExecutor_WaitingPeriod(); if(p.target == address(0)) revert SnapShotExecutor_InvalidProposal(); (bool success, ) = p.target.call{value: p.value}(p.cdata); - emit ProposalExecuted(proposalId, p.target, p.value, p.cdata, p.description, p.timestamp, success); + emit ProposalExecuted(proposalId, p.target, p.value, p.cdata, p.description, p.executableAfter, success); pendingProposalCount--; delete pendingProposals[proposalId]; } function cancel(bytes32 proposalId) external { proposal memory p = pendingProposals[proposalId]; - if (p.timestamp + cancelPeriod > block.timestamp) revert SnapShotExecutor_WaitingPeriod(); + if (p.executableAfter + proposalExpirySeconds > block.timestamp) revert SnapShotExecutor_NotExpired(); if(p.target == address(0)) revert SnapShotExecutor_InvalidProposal(); pendingProposalCount--; delete pendingProposals[proposalId]; - emit ProposalCanceled(proposalId, p.target, p.value, p.cdata, p.description, p.timestamp); + emit ProposalCanceled(proposalId, p.target, p.value, p.cdata, p.description, p.executableAfter); } /// @dev Allow transferring oracle through a proposal. It must be called by `SnapShotExecutor` itself and the only way to do it is through propose()+execute(). diff --git a/test/snapShotExecutor.t.sol b/test/snapShotExecutor.t.sol index a6006af..d2ed502 100644 --- a/test/snapShotExecutor.t.sol +++ b/test/snapShotExecutor.t.sol @@ -33,7 +33,7 @@ contract SnapShotExecutorTest is Test { auth, oracle, 3 days, // waitingPeriod - 2 days, // cancelPeriod + 7 days, // cancelPeriod 3, // pendingProposalLimit oracleTtl ); @@ -48,7 +48,7 @@ contract SnapShotExecutorTest is Test { assertEq(snapShotExecutor.oracle(), oracle, "Unexpected oracle address"); assertEq(snapShotExecutor.pendingOracle(), address(0), "Unexpected pending oracle address"); assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); - assertEq(snapShotExecutor.cancelPeriod(), 2 days, "Unexpected cancelPeriod"); + assertEq(snapShotExecutor.proposalExpirySeconds(), 7 days, "Unexpected cancelPeriod"); assertEq(snapShotExecutor.pendingProposalCount(), 0, "Unexpected pendingProposalCount"); assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); assertEq(snapShotExecutor.oracleTtl(), 30 days, "Unexpected ORACLE_TTL"); @@ -149,7 +149,7 @@ contract SnapShotExecutorTest is Test { // cancel() should fail within waiting period - vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_WaitingPeriod.selector)); + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotExpired.selector)); snapShotExecutor.cancel(proposalId); // After waiting period @@ -157,11 +157,11 @@ contract SnapShotExecutorTest is Test { // cancel() should fail within cancel period - vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_WaitingPeriod.selector)); + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotExpired.selector)); snapShotExecutor.cancel(proposalId); // After cancel period - skip(snapShotExecutor.cancelPeriod()); + skip(snapShotExecutor.proposalExpirySeconds()); // cancel() should succeed now diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 79e2c70..ab301db 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -109,7 +109,7 @@ contract YearnBorgAcceptanceTest is Test { function testSnapShotExecutorMeta() public view { assertEq(snapShotExecutor.oracle(), oracle, "Unexpected oracle"); assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); - assertEq(snapShotExecutor.cancelPeriod(), 7 days, "Unexpected cancelPeriod"); + assertEq(snapShotExecutor.proposalExpirySeconds(), 7 days, "Unexpected cancelPeriod"); assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); assertEq(snapShotExecutor.oracleTtl(), 30 days, "Unexpected ORACLE_TTL"); } From 57c426428da7ffe959cdc2a76b2c08eba625ab70 Mon Sep 17 00:00:00 2001 From: detoo Date: Sat, 17 May 2025 15:01:02 -0700 Subject: [PATCH 40/52] fix: Additional checks to prevent duplicate proposals --- src/libs/governance/snapShotExecutor.sol | 3 ++ test/snapShotExecutor.t.sol | 44 +++++++++++++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/libs/governance/snapShotExecutor.sol b/src/libs/governance/snapShotExecutor.sol index e2b1bb3..d69ad71 100644 --- a/src/libs/governance/snapShotExecutor.sol +++ b/src/libs/governance/snapShotExecutor.sol @@ -26,6 +26,7 @@ contract SnapShotExecutor is BorgAuthACL { error SnapShotExecutor_NotAuthorized(); error SnapShotExecutor_InvalidProposal(); + error SnapShotExecutor_ProposalAlreadyExists(); error SnapShotExecutor_WaitingPeriod(); error SnapShotExecutor_NotExpired(); error SnapShotExeuctor_InvalidParams(); @@ -76,6 +77,8 @@ contract SnapShotExecutor is BorgAuthACL { function propose(address target, uint256 value, bytes calldata cdata, string memory description) external onlyOracle() returns (bytes32) { if(pendingProposalCount >= pendingProposalLimit) revert SnapShotExecutor_TooManyPendingProposals(); bytes32 proposalId = keccak256(abi.encodePacked(target, value, cdata, description)); + // Make sure the new proposal does not duplicate a previous one, otherwise we wouldn't be able to cancel both + if (pendingProposals[proposalId].target != address(0)) revert SnapShotExecutor_ProposalAlreadyExists(); pendingProposals[proposalId] = proposal(target, value, cdata, description, block.timestamp + waitingPeriod); pendingProposalCount++; emit ProposalCreated(proposalId, target, value, cdata, description, block.timestamp + waitingPeriod); diff --git a/test/snapShotExecutor.t.sol b/test/snapShotExecutor.t.sol index d2ed502..f400f3f 100644 --- a/test/snapShotExecutor.t.sol +++ b/test/snapShotExecutor.t.sol @@ -123,6 +123,42 @@ contract SnapShotExecutorTest is Test { } } + /// @dev Should not be able to propose duplicates and mess up with the proposal count + function test_RevertIf_ProposalAlreadyExists() public { + // First proposal should work + vm.prank(oracle); + bytes32 proposalId1 = snapShotExecutor.propose( + address(alice), // target + 0, // value + "", // cdata + "Arbitrary instruction" + ); + + // Duplicate proposal should fail + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_ProposalAlreadyExists.selector)); + vm.prank(oracle); + snapShotExecutor.propose( + address(alice), // target + 0, // value + "", // cdata + "Arbitrary instruction" + ); + + // Change the description should work + vm.prank(oracle); + bytes32 proposalId2 = snapShotExecutor.propose( + address(alice), // target + 0, // value + "", // cdata + "Different descriptions" + ); + assertEq(snapShotExecutor.pendingProposalCount(), 2, "Expect 2 pending proposal"); + (,,, string memory description1, ) = snapShotExecutor.pendingProposals(proposalId1); + assertEq(description1, "Arbitrary instruction", "Expect proposal1's description"); + (,,, string memory description2, ) = snapShotExecutor.pendingProposals(proposalId2); + assertEq(description2, "Different descriptions", "Expect proposal2's description"); + } + /// @dev Non-oracle should not be able to propose function test_RevertIf_NotOracleProposal() public { vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); @@ -185,19 +221,19 @@ contract SnapShotExecutorTest is Test { address(alice), // target 0, // value "", // cdata - "Arbitrary instruction" + "Arbitrary instruction 1" ); snapShotExecutor.propose( address(alice), // target 0, // value "", // cdata - "Arbitrary instruction" + "Arbitrary instruction 2" ); snapShotExecutor.propose( address(alice), // target 0, // value "", // cdata - "Arbitrary instruction" + "Arbitrary instruction 3" ); // Should failed due to the limit @@ -207,7 +243,7 @@ contract SnapShotExecutorTest is Test { address(alice), // target 0, // value "", // cdata - "Arbitrary instruction" + "Arbitrary instruction 4" ); vm.stopPrank(); From 97b6d94be2a81a359953bc525aeadf24410ebd24 Mon Sep 17 00:00:00 2001 From: detoo Date: Sat, 17 May 2025 15:45:52 -0700 Subject: [PATCH 41/52] chore: Add scripts to replace SnapShotExecutor --- scripts/yearnBorg.s.sol | 4 +- .../yearnBorgReplaceSnapShotExecutor.s.sol | 94 +++++++++++++++++++ test/yearnBorgReplaceSnapShotExecutor.t.sol | 76 +++++++++++++++ 3 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 scripts/yearnBorgReplaceSnapShotExecutor.s.sol create mode 100644 test/yearnBorgReplaceSnapShotExecutor.t.sol diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 511e9cb..04b4ba2 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -44,7 +44,7 @@ contract YearnBorgDeployScript is Script { uint256 snapShotWaitingPeriod = 3 days; // TODO Is it still necessary? uint256 snapShotCancelPeriod = 7 days; uint256 snapShotPendingProposalLimit = 3; - uint256 snapShotTtl = 30 days; + uint256 snapShotOracleTtl = 30 days; address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00; SafeTxHelper safeTxHelper; @@ -109,7 +109,7 @@ contract YearnBorgDeployScript is Script { // Create SnapShotExecutor executorAuth = new BorgAuth(); - snapShotExecutor = new SnapShotExecutor(executorAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelPeriod, snapShotPendingProposalLimit, snapShotTtl); + snapShotExecutor = new SnapShotExecutor(executorAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelPeriod, snapShotPendingProposalLimit, snapShotOracleTtl); // Add modules diff --git a/scripts/yearnBorgReplaceSnapShotExecutor.s.sol b/scripts/yearnBorgReplaceSnapShotExecutor.s.sol new file mode 100644 index 0000000..0fd0c0a --- /dev/null +++ b/scripts/yearnBorgReplaceSnapShotExecutor.s.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import {CommonBase} from "forge-std/Base.sol"; +import {Script} from "forge-std/Script.sol"; +import {StdChains} from "forge-std/StdChains.sol"; +import {StdCheatsSafe} from "forge-std/StdCheats.sol"; +import {StdUtils} from "forge-std/StdUtils.sol"; +import {console2} from "forge-std/console2.sol"; +import {ejectImplant} from "../src/implants/ejectImplant.sol"; +import {sudoImplant} from "../src/implants/sudoImplant.sol"; +import {BorgAuth} from "../src/libs/auth.sol"; +import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; +import {IGnosisSafe} from "../test/libraries/safe.t.sol"; + +contract YearnBorgReplaceSnapShotExecutorScript is Script { + + // Warning: review and update the following before run + + IGnosisSafe ychadSafe = IGnosisSafe(0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52); // ychad.eth + + ejectImplant eject = ejectImplant(0xe44f5c9EAFB87731906AB87156E4F4cB3fa0Eb74); + sudoImplant sudo = sudoImplant(0x6766b727aa1489443b34A02ee89c34f39748600b); + SnapShotExecutor oldSnapShotExecutor = SnapShotExecutor(0x77691936fb6337d4B71dc62643b05b6bBE19285c); + + // Configs: SnapShowExecutor + // Reuse the old one's parameters if we are just upgrading it to a newer version + uint256 snapShotWaitingPeriod = oldSnapShotExecutor.waitingPeriod(); + uint256 snapShotCancelPeriod = oldSnapShotExecutor.proposalExpirySeconds(); + uint256 snapShotPendingProposalLimit = oldSnapShotExecutor.pendingProposalLimit(); + uint256 snapShotOracleTtl = oldSnapShotExecutor.oracleTtl(); + address oracle = oldSnapShotExecutor.oracle(); + + BorgAuth executorAuth = oldSnapShotExecutor.AUTH(); + BorgAuth implantAuth = eject.AUTH(); + + /// @dev For running from `forge script`. Provide the deployer private key through env var. + function run() public returns(SnapShotExecutor, bytes memory, bytes memory) { + return run(vm.envUint("DEPLOYER_PRIVATE_KEY")); + } + + /// @dev For running in tests + function run(uint256 deployerPrivateKey) public returns(SnapShotExecutor, bytes memory, bytes memory) { + console2.log("Configs:"); + console2.log(" Safe Multisig:", address(ychadSafe)); + console2.log(" Eject Implant:", address(eject)); + console2.log(" Sudo Implant:", address(sudo)); + console2.log(" Old SnapShotExecutor:", address(oldSnapShotExecutor)); + + address deployerAddress = vm.addr(deployerPrivateKey); + console2.log("Deployer:", deployerAddress); + + vm.startBroadcast(deployerPrivateKey); + + // Deploy new SnapShotExecutor + SnapShotExecutor newSnapShotExecutor = new SnapShotExecutor(executorAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelPeriod, snapShotPendingProposalLimit, snapShotOracleTtl); + + vm.stopBroadcast(); + + console2.log("Deployed addresses:"); + console2.log(" New SnapShotExecutor: ", address(newSnapShotExecutor)); + + // Generate the proposal calldata for old SnapShotExecutor to transfer its implant ownership to the new one. + // We can't just do it here. The proposal must go through the co-approval process to take effect. + + bytes memory grantNewOwnerData = abi.encodeWithSelector( + implantAuth.updateRole.selector, + address(newSnapShotExecutor), + implantAuth.OWNER_ROLE() + ); + + bytes memory revokeOldOwnerData = abi.encodeWithSelector( + implantAuth.updateRole.selector, + address(oldSnapShotExecutor), + 0 + ); + + console2.log("Tx proposal for the old SnapShotExecutor:"); + console2.log(" to:", address(implantAuth)); + console2.log(" value: 0"); + console2.log(" data:"); + console2.logBytes(grantNewOwnerData); + console2.log(""); + + console2.log("Tx proposal for the new SnapShotExecutor:"); + console2.log(" to:", address(implantAuth)); + console2.log(" value: 0"); + console2.log(" data:"); + console2.logBytes(revokeOldOwnerData); + console2.log(""); + + return (newSnapShotExecutor, grantNewOwnerData, revokeOldOwnerData); + } +} diff --git a/test/yearnBorgReplaceSnapShotExecutor.t.sol b/test/yearnBorgReplaceSnapShotExecutor.t.sol new file mode 100644 index 0000000..7099e18 --- /dev/null +++ b/test/yearnBorgReplaceSnapShotExecutor.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.20; + +import "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; +import {BorgAuth} from "../src/libs/auth.sol"; +import {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol"; +import {YearnBorgDeployScript} from "../scripts/yearnBorg.s.sol"; +import {YearnBorgAcceptanceTest} from "./yearnBorgAcceptance.t.sol"; +import {GnosisTransaction} from "../test/libraries/safe.t.sol"; +import {YearnBorgReplaceSnapShotExecutorScript} from "../scripts/yearnBorgReplaceSnapShotExecutor.s.sol"; + +/// @dev Simulate replacing SnapShotExecutor of a normal Yearn BORG deployment. The Yearn BORG acceptance tests should still pass +contract YearnBorgReplaceSnapShotExecutorTest is YearnBorgAcceptanceTest { + SnapShotExecutor oldSnapShotExecutor; + + function setUp() public override { + // Assume Ethereum mainnet fork after block 22377182 + + // Simulate changing ychad.eth threshold and adding the test owner so we can run tests + vm.prank(address(ychadSafe)); + ychadSafe.addOwnerWithThreshold(testSigner, 1); + + // MetaLex to deploy new BORG contracts and generate corresponding Safe txs for ychad.eth + GnosisTransaction[] memory safeTxs; + (core, eject, sudo, oldSnapShotExecutor, safeTxs) = (new YearnBorgDeployScript()).run(testSignerPrivateKey); + + // Simulate ychad.eth executing the provided Safe TXs (set guard & add module) + safeTxHelper.executeBatch(safeTxs); + + // MetaLex to run SnapShotExecutor replacing script + bytes memory grantNewOwnerData; + bytes memory revokeOldOwnerData; + (snapShotExecutor, grantNewOwnerData, revokeOldOwnerData) = (new YearnBorgReplaceSnapShotExecutorScript()).run(testSignerPrivateKey); + + BorgAuth implantAuth = eject.AUTH(); + + // Simulate proposing and executing the tx granting the new SnapShotExecutor as new owner + vm.prank(oracle); + bytes32 grantNewOwnerProposalId = oldSnapShotExecutor.propose(address(implantAuth), 0, grantNewOwnerData, "Grant new SnapShotExecutor as owner"); + skip(oldSnapShotExecutor.waitingPeriod()); // After waiting period + safeTxHelper.executeSingle(GnosisTransaction({ + to: address(oldSnapShotExecutor), + value: 0, + data: abi.encodeWithSelector( + oldSnapShotExecutor.execute.selector, + grantNewOwnerProposalId + ) + })); + + // Simulate proposing and executing the tx revoking the old SnapShotExecutor ownership + vm.prank(oracle); + bytes32 revokeOldOwnerProposalId = snapShotExecutor.propose(address(implantAuth), 0, revokeOldOwnerData, "Revoke old SnapShotExecutor ownership"); + skip(snapShotExecutor.waitingPeriod()); // After waiting period + safeTxHelper.executeSingle(GnosisTransaction({ + to: address(snapShotExecutor), + value: 0, + data: abi.encodeWithSelector( + snapShotExecutor.execute.selector, + revokeOldOwnerProposalId + ) + })); + } + + function testReplaceSnapShotExecutorScript() public { + BorgAuth implantAuth = eject.AUTH(); + + // Verify the ownership has been transferred + uint256 ownerRole = implantAuth.OWNER_ROLE(); + implantAuth.onlyRole(ownerRole, address(snapShotExecutor)); + vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(oldSnapShotExecutor))); + implantAuth.onlyRole(ownerRole, address(oldSnapShotExecutor)); + } + + // The acceptance tests will run against the overridden setup +} From 00e162811213434b0ad879e806d66b8f6de40a00 Mon Sep 17 00:00:00 2001 From: 0xPrepop <6125373+merisman@users.noreply.github.com> Date: Mon, 2 Jun 2025 12:43:38 -0400 Subject: [PATCH 42/52] First pass for blocking delegateCalls --- src/borgCore.sol | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/borgCore.sol b/src/borgCore.sol index e0f6f5f..bde3970 100644 --- a/src/borgCore.sol +++ b/src/borgCore.sol @@ -196,13 +196,18 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { lastNativeExecutionTimestamp = block.timestamp; } + // Block all delegate calls by default in blacklist mode + if(operation == Enum.Operation.DelegateCall) { + // Only allow if contract is explicitly whitelisted for delegate calls + if(!policy[to].enabled || !policy[to].delegateCallAllowed) { + revert BORG_CORE_DelegateCallNotAuthorized(); + } + } + //black list contract calls w/ data if (data.length > 0) { if(policy[to].enabled) { if(policy[to].fullAccessOrBlock) revert BORG_CORE_InvalidContract(); - if(!policy[to].delegateCallAllowed && operation == Enum.Operation.DelegateCall) { - revert BORG_CORE_DelegateCallNotAuthorized(); - } if(!isMethodCallAllowed(to, data)) revert BORG_CORE_MethodNotAuthorized(); From 94653d2c24c229685875d3722c781705cf354642 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 2 Jun 2025 11:26:04 -0700 Subject: [PATCH 43/52] feat: Transfer borgCore ownership to SnapShotExecutor for future policy change co-approvals --- scripts/yearnBorg.s.sol | 3 ++- test/yearnBorgAcceptance.t.sol | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 04b4ba2..7f19952 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -127,7 +127,8 @@ contract YearnBorgDeployScript is Script { address(ychadSafe) ); - // Burn core ownership + // Transfer core ownership to SnapShotExecutor + coreAuth.updateRole(address(snapShotExecutor), implantAuth.OWNER_ROLE()); coreAuth.zeroOwner(); // Transfer executor ownership to ychad.eth diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index ab301db..d239e31 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -78,6 +78,7 @@ contract YearnBorgAcceptanceTest is Test { // Verify core auth roles { uint256 ownerRole = coreAuth.OWNER_ROLE(); + coreAuth.onlyRole(ownerRole, address(snapShotExecutor)); // Verify not owners vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(ychadSafe))); coreAuth.onlyRole(ownerRole, address(ychadSafe)); From 9394785e114e8488da7c103386191c1a633eddc2 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 2 Jun 2025 16:01:05 -0700 Subject: [PATCH 44/52] test: BORG policy management through co-approval --- test/yearnBorgAcceptance.t.sol | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index d239e31..5998703 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -420,4 +420,59 @@ contract YearnBorgAcceptanceTest is Test { assertEq(snapShotExecutor.pendingOracle(), address(1), "New oracle should be pending now"); assertEq(snapShotExecutor.pendingOracleTtl(), 1 days, "New oracle TTL should be pending now"); } + + /// @dev BORG policy management should succeed given DAO and ychad.eth's co-approval + function testBorgPolicyManagement() public { + { + (bool approved,) = core.policyRecipients(alice); + vm.assertFalse(approved, "Alice should not be a recipient before proposal"); + } + + // Propose to change BORG policies + vm.prank(oracle); + bytes32 proposalId = snapShotExecutor.propose( + address(core), // target + 0, // value + abi.encodeWithSelector( + core.addRecipient.selector, + alice, // _recipient + 123 // _transactionLimit + ), // cdata + "Add Alice as a recipient" + ); + + // After waiting period + skip(snapShotExecutor.waitingPeriod()); + + // Should succeed if executed from Safe + safeTxHelper.executeSingle(GnosisTransaction({ + to: address(snapShotExecutor), + value: 0, + data: abi.encodeWithSelector( + snapShotExecutor.execute.selector, + proposalId + ) + })); + + { + (bool approved,) = core.policyRecipients(alice); + vm.assertTrue(approved, "Alice should be a recipient after proposal executed"); + } + } + + /// @dev Safe should not be able to unilaterally change BORG policies + function test_RevertIf_BorgPolicyManagementNotOwner() public { + safeTxHelper.executeSingle( + GnosisTransaction({ + to: address(core), + value: 0, + data: abi.encodeWithSelector( + core.addRecipient.selector, + alice, // _recipient + 123 // _transactionLimit + ) + }), + abi.encodePacked("GS013") // expectRevertData (code: Safe transaction failed when gasPrice and safeTxGas were 0) + ); + } } From 504ccc566a34c7b1cb285857459dbc35197c1965 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 2 Jun 2025 20:37:09 -0700 Subject: [PATCH 45/52] feat: Whitelist MultiSendCallOnly while blocking all other delegatecalls. Add tests --- scripts/yearnBorg.s.sol | 7 +++++-- src/borgCore.sol | 12 ++++-------- test/yearnBorgAcceptance.t.sol | 28 ++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 7f19952..3bc2968 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -35,13 +35,13 @@ contract YearnBorgDeployScript is Script { // Configs: BORG Core IGnosisSafe ychadSafe = IGnosisSafe(0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52); // ychad.eth - string borgIdentifier = "Yearn BORG"; // TODO WIP Ask for confirmation + string borgIdentifier = "Yearn BORG"; borgCore.borgModes borgMode = borgCore.borgModes.blacklist; uint256 borgType = 0x3; // devBORG // Configs: SnapShowExecutor - uint256 snapShotWaitingPeriod = 3 days; // TODO Is it still necessary? + uint256 snapShotWaitingPeriod = 3 days; uint256 snapShotCancelPeriod = 7 days; uint256 snapShotPendingProposalLimit = 3; uint256 snapShotOracleTtl = 30 days; @@ -90,6 +90,9 @@ contract YearnBorgDeployScript is Script { coreAuth = new BorgAuth(); core = new borgCore(coreAuth, borgType, borgMode, borgIdentifier, address(ychadSafe)); + // Whitelist MultiSendCallOnly for Operation.DelegateCall + core.toggleDelegateCallContract(address(multiSendCallOnly), true); + // Restrict admin operations // Safe.OwnerManager diff --git a/src/borgCore.sol b/src/borgCore.sol index bde3970..8efc53e 100644 --- a/src/borgCore.sol +++ b/src/borgCore.sol @@ -310,14 +310,10 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { /// @param _contract address, the address of the contract /// @param _allowed bool, the flag to allow delegate calls function toggleDelegateCallContract(address _contract, bool _allowed) external onlyOwner { - //ensure the contract is allowed before enabling delegate calls - if(policy[_contract].enabled == true) - { - policy[_contract].delegateCallAllowed = _allowed; - emit DelegateCallToggled(_contract, _allowed); - } - else - revert BORG_CORE_InvalidContract(); + // Toggle will enable the contract policy + policy[_contract].enabled = true; + policy[_contract].delegateCallAllowed = _allowed; + emit DelegateCallToggled(_contract, _allowed); } /// @dev remove contract address from the whitelist or blacklist diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index 5998703..ac46350 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -27,6 +27,7 @@ contract YearnBorgAcceptanceTest is Test { // Safe 1.3.0 Multi Send Call Only @ Ethereum mainnet // https://github.com/safe-global/safe-deployments?tab=readme-ov-file IMultiSendCallOnly multiSendCallOnly = IMultiSendCallOnly(0x40A2aCCbd92BCA938b02010E17A5b8929b49130D); + address multiSend = 0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761; IGnosisSafe ychadSafe = IGnosisSafe(0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52); // ychad.eth @@ -475,4 +476,31 @@ contract YearnBorgAcceptanceTest is Test { abi.encodePacked("GS013") // expectRevertData (code: Safe transaction failed when gasPrice and safeTxGas were 0) ); } + + /// @dev Safe should be able to use MultiSendCallOnly because its whitelisted + function testMultiSendCallOnly() public { + deal(address(weth), address(ychadSafe), 1 ether); + uint256 balanceBefore = weth.balanceOf(alice); + + GnosisTransaction[] memory safeTxs = new GnosisTransaction[](1); + safeTxs[0] = safeTxHelper.getTransferData(address(weth), alice, 1 ether); + safeTxHelper.executeBatch(safeTxs); + + vm.assertEq(weth.balanceOf(alice) - balanceBefore, 1 ether); + } + + /// @dev Safe should not be able to perform Operation.DelegateCall txs + function test_RevertIf_NonWhitelistedOperationDelegateCall() public { + deal(address(weth), address(ychadSafe), 1 ether); + + GnosisTransaction[] memory safeTxs = new GnosisTransaction[](1); + safeTxs[0] = safeTxHelper.getTransferData(address(weth), alice, 1 ether); + safeTxHelper.executeData( + multiSend, // Use multiSend because it is not whitelisted + 1, + safeTxHelper.getBatchExecutionData(safeTxs), + 0, + abi.encodeWithSelector(borgCore.BORG_CORE_DelegateCallNotAuthorized.selector) + ); + } } From 7eb2da090df6ff17307a356e037fca6abc9fc9ed Mon Sep 17 00:00:00 2001 From: detoo Date: Tue, 3 Jun 2025 09:50:04 -0700 Subject: [PATCH 46/52] chore: Add "Restricted Advanced Operations" section to README --- README-yearnBorg.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index e790c9a..7d0ff77 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -29,7 +29,6 @@ graph TD borg -->|"guard"| ychad - ychad -->|"owner
execute(proposalId)"| snapshotExecutor %% implants -->|modules| ychad @@ -39,6 +38,9 @@ graph TD oracleAddr -->|"oracle
propose(admin operation)"| snapshotExecutor oracleAddr -->|monitor| yearnDaoVoting + ychad -->|"owner
execute(proposalId)"| snapshotExecutor + + snapshotExecutor -->|"owner
policy operation()"| borg snapshotExecutor -->|"owner
guard & module management operation()"| sudoImplant snapshotExecutor -->|"owner
member management operation()"| ejectImplant @@ -69,12 +71,25 @@ If desired, can seek prior DAO social approval for these changes (and this is li ## Restricted Admin Operations -Once ychad is "BORGed", the following actions will require bilateral approval of the DAO and ychad. Onchain, this means 'blacklisting' certain unilateral SAFE operations that would otherwise be possible, instead requiring DAO/ychad co-approval of such actions: +Once ychad is "BORGed", the following operations will require bilateral approval of the DAO and ychad. Onchain, this means 'blacklisting' certain unilateral SAFE operations that would otherwise be possible, instead requiring DAO/ychad co-approval of such actions: - Add / remove / swap signers / change threshold - Add / disable Modules - Set Guards +## Restricted Advanced Operations + +Once ychad is "BORGed," the following operations are restricted for security reasons unless explicitly whitelisted: + +- Transactions executed in `DelegateCall` mode + +However, to ensure a seamless user experience, commonly used advanced operations are preemptively whitelisted, including: + +- Batch Transactions (via `MultiSendCallOnly`) + +Note: `MultiSendCallOnly` is whitelisted, but `MultiSend` is not, as it permits arbitrary `delegatecall`, posing security risks. +Operations relying on `MultiSend`, such as manual fund distributions, can typically be performed using safer alternatives, like custom vetted contracts. + ### Co-approval Workflows The process for bilateral ychad / DAO approvals will be as follows: From 4393296de0da9e56d0b50263ecd59dbaefc6f39a Mon Sep 17 00:00:00 2001 From: 0xPrepop <6125373+merisman@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:50:31 -0400 Subject: [PATCH 47/52] Updating to keep logic the same for whitelist, but new for blacklist. --- src/borgCore.sol | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/borgCore.sol b/src/borgCore.sol index 8efc53e..8da954d 100644 --- a/src/borgCore.sol +++ b/src/borgCore.sol @@ -310,10 +310,21 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { /// @param _contract address, the address of the contract /// @param _allowed bool, the flag to allow delegate calls function toggleDelegateCallContract(address _contract, bool _allowed) external onlyOwner { + //ensure the contract is allowed before enabling delegate calls + if(policy[_contract].enabled == true) + { + policy[_contract].delegateCallAllowed = _allowed; + emit DelegateCallToggled(_contract, _allowed); + } + else if(borgMode == borgModes.blacklist) + { // Toggle will enable the contract policy policy[_contract].enabled = true; policy[_contract].delegateCallAllowed = _allowed; emit DelegateCallToggled(_contract, _allowed); + } + else + revert BORG_CORE_InvalidContract(); } /// @dev remove contract address from the whitelist or blacklist From 05925a7e71417ddb2b2ae75b3642252e2f25fed2 Mon Sep 17 00:00:00 2001 From: 0xPrepop <6125373+merisman@users.noreply.github.com> Date: Thu, 5 Jun 2025 08:55:48 -0400 Subject: [PATCH 48/52] Updating toggle for blacklist mode. --- src/borgCore.sol | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/borgCore.sol b/src/borgCore.sol index bde3970..30dc1ff 100644 --- a/src/borgCore.sol +++ b/src/borgCore.sol @@ -316,6 +316,13 @@ contract borgCore is BaseGuard, BorgAuthACL, IEIP4824 { policy[_contract].delegateCallAllowed = _allowed; emit DelegateCallToggled(_contract, _allowed); } + else if(borgMode == borgModes.blacklist) + { + // Toggle will enable the contract policy + policy[_contract].enabled = true; + policy[_contract].delegateCallAllowed = _allowed; + emit DelegateCallToggled(_contract, _allowed); + } else revert BORG_CORE_InvalidContract(); } From 3132d3628d87403ee5cd834a44dfeda789a97823 Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 5 Jun 2025 09:14:07 -0700 Subject: [PATCH 49/52] chore: Optimize README sections --- README-yearnBorg.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README-yearnBorg.md b/README-yearnBorg.md index 7d0ff77..b27e619 100644 --- a/README-yearnBorg.md +++ b/README-yearnBorg.md @@ -77,19 +77,6 @@ Once ychad is "BORGed", the following operations will require bilateral approval - Add / disable Modules - Set Guards -## Restricted Advanced Operations - -Once ychad is "BORGed," the following operations are restricted for security reasons unless explicitly whitelisted: - -- Transactions executed in `DelegateCall` mode - -However, to ensure a seamless user experience, commonly used advanced operations are preemptively whitelisted, including: - -- Batch Transactions (via `MultiSendCallOnly`) - -Note: `MultiSendCallOnly` is whitelisted, but `MultiSend` is not, as it permits arbitrary `delegatecall`, posing security risks. -Operations relying on `MultiSend`, such as manual fund distributions, can typically be performed using safer alternatives, like custom vetted contracts. - ### Co-approval Workflows The process for bilateral ychad / DAO approvals will be as follows: @@ -140,6 +127,19 @@ For safety, it cannot remove the `SudoImplant` Module itself. A ychad member can unilaterally resign by calling `EjectImplant.selfEject(false)` without approval. The Safe contract ensures threshold validity. Members are prohibited from calling `EjectImplant.selfEject(true)` as it would alter the multisig threshold. Consequently, they cannot self-resign when the remaining member count equals the threshold. +## Restricted Advanced Operations + +Once ychad is "BORGed," the following operations are restricted for security reasons unless explicitly whitelisted: + +- Transactions executed in `DelegateCall` mode + +However, to ensure a seamless user experience, commonly used advanced operations are preemptively whitelisted, including: + +- Batch Transactions (via `MultiSendCallOnly`) + +Note: `MultiSendCallOnly` is whitelisted, but `MultiSend` is not, as it permits arbitrary `delegatecall`, posing security risks. +Operations relying on `MultiSend`, such as manual fund distributions, can typically be performed using safer alternatives, like custom vetted contracts. + ## Key Parameters | ID | Value | Descriptions | From 531f3829cd49d7f295237d6a33a9c9badca7f1ee Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 5 Jun 2025 15:39:39 -0700 Subject: [PATCH 50/52] chore: Add CHANGELOG.md --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0d24f4e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- src/implants/sudoImplant.sol + - A Module (implant) for enforcing BORG, DAO co-approvals on Safe admin operations such as toggling Modules or setting Guards + +- src/libs/governance/snapShotExecutor.sol + - An off-chain (snapshot) voting coordinator contract that enforces BORG, DAO co-approvals on proposals + +- scripts/yearnBorg.s.sol + - Scripts for deploying Yearn BORG contracts + +### Updated + +- src/borgCore.sol + - Blocks delegate calls by default in blacklist mode and allow whitelisting specific contracts + - Maintains the same behaviors in whitelist mode + +- src/implants/ejectImplant.sol + - Allows admin to allow/disallow a member to reduce threshold when resigning From 24977dbcfa5d552af70edc3388704a7d04c72a90 Mon Sep 17 00:00:00 2001 From: detoo Date: Fri, 13 Jun 2025 16:12:45 -0700 Subject: [PATCH 51/52] feat: Change Yearn BORG oracle TTL per requested --- scripts/yearnBorg.s.sol | 2 +- test/yearnBorgAcceptance.t.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 3bc2968..8b91cd6 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -44,7 +44,7 @@ contract YearnBorgDeployScript is Script { uint256 snapShotWaitingPeriod = 3 days; uint256 snapShotCancelPeriod = 7 days; uint256 snapShotPendingProposalLimit = 3; - uint256 snapShotOracleTtl = 30 days; + uint256 snapShotOracleTtl = 14 days; address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00; SafeTxHelper safeTxHelper; diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index ac46350..b2ebf57 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -113,7 +113,7 @@ contract YearnBorgAcceptanceTest is Test { assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); assertEq(snapShotExecutor.proposalExpirySeconds(), 7 days, "Unexpected cancelPeriod"); assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); - assertEq(snapShotExecutor.oracleTtl(), 30 days, "Unexpected ORACLE_TTL"); + assertEq(snapShotExecutor.oracleTtl(), 14 days, "Unexpected ORACLE_TTL"); } function testEjectImplantMeta() public view { From b9d43386429e9fdd79fc4982678b88b39e3593fb Mon Sep 17 00:00:00 2001 From: Detoo Date: Tue, 17 Jun 2025 10:21:32 -0700 Subject: [PATCH 52/52] Fix/audit items (#4) * fix: Proposal validity checks * fix: naming and typo --- scripts/yearnBorg.s.sol | 6 +++--- scripts/yearnBorgReplaceSnapShotExecutor.s.sol | 4 ++-- src/libs/governance/snapShotExecutor.sol | 17 +++++++++-------- test/snapShotExecutor.t.sol | 17 +++++++++++++++-- test/yearnBorgAcceptance.t.sol | 2 +- 5 files changed, 30 insertions(+), 16 deletions(-) diff --git a/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol index 8b91cd6..ba74d6d 100644 --- a/scripts/yearnBorg.s.sol +++ b/scripts/yearnBorg.s.sol @@ -42,7 +42,7 @@ contract YearnBorgDeployScript is Script { // Configs: SnapShowExecutor uint256 snapShotWaitingPeriod = 3 days; - uint256 snapShotCancelPeriod = 7 days; + uint256 snapShotCancelWaitingPeriod = 7 days; uint256 snapShotPendingProposalLimit = 3; uint256 snapShotOracleTtl = 14 days; address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00; @@ -71,7 +71,7 @@ contract YearnBorgDeployScript is Script { console2.log(" BORG type:", borgType); console2.log(" Safe Multisig:", address(ychadSafe)); console2.log(" Snapshot waiting period (secs.):", snapShotWaitingPeriod); - console2.log(" Snapshot cancel period (secs.):", snapShotCancelPeriod); + console2.log(" Snapshot cancel period (secs.):", snapShotCancelWaitingPeriod); console2.log(" Snapshot pending proposal limit:", snapShotPendingProposalLimit); address deployerAddress = vm.addr(deployerPrivateKey); @@ -112,7 +112,7 @@ contract YearnBorgDeployScript is Script { // Create SnapShotExecutor executorAuth = new BorgAuth(); - snapShotExecutor = new SnapShotExecutor(executorAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelPeriod, snapShotPendingProposalLimit, snapShotOracleTtl); + snapShotExecutor = new SnapShotExecutor(executorAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelWaitingPeriod, snapShotPendingProposalLimit, snapShotOracleTtl); // Add modules diff --git a/scripts/yearnBorgReplaceSnapShotExecutor.s.sol b/scripts/yearnBorgReplaceSnapShotExecutor.s.sol index 0fd0c0a..380da4a 100644 --- a/scripts/yearnBorgReplaceSnapShotExecutor.s.sol +++ b/scripts/yearnBorgReplaceSnapShotExecutor.s.sol @@ -26,7 +26,7 @@ contract YearnBorgReplaceSnapShotExecutorScript is Script { // Configs: SnapShowExecutor // Reuse the old one's parameters if we are just upgrading it to a newer version uint256 snapShotWaitingPeriod = oldSnapShotExecutor.waitingPeriod(); - uint256 snapShotCancelPeriod = oldSnapShotExecutor.proposalExpirySeconds(); + uint256 snapShotCancelWaitingPeriod = oldSnapShotExecutor.cancelWaitingPeriod(); uint256 snapShotPendingProposalLimit = oldSnapShotExecutor.pendingProposalLimit(); uint256 snapShotOracleTtl = oldSnapShotExecutor.oracleTtl(); address oracle = oldSnapShotExecutor.oracle(); @@ -53,7 +53,7 @@ contract YearnBorgReplaceSnapShotExecutorScript is Script { vm.startBroadcast(deployerPrivateKey); // Deploy new SnapShotExecutor - SnapShotExecutor newSnapShotExecutor = new SnapShotExecutor(executorAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelPeriod, snapShotPendingProposalLimit, snapShotOracleTtl); + SnapShotExecutor newSnapShotExecutor = new SnapShotExecutor(executorAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelWaitingPeriod, snapShotPendingProposalLimit, snapShotOracleTtl); vm.stopBroadcast(); diff --git a/src/libs/governance/snapShotExecutor.sol b/src/libs/governance/snapShotExecutor.sol index d69ad71..64e9412 100644 --- a/src/libs/governance/snapShotExecutor.sol +++ b/src/libs/governance/snapShotExecutor.sol @@ -10,8 +10,8 @@ contract SnapShotExecutor is BorgAuthACL { uint256 public oracleTtl; address public pendingOracle; uint256 public pendingOracleTtl; - uint256 public waitingPeriod; - uint256 public proposalExpirySeconds; + uint256 public waitingPeriod; // Waiting time after proposal and before it can be executable + uint256 public cancelWaitingPeriod; // Waiting time after a proposal is executable and before it can be cancelled uint256 public pendingProposalCount; uint256 public pendingProposalLimit; uint256 public lastOraclePingTimestamp; @@ -29,7 +29,7 @@ contract SnapShotExecutor is BorgAuthACL { error SnapShotExecutor_ProposalAlreadyExists(); error SnapShotExecutor_WaitingPeriod(); error SnapShotExecutor_NotExpired(); - error SnapShotExeuctor_InvalidParams(); + error SnapShotExecutor_InvalidParams(); error SnapShotExecutor_TooManyPendingProposals(); error SnapShotExecutor_OracleNotDead(); @@ -63,12 +63,12 @@ contract SnapShotExecutor is BorgAuthACL { _; } - constructor(BorgAuth _auth, address _oracle, uint256 _waitingPeriod, uint256 _proposalExpirySeconds, uint256 _pendingProposals, uint256 _oracleTtl) BorgAuthACL(_auth) { + constructor(BorgAuth _auth, address _oracle, uint256 _waitingPeriod, uint256 _cancelWaitingPeriod, uint256 _pendingProposals, uint256 _oracleTtl) BorgAuthACL(_auth) { oracle = _oracle; - if(_waitingPeriod < 1 minutes) revert SnapShotExeuctor_InvalidParams(); + if(_waitingPeriod < 1 minutes) revert SnapShotExecutor_InvalidParams(); waitingPeriod = _waitingPeriod; - if(_proposalExpirySeconds < 1 minutes) revert SnapShotExeuctor_InvalidParams(); - proposalExpirySeconds = _proposalExpirySeconds; + if(_cancelWaitingPeriod < 1 minutes) revert SnapShotExecutor_InvalidParams(); + cancelWaitingPeriod = _cancelWaitingPeriod; pendingProposalLimit = _pendingProposals; oracleTtl = _oracleTtl; lastOraclePingTimestamp = block.timestamp; @@ -76,6 +76,7 @@ contract SnapShotExecutor is BorgAuthACL { function propose(address target, uint256 value, bytes calldata cdata, string memory description) external onlyOracle() returns (bytes32) { if(pendingProposalCount >= pendingProposalLimit) revert SnapShotExecutor_TooManyPendingProposals(); + if(target == address(0)) revert SnapShotExecutor_InvalidProposal(); bytes32 proposalId = keccak256(abi.encodePacked(target, value, cdata, description)); // Make sure the new proposal does not duplicate a previous one, otherwise we wouldn't be able to cancel both if (pendingProposals[proposalId].target != address(0)) revert SnapShotExecutor_ProposalAlreadyExists(); @@ -97,7 +98,7 @@ contract SnapShotExecutor is BorgAuthACL { function cancel(bytes32 proposalId) external { proposal memory p = pendingProposals[proposalId]; - if (p.executableAfter + proposalExpirySeconds > block.timestamp) revert SnapShotExecutor_NotExpired(); + if (p.executableAfter + cancelWaitingPeriod > block.timestamp) revert SnapShotExecutor_NotExpired(); if(p.target == address(0)) revert SnapShotExecutor_InvalidProposal(); pendingProposalCount--; delete pendingProposals[proposalId]; diff --git a/test/snapShotExecutor.t.sol b/test/snapShotExecutor.t.sol index f400f3f..f396699 100644 --- a/test/snapShotExecutor.t.sol +++ b/test/snapShotExecutor.t.sol @@ -48,7 +48,7 @@ contract SnapShotExecutorTest is Test { assertEq(snapShotExecutor.oracle(), oracle, "Unexpected oracle address"); assertEq(snapShotExecutor.pendingOracle(), address(0), "Unexpected pending oracle address"); assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); - assertEq(snapShotExecutor.proposalExpirySeconds(), 7 days, "Unexpected cancelPeriod"); + assertEq(snapShotExecutor.cancelWaitingPeriod(), 7 days, "Unexpected cancelWaitingPeriod"); assertEq(snapShotExecutor.pendingProposalCount(), 0, "Unexpected pendingProposalCount"); assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); assertEq(snapShotExecutor.oracleTtl(), 30 days, "Unexpected ORACLE_TTL"); @@ -159,6 +159,19 @@ contract SnapShotExecutorTest is Test { assertEq(description2, "Different descriptions", "Expect proposal2's description"); } + /// @dev Should not be able to propose invalid proposals + function test_RevertIf_ProposalIsInvalid() public { + // Proposing invalid proposal should fail + vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_InvalidProposal.selector)); + vm.prank(oracle); + snapShotExecutor.propose( + address(0), // invalid target + 0, // value + "", // cdata + "Invalid proposal" + ); + } + /// @dev Non-oracle should not be able to propose function test_RevertIf_NotOracleProposal() public { vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotAuthorized.selector)); @@ -197,7 +210,7 @@ contract SnapShotExecutorTest is Test { snapShotExecutor.cancel(proposalId); // After cancel period - skip(snapShotExecutor.proposalExpirySeconds()); + skip(snapShotExecutor.cancelWaitingPeriod()); // cancel() should succeed now diff --git a/test/yearnBorgAcceptance.t.sol b/test/yearnBorgAcceptance.t.sol index b2ebf57..f184467 100644 --- a/test/yearnBorgAcceptance.t.sol +++ b/test/yearnBorgAcceptance.t.sol @@ -111,7 +111,7 @@ contract YearnBorgAcceptanceTest is Test { function testSnapShotExecutorMeta() public view { assertEq(snapShotExecutor.oracle(), oracle, "Unexpected oracle"); assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod"); - assertEq(snapShotExecutor.proposalExpirySeconds(), 7 days, "Unexpected cancelPeriod"); + assertEq(snapShotExecutor.cancelWaitingPeriod(), 7 days, "Unexpected cancelWaitingPeriod"); assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit"); assertEq(snapShotExecutor.oracleTtl(), 14 days, "Unexpected ORACLE_TTL"); }