Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions contracts/orchestration/ZeroTreasuryHub.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import { Safe } from "@safe-global/safe-contracts/contracts/Safe.sol";
import { SafeProxyFactory } from "@safe-global/safe-contracts/contracts/proxies/SafeProxyFactory.sol";
import { SafeProxy } from "@safe-global/safe-contracts/contracts/proxies/SafeProxy.sol";


/**
* @title ZeroTreasuryHub v0.
* @dev A contract that serves as a factory for treasury deployments based on user configs.
* Also works as a registry to keep track of topologies of every treasury system deployed.
* TODO proto: how do we keep track of runtime changes of addresses and such once each treasury is modified by users ?? we shouldn't wire all treasury/dao/safe calls through this contract
*/
contract ZeroTreasuryHub {

// <--- Errors --->
error ZeroAddressPassed();
error TreasuryExistsForDomain(bytes32 domain);
error InvalidSafeParams();

// <--- Events --->
event SafeSystemSet(
address singleton,
address proxyFactory,
address fallbackHandler
);
event SafeTreasuryInstanceCreated(
bytes32 indexed domain,
address indexed safe
);

/**
* @dev All available modules to be installed for any treasury.
* Lists all predeployed preset contracts to be cloned.
*/
// TODO proto: change this to be a mapping where the key = keccak256(abi.encodePacked(namespace, ":", name, ":", versionString))
// e.g.: "OZ:Governor_V1:v1", "ZODIAC:Roles:v4", etc. think on this and make it better.
// this way we don't need to upgrade and we can easily add new modules over time.
// if doing so, we need to store all available keys in an array.
// Another way would be to store a struct with metadata on the end of the mapping instead of just plain address
// Also need to write a deterministic helper that can create and acquire these keys for apps and such. Readable names for modules could help in events.
struct ModuleCatalog {
address safe;
// address governor;
// address timelock;
}

/**
* @dev Addresses of components that make up a deployed treasury system.
*/
struct TreasuryComponents {
address safe;
// address governor;
// address timelock;
}

// TODO proto: figure these proper ones out for ZChain!
struct SafeSystem {
// Safe contract used
address singleton;
// Proxy factory used to deploy new safes
address proxyFactory;
// Fallback handler for the safe
address fallbackHandler;
}

SafeSystem public safeSystem;
ModuleCatalog public moduleCatalog;

/**
* @dev Mapping from ZNS domain hash to the addresses of components for each treasury.
*/
mapping(bytes32 => TreasuryComponents) public treasuries;

// TODO proto: should we add ZNS registry address here in state to verify domain ownership/existence on treasury creation?

// TODO proto: change this to initialize() if decided to make upgradeable
constructor(
address _safeSingleton,
address _safeProxyFactory,
address _safeFallbackHandler
) {
if (
_safeSingleton == address(0) ||
_safeProxyFactory == address(0) ||
_safeFallbackHandler == address(0)
) {
revert ZeroAddressPassed();
}

_setSafeSystem(
_safeSingleton,
_safeProxyFactory,
_safeFallbackHandler
);
}

// <--- Treasury Creation --->
// TODO proto: should these be composable contracts we can evolve over time? Also separate from registry??

function createSafe(
bytes32 domain,
address[] calldata owners,
uint256 threshold,
// TODO proto: make these better if possible. need to avoid errors and collisions. do we need it (adds complexity. including storage) ??
// this outline Safe's purpose/role in the Treasury, so we can deploy multiple Safes if needed
// Optional, only for additional Safes. pass "" for "main"
string memory purpose
) external returns (address) {
if (treasuries[domain].safe != address(0)) revert TreasuryExistsForDomain(domain);
// TODO proto: verify domain ownership!!!

// TODO proto: should we store length in a memory var? does it save gas?
if (owners.length == 0 || threshold == 0 || threshold > owners.length) revert InvalidSafeParams();

// TODO proto: figure out if we ever need to set to/data/payment stuff ?
bytes memory setup = abi.encodeWithSelector(
Safe.setup.selector,
owners,
threshold,
// to
address(0),
// data
bytes(""),
safeSystem.fallbackHandler,
// paymentToken
address(0),
// payment
0,
// paymentReceiver
payable(address(0))
);

SafeProxy safe = SafeProxyFactory(safeSystem.proxyFactory).createProxyWithNonce(
safeSystem.singleton,
setup,
_getSaltNonce(
domain,
purpose
)
);

address safeAddress = address(safe);

treasuries[domain] = TreasuryComponents({ safe: safeAddress });
// TODO proto: extend this event to inclide function parameters for Safe
emit SafeTreasuryInstanceCreated(domain, safeAddress);

return safeAddress;
}

function createDao() external {}

function createHybrid() external {}

// <--- Treasury Management --->

function addModule() external {}

function removeModule() external {}

// <--- Utilities --->
function _getSaltNonce(bytes32 domain, string memory purpose) internal pure returns (uint256) {
string memory actualPurpose = bytes(purpose).length == 0 ? "main" : purpose;

return uint256(keccak256(abi.encodePacked(domain, ":", actualPurpose)));
}

// <--- Setters --->

function setSafeSystem(
address _singleton,
address _proxyFactory,
address _fallbackHandler
) external {
// TODO proto: add access control!
_setSafeSystem(
_singleton,
_proxyFactory,
_fallbackHandler
);
}

function _setSafeSystem(
address _singleton,
address _proxyFactory,
address _fallbackHandler
) internal {
safeSystem = SafeSystem({
singleton: _singleton,
proxyFactory: _proxyFactory,
fallbackHandler: _fallbackHandler
});

emit SafeSystemSet(
_singleton,
_proxyFactory,
_fallbackHandler
);
}
}
9 changes: 9 additions & 0 deletions hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ const config: HardhatUserConfig = {
},
},
],
npmFilesToBuild: [
"@safe-global/safe-contracts/contracts/SafeL2.sol",
"@safe-global/safe-contracts/contracts/proxies/SafeProxyFactory.sol",
"@safe-global/safe-contracts/contracts/libraries/MultiSend.sol",
"@safe-global/safe-contracts/contracts/libraries/MultiSendCallOnly.sol",
"@safe-global/safe-contracts/contracts/libraries/SignMessageLib.sol",
"@safe-global/safe-contracts/contracts/libraries/CreateCall.sol",
"@safe-global/safe-contracts/contracts/handler/CompatibilityFallbackHandler.sol",
],
},
networks: {
hardhatMainnet: {
Expand Down
19 changes: 12 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,16 @@
"scripts": {
"typechain": "hardhat typechain",
"compile": "hardhat compile",
"lint-ts": "yarn eslint ./test/** ./src/**",
"lint": "yarn lint-ts",
"build": "yarn run clean && yarn run compile",
"postbuild": "yarn save-tag",
"clean": "hardhat clean",
"test": "hardhat test mocha"
},
"devDependencies": {
"@openzeppelin/contracts": "5.4.0",
"@openzeppelin/contracts-upgradeable": "5.4.0",
"@safe-global/safe-deployments": "1.37.46",
"@safe-global/safe-contracts": "1.4.1-2",
"@gnosis-guild/zodiac-core": "3.0.1",
"@gnosis-guild/zodiac": "4.2.1",
"@gnosis-guild/zodiac-core": "3.0.1",
"@nomicfoundation/hardhat-ethers": "^4.0.2",
"@nomicfoundation/hardhat-ethers-chai-matchers": "^3.0.0",
"@nomicfoundation/hardhat-ignition": "^3.0.0",
Expand All @@ -34,20 +32,27 @@
"@nomicfoundation/hardhat-viem": "^3.0.0",
"@nomicfoundation/hardhat-viem-assertions": "^3.0.2",
"@nomicfoundation/ignition-core": "^3.0.0",
"@safe-global/protocol-kit": "6.1.1",
"@openzeppelin/contracts": "5.4.0",
"@openzeppelin/contracts-upgradeable": "5.4.0",
"@openzeppelin/hardhat-upgrades": "^3.9.1",
"@safe-global/protocol-kit": "6.1.1",
"@safe-global/safe-contracts": "1.4.1-2",
"@safe-global/safe-deployments": "1.37.46",
"@types/chai": "^4.2.0",
"@types/chai-as-promised": "^8.0.1",
"@types/mocha": ">=10.0.10",
"@types/node": "^22.8.5",
"@wagmi/cli": "^2.7.1",
"@zero-tech/eslint-config-cpt": "^0.2.8",
"chai": "^5.1.2",
"ethers": "^6.14.0",
"forge-std": "foundry-rs/forge-std#v1.9.4",
"hardhat": "^3.0.7",
"mocha": "^11.7.4",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"viem": "^2.38.3"
"viem": "^2.38.3",
"eslint": "^8.37.0"
},
"type": "module",
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
Expand Down
63 changes: 63 additions & 0 deletions test/ZeroTreasuryHub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { type HardhatViemHelpers } from "@nomicfoundation/hardhat-viem/types";
import { keccak256 } from "viem";
import { type Contract, setupViem, type Wallet } from "./helpers/viem";


describe("ZeroTreasuryHub Smoke Tests", () => {
let viem : HardhatViemHelpers;

let admin : Wallet;
let user1 : Wallet;
let user2 : Wallet;
let user3 : Wallet;

// TODO proto: is this really the best way ?!
let theHub : Contract<"ZeroTreasuryHub">;
let safeSingleton : Contract<"SafeL2">;
let proxyFactory : Contract<"SafeProxyFactory">;
let fallbackHandler : Contract<"CompatibilityFallbackHandler">;

before(async () => {
({ viem, wallets: [ admin, user1, user2, user3 ] } = await setupViem());

// Deploy the Safe singleton (use 'Safe' instead for L1-style)
safeSingleton = await viem.deployContract("SafeL2");

// Proxy Factory
proxyFactory = await viem.deployContract("SafeProxyFactory");

// Libs & handler frequently used by Protocol Kit
const multiSend = await viem.deployContract("MultiSend");
const multiSendCallOnly = await viem.deployContract("MultiSendCallOnly");
const signMessageLib = await viem.deployContract("SignMessageLib");
const createCall = await viem.deployContract("CreateCall");
fallbackHandler = await viem.deployContract("CompatibilityFallbackHandler");

// Deploy the Hub
theHub = await viem.deployContract(
"ZeroTreasuryHub",
[
safeSingleton.address,
proxyFactory.address,
fallbackHandler.address,
]);
});

it("should deploy Safe from the hub", async () => {
await theHub.write.createSafe([
keccak256("0xmydomain"),
[user2.account.address, user3.account.address],
1n,
"main",
]);

const {
args: {
domain,
safe,
},
} = (await theHub.getEvents.SafeTreasuryInstanceCreated())[0];

console.log(`Domain: ${domain}. Safe ${safe}`);
});
});
31 changes: 31 additions & 0 deletions test/helpers/viem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import hre from "hardhat";
import type { Account, WalletClient } from "viem";
import type { ContractReturnType } from "@nomicfoundation/hardhat-viem/types";

// <-- TYPES -->
// exact helper shape returned by hardhat-viem
export type Viem = Awaited<ReturnType<typeof hre.network.connect>>["viem"];
// valid compiled contract names inferred from viem.deployContract
export type ContractName = Parameters<Viem["deployContract"]>[0];
// instance type returned by viem.deployContract for a given name
export type Contract<Name extends ContractName> = ContractReturnType<Name>;
export type Wallet = WalletClient & { account : Account; };

// <-- HELPERS -->
// ensure a WalletClient definitely has an account (narrow once, use everywhere)
export const withAccount = <T extends WalletClient>(w : T) : T & { account : Account; } => {
if (!w.account) throw new Error("WalletClient has no account");
return w as T & { account : Account; };
};

// init viem, connect to Hardhat, get `walletCount` amount of wallets, 4 by default
export const setupViem = async (walletCount = 4) => {
const { viem } = await hre.network.connect();
const all = (await viem.getWalletClients()).map(withAccount);
if (walletCount < 0) throw new Error("count must be >= 0");
if (walletCount > all.length) {
throw new Error(`Requested ${walletCount} wallets, but only ${all.length} are available`);
}
const wallets = all.slice(0, walletCount) as Array<Wallet>;
return { viem, wallets } as const;
};
9 changes: 6 additions & 3 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
{
"compilerOptions": {
"lib": ["es2023"],
"module": "node16",
"module": "ESNext",
"target": "es2022",
"strict": true,
"noEmit": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "node16",
"outDir": "dist"
"moduleResolution": "bundler",
"outDir": "dist",
"types": ["hardhat", "hardhat-viem", "viem"]
}
}
12 changes: 12 additions & 0 deletions wagmi.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineConfig } from '@wagmi/cli';
import { hardhat } from '@wagmi/cli/plugins';

// TODO proto: do we need this wagmi here at all ?!

export default defineConfig({
out: 'src/abi/generated.ts',
plugins: [hardhat({
project: ".",
commands: { build: 'yarn hardhat compile' },
})],
});
Loading