feat: enable AVS owner workflow (#332)

# Enable AVS owner workflow

Until now, the deployer of the contracts and the owner of the deployed
contracts where the same account. Even if we allowed a different owner
to be specified, we were using the same. For this reason, a private key
was required, so after the deployment we could execute owned
transactions needed for the CLI.

In this PR we:
- Add a mechanism to the CLI to specify a different owner account other
than the deployer via `--avs-owner-address`
- Add CLI flags `--avs-owner-key` and`--execute-owner-transactions` so
account ownership vs. immediate execution is explicit and deferred. If
both previous parameters are provided, the CLI will execute the
transactions using the private key provided.
- Allow DataHaven AVS deploy scripts to toggle owner-call execution via
an env flag `TX_EXECUTION`
- Add documentation on how the new parameters work in `test/README.md`
and `test/docs/deployment.md`.

---------

Co-authored-by: Steve Degosserie <723552+stiiifff@users.noreply.github.com>
Co-authored-by: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com>
This commit is contained in:
Gonza Montiel 2025-12-10 17:38:21 +01:00 committed by GitHub
parent ef3ddaaf69
commit cb81164f22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 336 additions and 36 deletions

View file

@ -79,6 +79,8 @@ abstract contract DeployBase is Script, DeployParams, Accounts {
EigenPodManager public eigenPodManager;
IETHPOSDeposit public ethPOSDeposit;
bool internal _txExecutionEnabled;
function _logProgress() internal {
deploymentStep++;
Logging.logProgress(deploymentStep, totalSteps);
@ -95,6 +97,8 @@ abstract contract DeployBase is Script, DeployParams, Accounts {
* @notice Shared deployment flow for both local and testnet deployments
*/
function _executeSharedDeployment() internal {
_txExecutionEnabled = vm.envOr("TX_EXECUTION", true);
string memory networkName = _getNetworkName();
string memory deploymentMode = _getDeploymentMode();
@ -102,6 +106,9 @@ abstract contract DeployBase is Script, DeployParams, Accounts {
console.log("| Network: %s", networkName);
console.log("| Mode: %s", deploymentMode);
console.log("| Timestamp: %s", vm.toString(block.timestamp));
if (!_txExecutionEnabled) {
Logging.logInfo("TX EXECUTION DISABLED: owner transactions must be executed manually");
}
Logging.logFooter();
// Load configurations
@ -137,9 +144,13 @@ abstract contract DeployBase is Script, DeployParams, Accounts {
// Final configuration (same for both modes)
Logging.logHeader("FINAL CONFIGURATION");
vm.broadcast(_avsOwnerPrivateKey);
serviceManager.setRewardsAgent(0, address(rewardsAgentAddress));
Logging.logStep("Agent set in RewardsRegistry");
if (_txExecutionEnabled) {
vm.broadcast(_avsOwnerPrivateKey);
serviceManager.setRewardsAgent(0, address(rewardsAgentAddress));
Logging.logStep("Agent set in RewardsRegistry");
} else {
Logging.logInfo("TX EXECUTION DISABLED: call setRewardsAgent via multisig");
}
Logging.logContractDeployed("Agent Address", rewardsAgentAddress);
Logging.logFooter();
_logProgress();
@ -301,20 +312,32 @@ abstract contract DeployBase is Script, DeployParams, Accounts {
Logging.logSection("Configuring Service Manager");
// Register the DataHaven service in the AllocationManager
vm.broadcast(_avsOwnerPrivateKey);
serviceManager.updateAVSMetadataURI("");
Logging.logStep("DataHaven service registered in AllocationManager");
if (_txExecutionEnabled) {
vm.broadcast(_avsOwnerPrivateKey);
serviceManager.updateAVSMetadataURI("");
Logging.logStep("DataHaven service registered in AllocationManager");
} else {
Logging.logInfo("TX EXECUTION DISABLED: call updateAVSMetadataURI via multisig");
}
// Set the slasher in the ServiceManager
vm.broadcast(_avsOwnerPrivateKey);
serviceManager.setSlasher(vetoableSlasher);
Logging.logStep("Slasher set in ServiceManager");
if (_txExecutionEnabled) {
vm.broadcast(_avsOwnerPrivateKey);
serviceManager.setSlasher(vetoableSlasher);
Logging.logStep("Slasher set in ServiceManager");
} else {
Logging.logInfo("TX EXECUTION DISABLED: call setSlasher via multisig");
}
// Set the RewardsRegistry in the ServiceManager
uint32 validatorsSetId = serviceManager.VALIDATORS_SET_ID();
vm.broadcast(_avsOwnerPrivateKey);
serviceManager.setRewardsRegistry(validatorsSetId, rewardsRegistry);
Logging.logStep("RewardsRegistry set in ServiceManager");
if (_txExecutionEnabled) {
vm.broadcast(_avsOwnerPrivateKey);
serviceManager.setRewardsRegistry(validatorsSetId, rewardsRegistry);
Logging.logStep("RewardsRegistry set in ServiceManager");
} else {
Logging.logInfo("TX EXECUTION DISABLED: call setRewardsRegistry via multisig");
}
return (
serviceManager,

View file

@ -48,8 +48,12 @@ contract DeployParams is Script, Config {
);
string memory configJson = vm.readFile(configPath);
// Load from JSON config or use environment variables as fallback
config.avsOwner = vm.parseJsonAddress(configJson, ".avs.avsOwner");
address avsOwnerOverride = vm.envOr("AVS_OWNER_ADDRESS", address(0));
if (avsOwnerOverride != address(0)) {
config.avsOwner = avsOwnerOverride;
} else {
config.avsOwner = vm.parseJsonAddress(configJson, ".avs.avsOwner");
}
config.rewardsInitiator = vm.parseJsonAddress(configJson, ".avs.rewardsInitiator");
config.vetoCommitteeMember = vm.parseJsonAddress(configJson, ".avs.vetoCommitteeMember");
config.vetoWindowBlocks = uint32(vm.parseJsonUint(configJson, ".avs.vetoWindowBlocks"));

View file

@ -51,6 +51,12 @@ contract DeployTestnet is DeployBase {
_validateNetwork(networkName);
totalSteps = 4;
address avsOwnerEnv = vm.envOr("AVS_OWNER_ADDRESS", address(0));
require(
avsOwnerEnv != address(0),
"AVS_OWNER_ADDRESS env variable required for testnet deployments"
);
_executeSharedDeployment();
}

View file

@ -57,6 +57,38 @@ bun test suites/some-test.test.ts
NOTES: Adding the environment variable `INJECT_CONTRACTS=true` will inject the contracts when starting the tests to speed up setup.
## AVS Owner Parameters & Tx Execution
Our deployment tooling now separates “who becomes the ServiceManager owner” from “who executes the privileged post-deployment calls.” The knobs are:
| Flag / Env | Purpose | Default |
| --- | --- | --- |
| `--avs-owner-address` / `AVS_OWNER_ADDRESS` | Address set as `avsOwner` in the ServiceManager initializer. **Required** when targeting testnet/mainnet (Safe multisig). Falls back to `config/<network>.json` only for local/anvil. | Local uses config value; non-local must supply. |
| `--avs-owner-key` / `AVS_OWNER_PRIVATE_KEY` | Private key used to sign owner-only calls the script performs (e.g. `setSlasher`). Only read when tx execution is enabled. | Anvil default key if unset. |
| `--execute-owner-transactions` (CLI) / `TX_EXECUTION=true|false` (env) | Controls whether the script actually broadcasts owner calls. When disabled, we skip sending transactions and instead print ABI-encoded payloads that a Safe can execute. | Enabled automatically for local flows and CI helpers; disabled by default on `hoodi/holesky/mainnet`. |
### Examples
```bash
# Local/anvil developer run (executes owner txs immediately)
bun cli contracts deploy --chain anvil --avs-owner-key $LOCAL_OWNER_KEY --execute-owner-transactions
# Testnet deployment where ownership is a Safe (prints multisig payloads)
AVS_OWNER_ADDRESS=0x... bun cli contracts deploy --chain hoodi
# Force execution during launch/deploy automation (already the default)
bun cli launch --deploy-contracts --execute-owner-transactions
```
When tx execution is off, the CLI prints a list of `{to, data, value}` objects for:
1. `updateAVSMetadataURI("")`
2. `setSlasher(vetoableSlasher)`
3. `setRewardsRegistry(validatorsSetId, rewardsRegistry)`
4. `setRewardsAgent(validatorsSetId, rewardsAgent)`
Copy each object into your safe transaction builder (or preferred multisig workflow) to finalize the deployment.
## Generating Ethereum state
To avoid deploying contracts everytime for each tests, you can generate and then inject state in the Ethereum client.

View file

@ -15,6 +15,8 @@ export const contractsDeploy = async (options: any, command: any) => {
printHeader(`Deploying DataHaven Contracts to ${chain}`);
const txExecutionOverride = options.executeOwnerTransactions ? true : undefined;
try {
logger.info("🚀 Starting deployment...");
logger.info(`📡 Using chain: ${chain}`);
@ -25,7 +27,10 @@ export const contractsDeploy = async (options: any, command: any) => {
await deployContracts({
chain: chain,
rpcUrl: options.rpcUrl,
privateKey: options.privateKey
privateKey: options.privateKey,
avsOwnerKey: options.avsOwnerKey,
avsOwnerAddress: options.avsOwnerAddress,
txExecution: txExecutionOverride
});
printDivider();

View file

@ -1,5 +1,5 @@
import { logger, parseDeploymentsFile, printDivider } from "utils";
import { createPublicClient, createWalletClient, http } from "viem";
import { createPublicClient, createWalletClient, encodeFunctionData, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { getChainDeploymentParams } from "../../../configs/contracts/config";
import { dataHavenServiceManagerAbi } from "../../../contract-bindings/generated";
@ -7,12 +7,19 @@ import { dataHavenServiceManagerAbi } from "../../../contract-bindings/generated
/**
* Updates the AVS metadata URI for the DataHaven Service Manager
*/
export const updateAVSMetadataURI = async (chain: string, uri: string) => {
export const updateAVSMetadataURI = async (
chain: string,
uri: string,
opts: { execute?: boolean; avsOwnerKey?: string } = {}
) => {
try {
// Load environment variables
const avsOwnerPrivateKey = process.env.AVS_OWNER_PRIVATE_KEY;
if (!avsOwnerPrivateKey) {
throw new Error("AVS_OWNER_PRIVATE_KEY environment variable is required");
const execute = opts.execute ?? false;
const avsOwnerPrivateKey = normalizePrivateKey(
opts.avsOwnerKey || process.env.AVS_OWNER_PRIVATE_KEY
);
if (execute && !avsOwnerPrivateKey) {
throw new Error("AVS owner private key is required to execute this transaction");
}
// Get chain configuration
@ -22,6 +29,31 @@ export const updateAVSMetadataURI = async (chain: string, uri: string) => {
logger.info(`RPC URL: ${deploymentParams.rpcUrl}`);
logger.info(`New URI: ${uri}`);
const deployments = await parseDeploymentsFile(chain);
const serviceManagerAddress = deployments.ServiceManager;
if (!serviceManagerAddress) {
throw new Error("ServiceManager address not found in deployments file");
}
const calldata = encodeFunctionData({
abi: dataHavenServiceManagerAbi,
functionName: "updateAVSMetadataURI",
args: [uri]
});
if (!execute) {
logger.info("🔐 Tx execution disabled: submit the following transaction via your multisig");
const payload = {
to: serviceManagerAddress,
value: "0",
data: calldata
};
logger.info(JSON.stringify(payload, null, 2));
printDivider();
return payload;
}
// Create wallet client for the AVS owner
const account = privateKeyToAccount(avsOwnerPrivateKey as `0x${string}`);
const walletClient = createWalletClient({
@ -35,10 +67,6 @@ export const updateAVSMetadataURI = async (chain: string, uri: string) => {
});
logger.info(`Using account: ${account.address}`);
const deployments = await parseDeploymentsFile(chain);
const serviceManagerAddress = deployments.ServiceManager;
logger.info(`ServiceManager contract address: ${serviceManagerAddress}`);
// Call the updateAVSMetadataURI function
@ -73,3 +101,10 @@ export const updateAVSMetadataURI = async (chain: string, uri: string) => {
throw error;
}
};
const normalizePrivateKey = (key?: string): `0x${string}` | undefined => {
if (!key) {
return undefined;
}
return (key.startsWith("0x") ? key : `0x${key}`) as `0x${string}`;
};

View file

@ -44,7 +44,11 @@ export const deployContracts = async (options: DeployContractsOptions) => {
// Construct and execute deployment
const deployCommand = constructDeployCommand(options);
await executeDeployment(deployCommand, options.parameterCollection, options.chain);
const env: Record<string, string> = { TX_EXECUTION: "true" };
if (options.privateKey) {
env.DEPLOYER_PRIVATE_KEY = options.privateKey;
}
await executeDeployment(deployCommand, options.parameterCollection, options.chain, env);
printDivider();
};

View file

@ -51,7 +51,8 @@ export const deployContracts = async (options: DeployContractsOptions): Promise<
rpcUrl: options.rpcUrl,
verified: options.verified,
blockscoutBackendUrl: options.blockscoutBackendUrl,
parameterCollection: options.parameterCollection
parameterCollection: options.parameterCollection,
txExecution: true
});
printDivider();

View file

@ -232,6 +232,12 @@ contractsCommand
"Private key for deployment",
process.env.DEPLOYER_PRIVATE_KEY || ""
)
.option("--avs-owner-address <value>", "Address to set as AVS owner (required for non-local)")
.option("--avs-owner-key <value>", "Private key for the AVS owner (hex string)")
.option(
"--execute-owner-transactions",
"Execute AVS owner transactions immediately (tx execution on)"
)
.option("--skip-verification", "Skip contract verification", false)
.hook("preAction", contractsPreActionHook)
.action(contractsDeploy);
@ -254,6 +260,8 @@ contractsCommand
.option("--uri <value>", "New metadata URI (required)")
.option("--reset", "Use if you want to reset the metadata URI")
.option("--rpc-url <value>", "Chain RPC URL (optional, defaults based on chain)")
.option("--avs-owner-key <value>", "Private key for the AVS owner (hex string)")
.option("--execute", "Execute transaction immediately instead of emitting calldata", false)
.action(async (options: any, command: any) => {
// Try to get chain from options or command
let chain = options.chain;
@ -272,7 +280,10 @@ contractsCommand
if (!chain) {
throw new Error("--chain parameter is required");
}
await updateAVSMetadataURI(chain, options.uri);
await updateAVSMetadataURI(chain, options.uri, {
execute: options.execute,
avsOwnerKey: options.avsOwnerKey
});
});
// Default Contracts command (runs check)

View file

@ -318,6 +318,27 @@ If everything went well, you will see something like:
bun cli deploy --docker-username=<username> --docker-password=<pass> --docker-email=<email> --e local
```
### AVS owner & tx execution flags
When invoking `bun cli deploy`/`bun cli contracts deploy` in non-local environments you must:
- Provide the multisig address that should own the ServiceManager: `--avs-owner-address 0x...` (or set `AVS_OWNER_ADDRESS`). Local deployments can still fall back to the value in `contracts/config/anvil.json`.
- Decide whether the script should broadcast owner-only calls immediately:
- Default (recommended for testnet/mainnet) is leaving tx execution **disabled**, which prints the ABI payloads you can hand off to a Safe.
- To execute immediately (e.g. for local/dev or CI), pass `--execute-owner-transactions` or set `TX_EXECUTION=true`. If you do so, a signing key must be provided via `--avs-owner-key` / `AVS_OWNER_PRIVATE_KEY`.
Example (testnet Safe ownership, no immediate execution):
```bash
AVS_OWNER_ADDRESS=0x... bun cli contracts deploy --chain hoodi
```
Example (local dev, execute owner calls right away):
```bash
bun cli contracts deploy --chain anvil --avs-owner-key $LOCAL_OWNER_KEY --execute-owner-transactions
```
## Access Kubernetes dashboard: k9s
```bash

View file

@ -18,6 +18,7 @@ export interface ContractsOptions {
verified?: boolean;
blockscoutBackendUrl?: string;
parameterCollection?: ParameterCollection;
txExecution?: boolean;
}
/**
@ -54,14 +55,22 @@ export const deployContracts = async (options: ContractsOptions): Promise<void>
// Construct and execute deployment with parameter collection
const deployCommand = constructDeployCommand(options);
await executeDeployment(deployCommand, options.parameterCollection, options.chain);
const env: Record<string, string> = {};
if (options.privateKey) {
env.DEPLOYER_PRIVATE_KEY = options.privateKey;
}
if (typeof options.txExecution === "boolean") {
env.TX_EXECUTION = options.txExecution ? "true" : "false";
}
await executeDeployment(deployCommand, options.parameterCollection, options.chain, env);
} else {
await deployContractsCore({
chain: options.chain || "anvil",
rpcUrl: options.rpcUrl,
privateKey: options.privateKey,
verified: options.verified,
blockscoutBackendUrl: options.blockscoutBackendUrl
blockscoutBackendUrl: options.blockscoutBackendUrl,
txExecution: options.txExecution
});
}

View file

@ -1,5 +1,5 @@
import { $ } from "bun";
import { CHAIN_CONFIGS } from "configs/contracts/config";
import { CHAIN_CONFIGS, loadChainConfig } from "configs/contracts/config";
import invariant from "tiny-invariant";
import {
logger,
@ -8,6 +8,9 @@ import {
runShellCommandWithLogger
} from "utils";
import type { ParameterCollection } from "utils/parameters";
import { encodeFunctionData } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { dataHavenServiceManagerAbi } from "../contract-bindings/generated";
interface ContractDeploymentOptions {
chain?: string;
@ -15,6 +18,9 @@ interface ContractDeploymentOptions {
privateKey?: string | undefined;
verified?: boolean;
blockscoutBackendUrl?: string;
avsOwnerAddress?: string;
avsOwnerKey?: string;
txExecution?: boolean;
}
/**
@ -84,14 +90,14 @@ export const executeDeployment = async (
deployCommand: string,
parameterCollection?: ParameterCollection,
chain?: string,
privateKey?: string
env?: Record<string, string>
) => {
logger.info("⌛️ Deploying contracts (this might take a few minutes)...");
// Using custom shell command to improve logging with forge's stdoutput
await runShellCommandWithLogger(deployCommand, {
cwd: "../contracts",
env: privateKey ? { DEPLOYER_PRIVATE_KEY: privateKey } : undefined
env
});
// After deployment, read the:
@ -177,6 +183,9 @@ export const deployContracts = async (options: {
privateKey?: string | undefined;
verified?: boolean;
blockscoutBackendUrl?: string;
avsOwnerKey?: string;
avsOwnerAddress?: string;
txExecution?: boolean;
}) => {
const chainConfig = CHAIN_CONFIGS[options.chain as keyof typeof CHAIN_CONFIGS];
@ -185,13 +194,43 @@ export const deployContracts = async (options: {
}
const finalRpcUrl = options.rpcUrl || chainConfig.RPC_URL;
const isLocalChain = options.chain === "anvil";
const txExecutionEnabled = options.txExecution ?? isLocalChain;
const normalizedOwnerKey = normalizePrivateKey(
options.avsOwnerKey || process.env.AVS_OWNER_PRIVATE_KEY
);
let resolvedAvsOwnerAddress = options.avsOwnerAddress;
if (!resolvedAvsOwnerAddress && normalizedOwnerKey) {
resolvedAvsOwnerAddress = privateKeyToAccount(normalizedOwnerKey).address;
}
if (!resolvedAvsOwnerAddress && isLocalChain) {
const config = await loadChainConfig(options.chain);
resolvedAvsOwnerAddress = config?.avs?.avsOwner;
}
if (!resolvedAvsOwnerAddress) {
throw new Error(
"AVS owner address is required. Provide --avs-owner-address, --avs-owner-key, or AVS_OWNER_ADDRESS."
);
}
if (txExecutionEnabled && !normalizedOwnerKey) {
throw new Error(
"Executing AVS owner transactions requires --avs-owner-key or AVS_OWNER_PRIVATE_KEY to be set."
);
}
const deploymentOptions: ContractDeploymentOptions = {
chain: options.chain,
rpcUrl: finalRpcUrl,
privateKey: options.privateKey,
verified: options.verified,
blockscoutBackendUrl: options.blockscoutBackendUrl
blockscoutBackendUrl: options.blockscoutBackendUrl,
avsOwnerAddress: resolvedAvsOwnerAddress,
avsOwnerKey: normalizedOwnerKey,
txExecution: txExecutionEnabled
};
// Validate parameters
@ -202,11 +241,120 @@ export const deployContracts = async (options: {
// Construct and execute deployment
const deployCommand = constructDeployCommand(deploymentOptions);
await executeDeployment(deployCommand, undefined, options.chain, options.privateKey);
const env = buildDeploymentEnv(deploymentOptions);
await executeDeployment(deployCommand, undefined, options.chain, env);
if (!txExecutionEnabled) {
await emitOwnerTransactionCalldata(options.chain);
}
logger.success(`DataHaven contracts deployed successfully to ${options.chain}`);
};
const normalizePrivateKey = (key?: string): `0x${string}` | undefined => {
if (!key) {
return undefined;
}
return (key.startsWith("0x") ? key : `0x${key}`) as `0x${string}`;
};
const buildDeploymentEnv = (options: ContractDeploymentOptions) => {
const env: Record<string, string> = {};
if (options.privateKey) {
env.DEPLOYER_PRIVATE_KEY = options.privateKey;
}
if (options.avsOwnerKey) {
env.AVS_OWNER_PRIVATE_KEY = options.avsOwnerKey;
}
if (options.avsOwnerAddress) {
env.AVS_OWNER_ADDRESS = options.avsOwnerAddress;
}
if (typeof options.txExecution === "boolean") {
env.TX_EXECUTION = options.txExecution ? "true" : "false";
}
return env;
};
const emitOwnerTransactionCalldata = async (chain?: string) => {
try {
const deployments = await parseDeploymentsFile(chain);
const rewardsInfo = await parseRewardsInfoFile(chain);
const serviceManager = deployments.ServiceManager;
const vetoableSlasher = deployments.VetoableSlasher;
const rewardsRegistry = deployments.RewardsRegistry;
const rewardsAgent = rewardsInfo.RewardsAgent;
if (!serviceManager || !vetoableSlasher || !rewardsRegistry || !rewardsAgent) {
logger.warn("⚠️ Missing deployment artifacts; cannot produce multisig calldata.");
return;
}
const calls = [
{
label: "Set metadata URI",
description: 'DataHavenServiceManager.updateAVSMetadataURI("")',
to: serviceManager,
value: "0",
data: encodeFunctionData({
abi: dataHavenServiceManagerAbi,
functionName: "updateAVSMetadataURI",
args: [""]
})
},
{
label: "Set slasher",
description: "DataHavenServiceManager.setSlasher(address)",
to: serviceManager,
value: "0",
data: encodeFunctionData({
abi: dataHavenServiceManagerAbi,
functionName: "setSlasher",
args: [vetoableSlasher]
})
},
{
label: "Attach RewardsRegistry",
description: "DataHavenServiceManager.setRewardsRegistry(VALIDATORS_SET_ID, address)",
to: serviceManager,
value: "0",
data: encodeFunctionData({
abi: dataHavenServiceManagerAbi,
functionName: "setRewardsRegistry",
args: [0, rewardsRegistry]
})
},
{
label: "Set Rewards Agent",
description: "DataHavenServiceManager.setRewardsAgent(VALIDATORS_SET_ID, address)",
to: serviceManager,
value: "0",
data: encodeFunctionData({
abi: dataHavenServiceManagerAbi,
functionName: "setRewardsAgent",
args: [0, rewardsAgent]
})
}
];
logger.info(
"🔐 On-chain owner transactions were deferred. Submit the following calls via your multisig:"
);
calls.forEach((call, index) => {
logger.info(`\n#${index + 1} ${call.label}`);
logger.info(call.description);
logger.info(JSON.stringify(call, null, 2));
});
} catch (error) {
logger.warn(`⚠️ Failed to build multisig calldata: ${error}`);
}
};
// Allow script to be run directly with CLI arguments
if (import.meta.main) {
const args = process.argv.slice(2);
@ -255,5 +403,6 @@ if (import.meta.main) {
await buildContracts();
const deployCommand = constructDeployCommand(options);
await executeDeployment(deployCommand, undefined, undefined, options.privateKey);
const directEnv = options.privateKey ? { DEPLOYER_PRIVATE_KEY: options.privateKey } : undefined;
await executeDeployment(deployCommand, undefined, undefined, directEnv);
}