feat: CLI command for (de)registering from AVS (#3790)

### Description

- CLI commands for registering/deregistering an operator from the AVS
- uses the encrypted local key store as source of operator key
- Web3Signer not implemented yet

### Drive-by changes

None

### Related issues

- fixes https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3598
(duplicate)
- also fixes
https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3758

### Backward compatibility

Yes

### Testing

Onchain
https://holesky.etherscan.io/tx/0x95d95f6fcf1f80d0911124bd0a85ac848eecedc8ec39b3ceba6e1a30792c5651
and
https://holesky.etherscan.io/tx/0xd1d1b56d45c30276596b73e040e3747ca7ef90977182f1912c5b399559d2a503

---------

Co-authored-by: Connor McEwen <connor.mcewen@gmail.com>
Co-authored-by: Yorke Rhodes <yorke@hyperlane.xyz>
pull/3842/head
Kunal Arora 6 months ago committed by GitHub
parent d0ce9081dd
commit b440d98be3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/many-rice-wave.md
  2. 6
      .changeset/tall-tables-scream.md
  3. 30
      solidity/script/avs/DeployAVS.s.sol
  4. 2
      solidity/script/avs/eigenlayer_addresses.json
  5. 2
      typescript/cli/cli.ts
  6. 19
      typescript/cli/src/avs/config.ts
  7. 164
      typescript/cli/src/avs/stakeRegistry.ts
  8. 84
      typescript/cli/src/commands/avs.ts
  9. 1
      typescript/cli/src/consts.ts
  10. 9
      typescript/cli/src/utils/files.ts

@ -1,7 +1,7 @@
---
"@hyperlane-xyz/cli": patch
"@hyperlane-xyz/helloworld": patch
"@hyperlane-xyz/infra": patch
'@hyperlane-xyz/cli': patch
'@hyperlane-xyz/helloworld': patch
'@hyperlane-xyz/infra': patch
---
fix: minor change was breaking in registry export

@ -0,0 +1,6 @@
---
'@hyperlane-xyz/cli': minor
'@hyperlane-xyz/core': minor
---
Added support for registering/deregistering from the Hyperlane AVS

@ -12,6 +12,7 @@ import {ProxyAdmin} from "../../contracts/upgrade/ProxyAdmin.sol";
import {TransparentUpgradeableProxy} from "../../contracts/upgrade/TransparentUpgradeableProxy.sol";
import {ECDSAStakeRegistry} from "../../contracts/avs/ECDSAStakeRegistry.sol";
import {Quorum, StrategyParams} from "../../contracts/interfaces/avs/vendored/IECDSAStakeRegistryEventsAndErrors.sol";
import {ECDSAServiceManagerBase} from "../../contracts/avs/ECDSAServiceManagerBase.sol";
import {HyperlaneServiceManager} from "../../contracts/avs/HyperlaneServiceManager.sol";
import {TestPaymentCoordinator} from "../../contracts/test/avs/TestPaymentCoordinator.sol";
@ -42,6 +43,11 @@ contract DeployAVS is Script {
);
string memory json = vm.readFile(path);
proxyAdmin = ProxyAdmin(
json.readAddress(
string(abi.encodePacked(".", targetEnv, ".proxyAdmin"))
)
);
avsDirectory = IAVSDirectory(
json.readAddress(
string(abi.encodePacked(".", targetEnv, ".avsDirectory"))
@ -88,15 +94,14 @@ contract DeployAVS is Script {
}
}
function run(string memory network) external {
function run(string memory network, string memory metadataUri) external {
deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY");
address deployerAddress = vm.addr(deployerPrivateKey);
_loadEigenlayerAddresses(network);
vm.startBroadcast(deployerPrivateKey);
proxyAdmin = new ProxyAdmin();
ECDSAStakeRegistry stakeRegistryImpl = new ECDSAStakeRegistry(
delegationManager
);
@ -118,7 +123,7 @@ contract DeployAVS is Script {
address(proxyAdmin),
abi.encodeWithSelector(
HyperlaneServiceManager.initialize.selector,
msg.sender
address(deployerAddress)
)
);
@ -131,7 +136,24 @@ contract DeployAVS is Script {
quorum
)
);
HyperlaneServiceManager hsm = HyperlaneServiceManager(
address(hsmProxy)
);
require(success, "Failed to initialize ECDSAStakeRegistry");
require(
ECDSAStakeRegistry(address(stakeRegistryProxy)).owner() ==
address(deployerAddress),
"Owner of ECDSAStakeRegistry is not the deployer"
);
require(
HyperlaneServiceManager(address(hsmProxy)).owner() ==
address(deployerAddress),
"Owner of HyperlaneServiceManager is not the deployer"
);
hsm.updateAVSMetadataURI(metadataUri);
console.log(
"ECDSAStakeRegistry Implementation: ",

@ -1,5 +1,6 @@
{
"ethereum": {
"proxyAdmin": "0x75EE15Ee1B4A75Fa3e2fDF5DF3253c25599cc659",
"delegationManager": "0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A",
"avsDirectory": "0x135DDa560e946695d6f155dACaFC6f1F25C1F5AF",
"paymentCoordinator": "",
@ -19,6 +20,7 @@
]
},
"holesky": {
"proxyAdmin": "0x33dB966328Ea213b0f76eF96CA368AB37779F065",
"delegationManager": "0xA44151489861Fe9e3055d95adC98FbD462B948e7",
"avsDirectory": "0x055733000064333CaDDbC92763c58BF0192fFeBf",
"paymentCoordinator": "",

@ -5,6 +5,7 @@ import yargs from 'yargs';
import type { LogFormat, LogLevel } from '@hyperlane-xyz/utils';
import './env.js';
import { avsCommand } from './src/commands/avs.js';
import { chainsCommand } from './src/commands/chains.js';
import { configCommand } from './src/commands/config.js';
import { deployCommand } from './src/commands/deploy.js';
@ -49,6 +50,7 @@ try {
},
contextMiddleware,
])
.command(avsCommand)
.command(chainsCommand)
.command(configCommand)
.command(deployCommand)

@ -0,0 +1,19 @@
import { ChainMap } from '@hyperlane-xyz/sdk';
import { Address } from '@hyperlane-xyz/utils';
interface AVSContracts {
avsDirectory: Address;
proxyAdmin: Address;
ecdsaStakeRegistry: Address;
hyperlaneServiceManager: Address;
}
// TODO: move to registry
export const avsAddresses: ChainMap<AVSContracts> = {
holesky: {
avsDirectory: '0x055733000064333CaDDbC92763c58BF0192fFeBf',
proxyAdmin: '0x33dB966328Ea213b0f76eF96CA368AB37779F065',
ecdsaStakeRegistry: '0xFfa913705484C9BAea32Ffe9945BeA099A1DFF72',
hyperlaneServiceManager: '0xc76E477437065093D353b7d56c81ff54D167B0Ab',
},
};

@ -0,0 +1,164 @@
import { password } from '@inquirer/prompts';
import { BigNumberish, Wallet, utils } from 'ethers';
import {
ECDSAStakeRegistry__factory,
TestAVSDirectory__factory,
} from '@hyperlane-xyz/core';
import { ChainName } from '@hyperlane-xyz/sdk';
import { Address } from '@hyperlane-xyz/utils';
import { WriteCommandContext } from '../context/types.js';
import { log, logBlue } from '../logger.js';
import { readFileAtPath, resolvePath } from '../utils/files.js';
import { avsAddresses } from './config.js';
export type SignatureWithSaltAndExpiryStruct = {
signature: utils.BytesLike;
salt: utils.BytesLike;
expiry: BigNumberish;
};
export async function registerOperatorWithSignature({
context,
chain,
operatorKeyPath,
avsSigningKey,
}: {
context: WriteCommandContext;
chain: ChainName;
operatorKeyPath: string;
avsSigningKey: Address;
}) {
const { multiProvider } = context;
const operatorAsSigner = await readOperatorFromEncryptedJson(operatorKeyPath);
const provider = multiProvider.getProvider(chain);
const connectedSigner = operatorAsSigner.connect(provider);
const stakeRegistryAddress = avsAddresses[chain].ecdsaStakeRegistry;
const ecdsaStakeRegistry = ECDSAStakeRegistry__factory.connect(
stakeRegistryAddress,
connectedSigner,
);
const domainId = multiProvider.getDomainId(chain);
const avsDirectoryAddress = avsAddresses[chain].avsDirectory;
const operatorSignature = await getOperatorSignature(
domainId,
avsAddresses[chain].hyperlaneServiceManager,
avsDirectoryAddress,
operatorAsSigner,
connectedSigner,
);
// check if the operator is already registered
const operatorStatus = await ecdsaStakeRegistry.operatorRegistered(
operatorAsSigner.address,
);
if (operatorStatus) {
logBlue(
`Operator ${operatorAsSigner.address} already registered to Hyperlane AVS`,
);
return;
}
log(
`Registering operator ${operatorAsSigner.address} attesting ${avsSigningKey} with signature on ${chain}...`,
);
await multiProvider.handleTx(
chain,
ecdsaStakeRegistry.registerOperatorWithSignature(
operatorSignature,
avsSigningKey,
),
);
logBlue(`Operator ${operatorAsSigner.address} registered to Hyperlane AVS`);
}
export async function deregisterOperator({
context,
chain,
operatorKeyPath,
}: {
context: WriteCommandContext;
chain: ChainName;
operatorKeyPath: string;
}) {
const { multiProvider } = context;
const operatorAsSigner = await readOperatorFromEncryptedJson(operatorKeyPath);
const provider = multiProvider.getProvider(chain);
const connectedSigner = operatorAsSigner.connect(provider);
const stakeRegistryAddress = avsAddresses[chain].ecdsaStakeRegistry;
const ecdsaStakeRegistry = ECDSAStakeRegistry__factory.connect(
stakeRegistryAddress,
connectedSigner,
);
log(`Deregistering operator ${operatorAsSigner.address} on ${chain}...`);
await multiProvider.handleTx(chain, ecdsaStakeRegistry.deregisterOperator());
logBlue(
`Operator ${operatorAsSigner.address} deregistered from Hyperlane AVS`,
);
}
async function readOperatorFromEncryptedJson(
operatorKeyPath: string,
): Promise<Wallet> {
const encryptedJson = readFileAtPath(resolvePath(operatorKeyPath));
const keyFilePassword = await password({
mask: '*',
message: 'Enter the password for the operator key file: ',
});
return await Wallet.fromEncryptedJson(encryptedJson, keyFilePassword);
}
async function getOperatorSignature(
domain: number,
serviceManager: Address,
avsDirectory: Address,
operator: Wallet,
signer: Wallet,
): Promise<SignatureWithSaltAndExpiryStruct> {
const avsDirectoryContract = TestAVSDirectory__factory.connect(
avsDirectory,
signer,
);
// random salt is ok, because we register the operator right after
const salt = utils.hexZeroPad(utils.randomBytes(32), 32);
// give a expiry timestamp 1 hour from now
const expiry = utils.hexZeroPad(
utils.hexlify(Math.floor(Date.now() / 1000) + 60 * 60),
32,
);
const signingHash =
await avsDirectoryContract.calculateOperatorAVSRegistrationDigestHash(
operator.address,
serviceManager,
salt,
expiry,
);
// Eigenlayer's AVSDirectory expects the signature over raw signed hash instead of EIP-191 compatible toEthSignedMessageHash
// see https://github.com/Layr-Labs/eigenlayer-contracts/blob/ef2ea4a7459884f381057aa9bbcd29c7148cfb63/src/contracts/libraries/EIP1271SignatureUtils.sol#L22
const signature = operator
._signingKey()
.signDigest(utils.arrayify(signingHash));
return {
signature: utils.joinSignature(signature),
salt,
expiry,
};
}

@ -0,0 +1,84 @@
import { CommandModule, Options } from 'yargs';
import { ChainName } from '@hyperlane-xyz/sdk';
import { Address } from '@hyperlane-xyz/utils';
import {
deregisterOperator,
registerOperatorWithSignature,
} from '../avs/stakeRegistry.js';
import { CommandModuleWithWriteContext } from '../context/types.js';
import { log } from '../logger.js';
/**
* Parent command
*/
export const avsCommand: CommandModule = {
command: 'avs',
describe: 'Interact with the Hyperlane AVS',
builder: (yargs) =>
yargs
.command(registerCommand)
.command(deregisterCommand)
.version(false)
.demandCommand(),
handler: () => log('Command required'),
};
/**
* Registration command
*/
export const registrationOptions: { [k: string]: Options } = {
chain: {
type: 'string',
description: 'Chain to interact with the AVS on',
demandOption: true,
choices: ['holesky', 'ethereum'],
},
operatorKeyPath: {
type: 'string',
description: 'Path to the operator key file',
demandOption: true,
},
avsSigningKey: {
type: 'string',
description: 'Address of the AVS signing key',
demandOption: true,
},
};
const registerCommand: CommandModuleWithWriteContext<{
chain: ChainName;
operatorKeyPath: string;
avsSigningKey: Address;
}> = {
command: 'register',
describe: 'Register operator with the AVS',
builder: registrationOptions,
handler: async ({ context, chain, operatorKeyPath, avsSigningKey }) => {
await registerOperatorWithSignature({
context,
chain,
operatorKeyPath,
avsSigningKey,
});
process.exit(0);
},
};
const deregisterCommand: CommandModuleWithWriteContext<{
chain: ChainName;
operatorKeyPath: string;
}> = {
command: 'deregister',
describe: 'Deregister yourself with the AVS',
builder: registrationOptions,
handler: async ({ context, chain, operatorKeyPath }) => {
await deregisterOperator({
context,
chain,
operatorKeyPath,
});
process.exit(0);
},
};

@ -1,3 +1,4 @@
export const MINIMUM_CORE_DEPLOY_GAS = (1e8).toString();
export const MINIMUM_WARP_DEPLOY_GAS = (1e7).toString();
export const MINIMUM_TEST_SEND_GAS = (3e5).toString();
export const MINIMUM_AVS_GAS = (3e6).toString();

@ -1,6 +1,7 @@
import { input } from '@inquirer/prompts';
import select from '@inquirer/select';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
@ -15,6 +16,14 @@ export type ArtifactsFile = {
description: string;
};
export function resolvePath(filePath: string): string {
if (filePath.startsWith('~')) {
const homedir = os.homedir();
return path.join(homedir, filePath.slice(1));
}
return filePath;
}
export function isFile(filepath: string) {
if (!filepath) return false;
try {

Loading…
Cancel
Save