diff --git a/.gitignore b/.gitignore
index a71d847..512e4b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,9 @@
cache/
out/
-.env
+.env*
test-command.txt
broadcast/
.DS_Store
+.idea/
/broadcast_bk
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
diff --git a/README-yearnBorg.md b/README-yearnBorg.md
new file mode 100644
index 0000000..b27e619
--- /dev/null
+++ b/README-yearnBorg.md
@@ -0,0 +1,207 @@
+# Yearn BORG
+
+## BORG Architectures
+
+| 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
+ ychad[ychad.eth
6/9 signers]
+ ychadSigner[Signer EOA]
+ yearnDaoVoting[Yearn DAO Voting Snapshot]
+
+ oracleAddr[oracle]
+
+ borg{{Yearn BORG
BORG Core}}
+
+ subgraph implants["Implants (Modules)"]
+ ejectImplant{{Eject Implant}}
+ sudoImplant{{Sudo Implant}}
+ end
+
+ snapshotExecutor[Snapshot Executor]
+
+ borg -->|"guard"| ychad
+
+
+ %% implants -->|modules| ychad
+
+ ychadSigner -->|signer| ychad
+ ychadSigner -->|"selfEject()"| ejectImplant
+
+ 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
+
+ %% 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 yearn;
+ class ychadSigner yearn;
+ 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
+
+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
+
+### Co-approval Workflows
+
+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 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`).
+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 assign `YearnGovExecutor` as the new oracle of `Snapshot Executor`
+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 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 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 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 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 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 |
+|--------------------------------|------------|----------------------------------------------------------------------------------------------------------------------------|
+| `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
+
+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
+ ```
+
+3. Take notes of the output Safe TXs (for setting guard & adding modules), for examples:
+ ```
+ Safe TXs:
+ # 0
+ to: 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52
+ value: 0
+ data:
+ 0x610b59250000000000000000000000006faa027c062868424287af2faef3ddaca802bff7
+
+ # 1
+ to: 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52
+ value: 0
+ data:
+ 0x610b5925000000000000000000000000a21f6d7aa0b320b8669caef53f790b1a2ac838d7
+
+ # 2
+ to: 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52
+ value: 0
+ data:
+ 0xe19a9dd9000000000000000000000000bc19387f5b8ae73fad41cd2294f928a735c60534
+ ```
+4. Ask ychad to sign and execute the Safe TXs
+
+## 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 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/scripts/yearnBorg.s.sol b/scripts/yearnBorg.s.sol
new file mode 100644
index 0000000..ba74d6d
--- /dev/null
+++ b/scripts/yearnBorg.s.sol
@@ -0,0 +1,175 @@
+// 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 {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";
+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";
+
+contract PlaceholderFailSafeImplant {
+ uint256 public immutable IMPLANT_ID = 0;
+
+ error PlaceholderFailSafeImplant_UnexpectedTrigger();
+
+ function recoverSafeFunds() external pure {
+ revert PlaceholderFailSafeImplant_UnexpectedTrigger();
+ }
+}
+
+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
+ string borgIdentifier = "Yearn BORG";
+ borgCore.borgModes borgMode = borgCore.borgModes.blacklist;
+ uint256 borgType = 0x3; // devBORG
+
+ // Configs: SnapShowExecutor
+
+ uint256 snapShotWaitingPeriod = 3 days;
+ uint256 snapShotCancelWaitingPeriod = 7 days;
+ uint256 snapShotPendingProposalLimit = 3;
+ uint256 snapShotOracleTtl = 14 days;
+ address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00;
+
+ SafeTxHelper safeTxHelper;
+
+ borgCore core;
+ 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, sudoImplant, SnapShotExecutor, GnosisTransaction[] memory) {
+ return run(vm.envUint("DEPLOYER_PRIVATE_KEY"));
+ }
+
+ /// @dev For running in tests
+ 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));
+ console2.log(" BORG type:", borgType);
+ console2.log(" Safe Multisig:", address(ychadSafe));
+ console2.log(" Snapshot waiting period (secs.):", snapShotWaitingPeriod);
+ console2.log(" Snapshot cancel period (secs.):", snapShotCancelWaitingPeriod);
+ console2.log(" Snapshot pending proposal limit:", snapShotPendingProposalLimit);
+
+ address deployerAddress = vm.addr(deployerPrivateKey);
+ console2.log("Deployer:", deployerAddress);
+
+ safeTxHelper = new SafeTxHelper(
+ ychadSafe,
+ multiSendCallOnly,
+ deployerPrivateKey // No-op. We are not supposed to sign any Safe tx here
+ );
+
+ vm.startBroadcast(deployerPrivateKey);
+
+ // Core
+
+ 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
+ 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)");
+
+ // Create SnapShotExecutor
+
+ executorAuth = new BorgAuth();
+ snapShotExecutor = new SnapShotExecutor(executorAuth, address(oracle), snapShotWaitingPeriod, snapShotCancelWaitingPeriod, snapShotPendingProposalLimit, snapShotOracleTtl);
+
+ // Add modules
+
+ implantAuth = new BorgAuth();
+ eject = new ejectImplant(
+ implantAuth,
+ address(ychadSafe),
+ address(new PlaceholderFailSafeImplant()), // Placeholder because Yearn BORG does not use failSafe
+ true, // _allowManagement
+ true, // _allowEjection
+ false // _allowSelfEjectReduce
+ );
+ sudo = new sudoImplant(
+ implantAuth,
+ address(ychadSafe)
+ );
+
+ // Transfer core ownership to SnapShotExecutor
+ coreAuth.updateRole(address(snapShotExecutor), implantAuth.OWNER_ROLE());
+ coreAuth.zeroOwner();
+
+ // Transfer executor ownership to ychad.eth
+ executorAuth.updateRole(address(ychadSafe), executorAuth.OWNER_ROLE());
+ executorAuth.zeroOwner();
+
+ // Transfer eject implant ownership to SnapShotExecutor
+ implantAuth.updateRole(address(snapShotExecutor), implantAuth.OWNER_ROLE());
+ implantAuth.zeroOwner();
+
+ vm.stopBroadcast();
+
+ console2.log("Deployed addresses:");
+ console2.log(" Core: ", address(core));
+ console2.log(" Eject Implant: ", address(eject));
+ 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[](3);
+ safeTxs[0] = safeTxHelper.getAddModuleData(address(eject));
+ 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++) {
+ 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, sudo, snapShotExecutor, safeTxs);
+ }
+}
diff --git a/scripts/yearnBorgReplaceSnapShotExecutor.s.sol b/scripts/yearnBorgReplaceSnapShotExecutor.s.sol
new file mode 100644
index 0000000..380da4a
--- /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 snapShotCancelWaitingPeriod = oldSnapShotExecutor.cancelWaitingPeriod();
+ 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, snapShotCancelWaitingPeriod, 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/src/borgCore.sol b/src/borgCore.sol
index f3a0c5f..8da954d 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
@@ -188,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();
@@ -297,12 +310,19 @@ 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
+ //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();
}
@@ -641,12 +661,12 @@ 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 && borgMode == borgModes.whitelist)
return false;
-
+
if(methodConstraint.enabled && methodConstraint.paramOffsets.length == 0 && borgMode == borgModes.blacklist)
- return false;
+ return false;
// Iterate through the whitelist constraints for the method
for (uint256 i = 0; i < methodConstraint.paramOffsets.length;) {
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/src/implants/sudoImplant.sol b/src/implants/sudoImplant.sol
new file mode 100644
index 0000000..3c5cff2
--- /dev/null
+++ b/src/implants/sudoImplant.sol
@@ -0,0 +1,109 @@
+// 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();
+ error sudoImplant_SelfDisablingNotAllowed();
+
+ 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 (module == address(this)) revert sudoImplant_SelfDisablingNotAllowed();
+ 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/src/libs/governance/snapShotExecutor.sol b/src/libs/governance/snapShotExecutor.sol
new file mode 100644
index 0000000..64e9412
--- /dev/null
+++ b/src/libs/governance/snapShotExecutor.sol
@@ -0,0 +1,124 @@
+// 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 oracle;
+ uint256 public oracleTtl;
+ address public pendingOracle;
+ uint256 public pendingOracleTtl;
+ 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;
+
+ struct proposal {
+ address target;
+ uint256 value;
+ bytes cdata;
+ string description;
+ uint256 executableAfter;
+ }
+
+ error SnapShotExecutor_NotAuthorized();
+ error SnapShotExecutor_InvalidProposal();
+ error SnapShotExecutor_ProposalAlreadyExists();
+ error SnapShotExecutor_WaitingPeriod();
+ error SnapShotExecutor_NotExpired();
+ error SnapShotExecutor_InvalidParams();
+ error SnapShotExecutor_TooManyPendingProposals();
+ error SnapShotExecutor_OracleNotDead();
+
+ //events
+ 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;
+
+ /// @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 == 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 + oracleTtl) revert SnapShotExecutor_OracleNotDead();
+ _;
+ }
+
+ constructor(BorgAuth _auth, address _oracle, uint256 _waitingPeriod, uint256 _cancelWaitingPeriod, uint256 _pendingProposals, uint256 _oracleTtl) BorgAuthACL(_auth) {
+ oracle = _oracle;
+ if(_waitingPeriod < 1 minutes) revert SnapShotExecutor_InvalidParams();
+ waitingPeriod = _waitingPeriod;
+ if(_cancelWaitingPeriod < 1 minutes) revert SnapShotExecutor_InvalidParams();
+ cancelWaitingPeriod = _cancelWaitingPeriod;
+ pendingProposalLimit = _pendingProposals;
+ oracleTtl = _oracleTtl;
+ lastOraclePingTimestamp = block.timestamp;
+ }
+
+ 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();
+ 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.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.executableAfter, success);
+ pendingProposalCount--;
+ delete pendingProposals[proposalId];
+ }
+
+ function cancel(bytes32 proposalId) external {
+ proposal memory p = pendingProposals[proposalId];
+ if (p.executableAfter + cancelWaitingPeriod > 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.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().
+ /// The new oracle accepts the transfer by calling any other onlyOracle() function
+ 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, uint256 newOracleTtl) external onlyOwner() onlyDeadOracle() {
+ pendingOracle = newOracle;
+ pendingOracleTtl = newOracleTtl;
+ }
+
+ function ping() external onlyOracle() {}
+}
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/libraries/safe.t.sol b/test/libraries/safe.t.sol
index 7d987bd..5f2ff0c 100644
--- a/test/libraries/safe.t.sol
+++ b/test/libraries/safe.t.sol
@@ -9,8 +9,12 @@ 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;
+
function execTransaction(
address to,
uint256 value,
diff --git a/test/libraries/safeTxHelper.sol b/test/libraries/safeTxHelper.sol
new file mode 100644
index 0000000..f1ace13
--- /dev/null
+++ b/test/libraries/safeTxHelper.sol
@@ -0,0 +1,425 @@
+
+// SPDX-License-Identifier: AGPL-3.0-only
+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 {
+ IGnosisSafe safe;
+ IMultiSendCallOnly multiSendCallOnly;
+ uint256 signerPrivateKey;
+ address signer;
+
+ constructor(IGnosisSafe _safe, IMultiSendCallOnly _multiSendCallOnly, uint256 _signerPrivateKey) {
+ safe = _safe;
+ multiSendCallOnly = _multiSendCallOnly;
+ signerPrivateKey = _signerPrivateKey;
+ signer = vm.addr(signerPrivateKey);
+ }
+
+ function createTestBatch(address core) public view returns (GnosisTransaction[] memory) {
+ GnosisTransaction[] memory batch = new GnosisTransaction[](2);
+ address guyToApprove = address(0xdeadbabe);
+ address token = 0xF17A3fE536F8F7847F1385ec1bC967b2Ca9caE8D;
+
+ // set guard
+ bytes4 funcSig = bytes4(
+ keccak256("setGuard(address)")
+ );
+
+ bytes memory cdata = abi.encodeWithSelector(
+ funcSig,
+ core
+ );
+
+ batch[0] = GnosisTransaction({to: address(safe), value: 0, data: cdata});
+
+ 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 funcSig = bytes4(
+ keccak256("enableModule(address)")
+ );
+
+ bytes memory cdata = abi.encodeWithSelector(
+ funcSig,
+ to
+ );
+ GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: cdata});
+ 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)")
+ );
+
+ bytes memory cdata = abi.encodeWithSelector(
+ funcSig,
+ core
+ );
+ GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: cdata});
+ 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 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 pure 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 pure 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 pure returns (GnosisTransaction memory) {
+ bytes4 funcSig = bytes4(
+ keccak256("addContract(address,uint256)")
+ );
+
+ bytes memory cdata = abi.encodeWithSelector(
+ funcSig,
+ address(allow),
+ amount
+ );
+ GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: cdata});
+ return txData;
+ }
+
+ function getAddEjectModuleData(address to) public view returns (GnosisTransaction memory) {
+ bytes4 funcSig = bytes4(
+ keccak256("enableModule(address)")
+ );
+
+ bytes memory cdata = abi.encodeWithSelector(
+ funcSig,
+ to
+ );
+ GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: cdata});
+ return txData;
+ }
+
+ function getAddOwnerData(address toAdd) public view returns (GnosisTransaction memory) {
+ bytes4 funcSig = bytes4(
+ keccak256("addOwnerWithThreshold(address,uint256)")
+ );
+
+ bytes memory cdata = abi.encodeWithSelector(
+ funcSig,
+ toAdd,
+ 1
+ );
+ GnosisTransaction memory txData = GnosisTransaction({to: address(safe), value: 0, data: cdata});
+ 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 _contract, uint256 amount) public pure returns (GnosisTransaction memory) {
+ bytes4 addRecipientMethod = bytes4(
+ keccak256("addRecipient(address,uint256)")
+ );
+
+ bytes memory recData = abi.encodeWithSelector(
+ addRecipientMethod,
+ address(_contract),
+ amount
+ );
+ GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: recData});
+ return txData;
+ }
+
+ function getRemoveRecepientGuardData(address to, address _contract) public pure returns (GnosisTransaction memory) {
+ bytes4 removeRecepientMethod = bytes4(
+ keccak256("removeRecepient(address)")
+ );
+
+ bytes memory recData = abi.encodeWithSelector(
+ removeRecepientMethod,
+ address(_contract)
+ );
+ GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: recData});
+ return txData;
+ }
+
+ function getRemoveContractGuardData(address to, address _contract) public pure returns (GnosisTransaction memory) {
+ bytes4 removeContractMethod = bytes4(
+ keccak256("removeContract(address)")
+ );
+
+ bytes memory recData = abi.encodeWithSelector(
+ removeContractMethod,
+ address(_contract)
+ );
+ GnosisTransaction memory txData = GnosisTransaction({to: to, value: 0, data: recData});
+ return txData;
+ }
+
+ function getRemovePolicyMethodGuardData(address to, address _contract, string memory methodSignature) public pure returns (GnosisTransaction memory) {
+ bytes memory cdata = abi.encodeWithSelector(
+ borgCore.removePolicyMethod.selector,
+ _contract,
+ methodSignature
+ );
+ return GnosisTransaction({to: to, value: 0, data: cdata});
+ }
+
+ 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,
+ methodSignature,
+ byteOffset
+ );
+ return GnosisTransaction({to: to, value: 0, data: cdata});
+ }
+
+ function getCreateGrantData(address opGrant, address token, address rec, uint256 amount) public pure returns (GnosisTransaction memory) {
+ bytes4 funcSig = bytes4(
+ keccak256("createDirectGrant(address,address,uint256)")
+ );
+
+ bytes memory cdata = abi.encodeWithSelector(
+ funcSig,
+ token,
+ rec,
+ amount
+ );
+ GnosisTransaction memory txData = GnosisTransaction({to: opGrant, value: 0, data: cdata});
+ return txData;
+ }
+
+ function getCreateBasicGrantData(address opGrant, address token, address rec, uint256 amount) public view returns (GnosisTransaction memory) {
+ // Configure the metavest details
+ 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 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 cdata = abi.encodeWithSelector(
+ funcSig,
+ 0,
+ rec,
+ _metavestDetails,
+ emptyMilestones,
+ 0,
+ address(0),
+ 0,
+ 0
+ );
+ GnosisTransaction memory txData = GnosisTransaction({to: opGrant, value: 0, data: cdata});
+ 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,
+ 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 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, "");
+ }
+
+ 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/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/snapShotExecutor.t.sol b/test/snapShotExecutor.t.sol
new file mode 100644
index 0000000..f396699
--- /dev/null
+++ b/test/snapShotExecutor.t.sol
@@ -0,0 +1,378 @@
+// 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 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();
+ snapShotExecutor = new SnapShotExecutor(
+ auth,
+ oracle,
+ 3 days, // waitingPeriod
+ 7 days, // cancelPeriod
+ 3, // pendingProposalLimit
+ oracleTtl
+ );
+
+ // 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.pendingOracle(), address(0), "Unexpected pending oracle address");
+ assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod");
+ 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");
+ assertEq(snapShotExecutor.lastOraclePingTimestamp(), block.timestamp, "Unexpected lastOraclePingTimestamp");
+ }
+
+ /// @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 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 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));
+ 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_NotExpired.selector));
+ snapShotExecutor.cancel(proposalId);
+
+ // After waiting period
+ skip(snapShotExecutor.waitingPeriod());
+
+ // cancel() should fail within cancel period
+
+ vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_NotExpired.selector));
+ snapShotExecutor.cancel(proposalId);
+
+ // After cancel period
+ skip(snapShotExecutor.cancelWaitingPeriod());
+
+ // 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 1"
+ );
+ snapShotExecutor.propose(
+ address(alice), // target
+ 0, // value
+ "", // cdata
+ "Arbitrary instruction 2"
+ );
+ snapShotExecutor.propose(
+ address(alice), // target
+ 0, // value
+ "", // cdata
+ "Arbitrary instruction 3"
+ );
+
+ // Should failed due to the limit
+
+ vm.expectRevert(abi.encodeWithSelector(SnapShotExecutor.SnapShotExecutor_TooManyPendingProposals.selector));
+ snapShotExecutor.propose(
+ address(alice), // target
+ 0, // value
+ "", // cdata
+ "Arbitrary instruction 4"
+ );
+
+ 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 Should be able to transfer oracle through a proposal
+ function testTransferOracle() public {
+ // Propose & execute the transfer
+ vm.prank(oracle);
+ bytes32 proposalId = snapShotExecutor.propose(
+ address(snapShotExecutor), // target
+ 0 ether, // value
+ abi.encodeWithSelector(
+ snapShotExecutor.transferOracle.selector,
+ address(newOracle),
+ newOracleTtl
+ ), // 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);
+ 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);
+ 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, newOracleTtl);
+ }
+
+ /// @dev Owner should be able to replace dead oracle
+ function testTransferExpiredOracle() public {
+ // Let the old oracle expire, then transfer it
+ skip(snapShotExecutor.oracleTtl());
+ vm.prank(owner);
+ 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);
+ snapShotExecutor.ping();
+ }
+
+ /// @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.transferExpiredOracle(newOracle, newOracleTtl);
+ }
+}
diff --git a/test/sudoImplant.t.sol b/test/sudoImplant.t.sol
new file mode 100644
index 0000000..7d3b3b1
--- /dev/null
+++ b/test/sudoImplant.t.sol
@@ -0,0 +1,230 @@
+// 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;
+ address anotherImplant;
+ 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));
+ anotherImplant = address(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.getAddModuleData(address(anotherImplant)));
+ 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(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(anotherImplant));
+
+ // Function condition not met
+ vm.expectRevert(abi.encodeWithSelector(ConditionManager.ConditionManager_ConditionNotMet.selector));
+ vm.prank(owner);
+ sudo.disableModule(address(anotherImplant));
+
+ // 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(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(anotherImplant));
+ vm.prank(owner);
+ sudo.disableModule(address(anotherImplant));
+
+ assertFalse(safe.isModuleEnabled(address(anotherImplant)), "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));
+ }
+}
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/yearnBorg.t.sol b/test/yearnBorg.t.sol
new file mode 100644
index 0000000..c6784eb
--- /dev/null
+++ b/test/yearnBorg.t.sol
@@ -0,0 +1,27 @@
+// 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 {YearnBorgDeployScript} from "../scripts/yearnBorg.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 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, snapShotExecutor, safeTxs) = (new YearnBorgDeployScript()).run(testSignerPrivateKey);
+
+ // Simulate ychad.eth executing the provided Safe TXs (set guard & add module)
+ safeTxHelper.executeBatch(safeTxs);
+ }
+
+ // 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..f184467
--- /dev/null
+++ b/test/yearnBorgAcceptance.t.sol
@@ -0,0 +1,506 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+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 {SnapShotExecutor} from "../src/libs/governance/snapShotExecutor.sol";
+import {SafeTxHelper} from "./libraries/safeTxHelper.sol";
+import {IGnosisSafe, GnosisTransaction, IMultiSendCallOnly} from "../test/libraries/safe.t.sol";
+
+/// @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);
+ }
+}
+
+contract YearnBorgAcceptanceTest is Test {
+ 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);
+ address multiSend = 0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761;
+
+ IGnosisSafe ychadSafe = IGnosisSafe(0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52); // ychad.eth
+
+ address oracle = 0xf00c0dE09574805389743391ada2A0259D6b7a00;
+
+ address deployer = address(0); // TODO Update after deployment
+
+ uint256 testSignerPrivateKey = 1;
+ address testSigner = vm.addr(testSignerPrivateKey);
+
+ address alice = vm.addr(2);
+
+ SafeTxHelper safeTxHelper = new SafeTxHelper(ychadSafe, multiSendCallOnly, testSignerPrivateKey);
+
+ 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.
+ /// Furthermore, one could override it for dynamic integration tests.
+ function setUp() public virtual {
+ // Assume Ethereum mainnet fork after block 22268905
+
+ 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
+ }
+
+ /// @dev BORG Core metadata should meet specs
+ 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");
+ }
+
+ /// @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 implantAuth = eject.AUTH();
+
+ 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
+ {
+ 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));
+ 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 implant auth roles
+ {
+ uint256 ownerRole = implantAuth.OWNER_ROLE();
+ implantAuth.onlyRole(ownerRole, address(snapShotExecutor));
+ // Verify not owners
+ vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(ychadSafe)));
+ implantAuth.onlyRole(ownerRole, address(ychadSafe));
+ vm.expectRevert(abi.encodeWithSelector(BorgAuth.BorgAuth_NotAuthorized.selector, ownerRole, address(deployer)));
+ implantAuth.onlyRole(ownerRole, address(deployer));
+ }
+ }
+
+ function testSnapShotExecutorMeta() public view {
+ assertEq(snapShotExecutor.oracle(), oracle, "Unexpected oracle");
+ assertEq(snapShotExecutor.waitingPeriod(), 3 days, "Unexpected waitingPeriod");
+ assertEq(snapShotExecutor.cancelWaitingPeriod(), 7 days, "Unexpected cancelWaitingPeriod");
+ assertEq(snapShotExecutor.pendingProposalLimit(), 3, "Unexpected pendingProposalLimit");
+ assertEq(snapShotExecutor.oracleTtl(), 14 days, "Unexpected ORACLE_TTL");
+ }
+
+ 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");
+ assertFalse(eject.ALLOW_AUTH_SELF_EJECT_REDUCE(), "Auth self-eject with reduce should not be allowed");
+ }
+
+ /// @dev Safe normal operations should be unrestricted
+ function testSafeOpUnrestricted() public {
+ {
+ 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(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);
+ }
+ }
+
+ /// @dev Safe signers should be able to self-resign
+ function testSelfEject() public {
+ vm.assertTrue(ychadSafe.isOwner(testSigner), "Should be Safe signer");
+
+ // 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);
+
+ vm.assertFalse(ychadSafe.isOwner(testSigner), "Should not be Safe signer");
+ vm.assertEq(ychadSafe.getThreshold(), thresholdBefore, "Threshold should not change");
+ }
+
+ /// @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");
+
+ 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"
+ );
+
+ // 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: abi.encodeWithSelector(
+ snapShotExecutor.execute.selector,
+ proposalId
+ )
+ }));
+
+ 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"
+ );
+
+ // 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: abi.encodeWithSelector(
+ snapShotExecutor.execute.selector,
+ proposalId
+ )
+ }));
+
+ 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"
+ );
+
+ // 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: 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 {
+ MockYearnGovExecutor yearnGovExecutor = new MockYearnGovExecutor();
+
+ 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
+ {
+ // SnapShotExecutor to assign YearnGovExecutor as the new oracle
+ vm.prank(oracle);
+ bytes32 proposalIdTransferOracle = snapShotExecutor.propose(
+ address(snapShotExecutor), // target
+ 0, // value
+ abi.encodeWithSelector(
+ snapShotExecutor.transferOracle.selector,
+ address(yearnGovExecutor),
+ 1095 days // 3 years
+ ), // cdata
+ "Set yearnGovExecutor as new oracle"
+ );
+
+ // 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,
+ proposalIdTransferOracle
+ )
+ }));
+
+ // 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
+ {
+ vm.assertFalse(ychadSafe.isOwner(alice), "Should not be Safe signer");
+
+ // Assume the voting passed and `yearnGovExecutor` proposes to `snapShotExecutor`
+ bytes32 proposalId = yearnGovExecutor.proposeToSnapshotExecutor(
+ snapShotExecutor,
+ address(eject), // target
+ 0, //value
+ abi.encodeWithSelector(
+ bytes4(keccak256("addOwner(address)")),
+ alice // newOwner
+ ), // cdata
+ "Add Alice as new signer"
+ );
+
+ // After waiting period
+ skip(snapShotExecutor.waitingPeriod());
+
+ // Safe should be able to execute it and add Alice as new signer
+ safeTxHelper.executeSingle(GnosisTransaction({
+ to: address(snapShotExecutor),
+ value: 0,
+ data: abi.encodeWithSelector(
+ snapShotExecutor.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));
+ snapShotExecutor.propose(
+ address(eject), // target
+ 0, // value
+ "", // cdata
+ "Arbitrary instruction"
+ );
+ }
+
+ /// @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());
+ }
+
+ /// @dev Safe should not be able to unilaterally perform restricted admin operations without DAO approval
+ function test_RevertIf_RestrictedAdminOperations() public {
+ // The test cases are exhaustive
+
+ // 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
+ );
+ }
+
+ /// @dev Safe should be able to replace a dead oracle
+ function testTransferExpiredOracle() public {
+ // Let the old oracle expire, then transfer it
+ skip(snapShotExecutor.oracleTtl());
+
+ // 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
+ 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");
+ }
+
+ /// @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)
+ );
+ }
+
+ /// @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)
+ );
+ }
+}
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
+}