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
parent
d0ce9081dd
commit
b440d98be3
@ -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 |
@ -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(); |
||||
|
Loading…
Reference in new issue