diff --git a/.gitignore b/.gitignore index ef990a3..9f1cd9e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ operator-config.yaml.old operator.yaml operator.yaml.old config/* +updates.csv # build dist/ \ No newline at end of file diff --git a/go.mod b/go.mod index cba2071..bef1e91 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/Layr-Labs/eigenlayer-cli go 1.21.11 +replace github.com/Layr-Labs/eigensdk-go v0.1.10 => /Users/madhurshrimal/Desktop/github/Layr-Labs/eigensdk-go + require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Layr-Labs/eigenlayer-contracts v0.3.2-mainnet-rewards diff --git a/go.sum b/go.sum index 9875cd0..7349922 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,6 @@ github.com/Layr-Labs/eigenlayer-rewards-proofs v0.2.10 h1:Uxf0MaBJNiFjEdNGuCmm/U github.com/Layr-Labs/eigenlayer-rewards-proofs v0.2.10/go.mod h1:OlJd1QjqEW53wfWG/lJyPCGvrXwWVEjPQsP4TV+gttQ= github.com/Layr-Labs/eigenpod-proofs-generation v0.0.14-stable.0.20240730152248-5c11a259293e h1:DvW0/kWHV9mZsbH2KOjEHKTSIONNPUj6X05FJvUohy4= github.com/Layr-Labs/eigenpod-proofs-generation v0.0.14-stable.0.20240730152248-5c11a259293e/go.mod h1:T7tYN8bTdca2pkMnz9G2+ZwXYWw5gWqQUIu4KLgC/vM= -github.com/Layr-Labs/eigensdk-go v0.1.10 h1:VWXSoJ0Vm2ZEfSPgftOgmek3xwXKRmeKrNFXfMJJlko= -github.com/Layr-Labs/eigensdk-go v0.1.10/go.mod h1:XcLVDtlB1vOPj63D236b451+SC75B8gwgkpNhYHSxNs= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8= diff --git a/pkg/internal/common/contracts.go b/pkg/internal/common/contracts.go index b6adf13..ff7fe70 100644 --- a/pkg/internal/common/contracts.go +++ b/pkg/internal/common/contracts.go @@ -1,6 +1,7 @@ package common import ( + "context" "errors" "math/big" @@ -57,3 +58,14 @@ func GetELWriter( return eLWriter, nil } + +func IsSmartContractAddress(address gethcommon.Address, ethClient *ethclient.Client) bool { + code, err := ethClient.CodeAt(context.Background(), address, nil) + if err != nil { + // We return true here because we want to treat the address as a smart contract + // This is only used to gas estimation and creating unsigned transactions + // So it's fine if eth client return an error + return true + } + return len(code) > 0 +} diff --git a/pkg/internal/common/eth.go b/pkg/internal/common/eth.go index 59123db..cd8e610 100644 --- a/pkg/internal/common/eth.go +++ b/pkg/internal/common/eth.go @@ -5,6 +5,7 @@ import ( "math/big" "strings" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" ) @@ -46,3 +47,12 @@ func GetTxFeeDetails(tx *types.Transaction) *TxFeeDetails { GasFeeCapGwei: gasFeeCapGwei, } } + +func ConvertStringSliceToGethAddressSlice(addresses []string) []common.Address { + gethAddresses := make([]common.Address, 0, len(addresses)) + for _, address := range addresses { + parsed := common.HexToAddress(address) + gethAddresses = append(gethAddresses, parsed) + } + return gethAddresses +} diff --git a/pkg/internal/common/flags/avs.go b/pkg/internal/common/flags/avs.go new file mode 100644 index 0000000..a573f14 --- /dev/null +++ b/pkg/internal/common/flags/avs.go @@ -0,0 +1,40 @@ +package flags + +import "github.com/urfave/cli/v2" + +var ( + AVSAddressesFlag = cli.StringSliceFlag{ + Name: "avs-addresses", + Usage: "AVS addresses", + Aliases: []string{"aa"}, + EnvVars: []string{"AVS_ADDRESSES"}, + } + + AVSAddressFlag = cli.StringFlag{ + Name: "avs-address", + Usage: "AVS addresses", + Aliases: []string{"aa"}, + EnvVars: []string{"AVS_ADDRESS"}, + } + + StrategyAddressesFlag = cli.StringSliceFlag{ + Name: "strategy-addresses", + Usage: "Strategy addresses", + Aliases: []string{"sa"}, + EnvVars: []string{"STRATEGY_ADDRESSES"}, + } + + StrategyAddressFlag = cli.StringFlag{ + Name: "strategy-address", + Usage: "Strategy addresses", + Aliases: []string{"sa"}, + EnvVars: []string{"STRATEGY_ADDRESS"}, + } + + OperatorSetIdFlag = cli.Uint64Flag{ + Name: "operator-set-id", + Usage: "Operator set ID", + Aliases: []string{"osid"}, + EnvVars: []string{"OPERATOR_SET_ID"}, + } +) diff --git a/pkg/internal/common/flags/general.go b/pkg/internal/common/flags/general.go index 58b4ca0..8ec47be 100644 --- a/pkg/internal/common/flags/general.go +++ b/pkg/internal/common/flags/general.go @@ -75,4 +75,25 @@ var ( Usage: "Enable verbose logging", EnvVars: []string{"VERBOSE"}, } + + OperatorAddressFlag = cli.StringFlag{ + Name: "operator-address", + Aliases: []string{"oa", "operator"}, + Usage: "Operator address", + EnvVars: []string{"OPERATOR_ADDRESS"}, + } + + CSVFileFlag = cli.StringFlag{ + Name: "csv-file", + Aliases: []string{"csv"}, + Usage: "CSV file to read data from", + EnvVars: []string{"CSV_FILE"}, + } + + EnvironmentFlag = cli.StringFlag{ + Name: "environment", + Aliases: []string{"env"}, + Usage: "environment to use. Currently supports 'preprod' ,'testnet' and 'prod'. If not provided, it will be inferred based on network", + EnvVars: []string{"ENVIRONMENT"}, + } ) diff --git a/pkg/internal/common/helper.go b/pkg/internal/common/helper.go index a4506d3..6ce94cb 100644 --- a/pkg/internal/common/helper.go +++ b/pkg/internal/common/helper.go @@ -473,3 +473,14 @@ func GetNoSendTxOpts(from common.Address) *bind.TransactOpts { func Trim0x(s string) string { return strings.TrimPrefix(s, "0x") } + +func GetEnvFromNetwork(network string) string { + switch network { + case utils.HoleskyNetworkName: + return "testnet" + case utils.MainnetNetworkName: + return "mainnet" + default: + return "local" + } +} diff --git a/pkg/operator.go b/pkg/operator.go index 4fafe4f..565ead9 100644 --- a/pkg/operator.go +++ b/pkg/operator.go @@ -17,6 +17,7 @@ func OperatorCmd(p utils.Prompter) *cli.Command { operator.StatusCmd(p), operator.UpdateCmd(p), operator.UpdateMetadataURICmd(p), + operator.AllocationsCmd(p), }, } diff --git a/pkg/operator/allocations.go b/pkg/operator/allocations.go new file mode 100644 index 0000000..a1e6cb6 --- /dev/null +++ b/pkg/operator/allocations.go @@ -0,0 +1,21 @@ +package operator + +import ( + "github.com/Layr-Labs/eigenlayer-cli/pkg/operator/allocations" + "github.com/Layr-Labs/eigenlayer-cli/pkg/utils" + "github.com/urfave/cli/v2" +) + +func AllocationsCmd(p utils.Prompter) *cli.Command { + var allocationsCmd = &cli.Command{ + Name: "allocations", + Usage: "Stake allocation commands for operators", + Subcommands: []*cli.Command{ + allocations.ShowCmd(p), + allocations.UpdateCmd(p), + allocations.InitializeDelayCmd(p), + }, + } + + return allocationsCmd +} diff --git a/pkg/operator/allocations/README.md b/pkg/operator/allocations/README.md new file mode 100644 index 0000000..3f8cf16 --- /dev/null +++ b/pkg/operator/allocations/README.md @@ -0,0 +1,75 @@ +## Allocations Command +### Initialize Delay +```bash +eigenlayer operator allocations initialize-delay --help +NAME: + eigenlayer operator allocations initialize-delay - Initialize the allocation delay for operator + +USAGE: + initialize-delay [flags] + +DESCRIPTION: + Initializes the allocation delay for operator. This is a one time command. You can not change the allocation delay once + +OPTIONS: + --broadcast, -b Use this flag to broadcast the transaction (default: false) [$BROADCAST] + --ecdsa-private-key value, -e value ECDSA private key hex to send transaction [$ECDSA_PRIVATE_KEY] + --environment value, --env value environment to use. Currently supports 'preprod' ,'testnet' and 'prod'. If not provided, it will be inferred based on network [$ENVIRONMENT] + --eth-rpc-url value, -r value URL of the Ethereum RPC [$ETH_RPC_URL] + --fireblocks-api-key value, --ff value Fireblocks API key [$FIREBLOCKS_API_KEY] + --fireblocks-aws-region value, --fa value AWS region if secret is stored in AWS KMS (default: "us-east-1") [$FIREBLOCKS_AWS_REGION] + --fireblocks-base-url value, --fb value Fireblocks base URL [$FIREBLOCKS_BASE_URL] + --fireblocks-secret-key value, --fs value Fireblocks secret key. If you are using AWS Secret Manager, this should be the secret name. [$FIREBLOCKS_SECRET_KEY] + --fireblocks-secret-storage-type value, --fst value Fireblocks secret storage type. Supported values are 'plaintext' and 'aws_secret_manager' [$FIREBLOCKS_SECRET_STORAGE_TYPE] + --fireblocks-timeout value, --ft value Fireblocks timeout (default: 30) [$FIREBLOCKS_TIMEOUT] + --fireblocks-vault-account-name value, --fv value Fireblocks vault account name [$FIREBLOCKS_VAULT_ACCOUNT_NAME] + --network value, -n value Network to use. Currently supports 'holesky' and 'mainnet' (default: "holesky") [$NETWORK] + --operator-address value, --oa value, --operator value Operator address [$OPERATOR_ADDRESS] + --output-file value, -o value Output file to write the data [$OUTPUT_FILE] + --output-type value, --ot value Output format of the command. One of 'pretty', 'json' or 'calldata' (default: "pretty") [$OUTPUT_TYPE] + --path-to-key-store value, -k value Path to the key store used to send transactions [$PATH_TO_KEY_STORE] + --verbose, -v Enable verbose logging (default: false) [$VERBOSE] + --web3signer-url value, -w value URL of the Web3Signer [$WEB3SIGNER_URL] + --help, -h show help +``` + +### Update allocations +```bash +eigenlayer operator allocations update --help +NAME: + eigenlayer operator allocations update - Update allocations + +USAGE: + update + +DESCRIPTION: + + Command to update allocations + + +OPTIONS: + --avs-address value, --aa value AVS addresses [$AVS_ADDRESS] + --bips-to-allocate value, --bta value, --bips value, --bps value Bips to allocate to the strategy (default: 0) [$BIPS_TO_ALLOCATE] + --broadcast, -b Use this flag to broadcast the transaction (default: false) [$BROADCAST] + --csv-file value, --csv value CSV file to read data from [$CSV_FILE] + --ecdsa-private-key value, -e value ECDSA private key hex to send transaction [$ECDSA_PRIVATE_KEY] + --environment value, --env value environment to use. Currently supports 'preprod' ,'testnet' and 'prod'. If not provided, it will be inferred based on network [$ENVIRONMENT] + --eth-rpc-url value, -r value URL of the Ethereum RPC [$ETH_RPC_URL] + --fireblocks-api-key value, --ff value Fireblocks API key [$FIREBLOCKS_API_KEY] + --fireblocks-aws-region value, --fa value AWS region if secret is stored in AWS KMS (default: "us-east-1") [$FIREBLOCKS_AWS_REGION] + --fireblocks-base-url value, --fb value Fireblocks base URL [$FIREBLOCKS_BASE_URL] + --fireblocks-secret-key value, --fs value Fireblocks secret key. If you are using AWS Secret Manager, this should be the secret name. [$FIREBLOCKS_SECRET_KEY] + --fireblocks-secret-storage-type value, --fst value Fireblocks secret storage type. Supported values are 'plaintext' and 'aws_secret_manager' [$FIREBLOCKS_SECRET_STORAGE_TYPE] + --fireblocks-timeout value, --ft value Fireblocks timeout (default: 30) [$FIREBLOCKS_TIMEOUT] + --fireblocks-vault-account-name value, --fv value Fireblocks vault account name [$FIREBLOCKS_VAULT_ACCOUNT_NAME] + --network value, -n value Network to use. Currently supports 'holesky' and 'mainnet' (default: "holesky") [$NETWORK] + --operator-address value, --oa value, --operator value Operator address [$OPERATOR_ADDRESS] + --operator-set-id value, --osid value Operator set ID (default: 0) [$OPERATOR_SET_ID] + --output-file value, -o value Output file to write the data [$OUTPUT_FILE] + --output-type value, --ot value Output format of the command. One of 'pretty', 'json' or 'calldata' (default: "pretty") [$OUTPUT_TYPE] + --path-to-key-store value, -k value Path to the key store used to send transactions [$PATH_TO_KEY_STORE] + --strategy-address value, --sa value Strategy addresses [$STRATEGY_ADDRESS] + --verbose, -v Enable verbose logging (default: false) [$VERBOSE] + --web3signer-url value, -w value URL of the Web3Signer [$WEB3SIGNER_URL] + --help, -h show help +``` \ No newline at end of file diff --git a/pkg/operator/allocations/flags.go b/pkg/operator/allocations/flags.go new file mode 100644 index 0000000..cb09691 --- /dev/null +++ b/pkg/operator/allocations/flags.go @@ -0,0 +1,19 @@ +package allocations + +import "github.com/urfave/cli/v2" + +var ( + BipsToAllocateFlag = cli.Uint64Flag{ + Name: "bips-to-allocate", + Aliases: []string{"bta", "bips", "bps"}, + Usage: "Bips to allocate to the strategy", + EnvVars: []string{"BIPS_TO_ALLOCATE"}, + } + + EnvironmentFlag = cli.StringFlag{ + Name: "environment", + Aliases: []string{"env"}, + Usage: "environment to use. Currently supports 'preprod' ,'testnet' and 'prod'. If not provided, it will be inferred based on network", + EnvVars: []string{"ENVIRONMENT"}, + } +) diff --git a/pkg/operator/allocations/initializedelay.go b/pkg/operator/allocations/initializedelay.go new file mode 100644 index 0000000..e89fbf8 --- /dev/null +++ b/pkg/operator/allocations/initializedelay.go @@ -0,0 +1,207 @@ +package allocations + +import ( + "fmt" + "sort" + "strconv" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common" + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/Layr-Labs/eigenlayer-cli/pkg/utils" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + eigenSdkUtils "github.com/Layr-Labs/eigensdk-go/utils" + + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/urfave/cli/v2" +) + +func InitializeDelayCmd(p utils.Prompter) *cli.Command { + initializeDelayCmd := &cli.Command{ + Name: "initialize-delay", + UsageText: "initialize-delay [flags] ", + Usage: "Initialize the allocation delay for operator", + Description: "Initializes the allocation delay for operator. This is a one time command. You can not change the allocation delay once", + Flags: getInitializeAllocationDelayFlags(), + After: telemetry.AfterRunAction(), + Action: func(c *cli.Context) error { + return initializeDelayAction(c, p) + }, + } + + return initializeDelayCmd +} + +func initializeDelayAction(cCtx *cli.Context, p utils.Prompter) error { + ctx := cCtx.Context + logger := common.GetLogger(cCtx) + + config, err := readAndValidateAllocationDelayConfig(cCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate claim config", err) + } + cCtx.App.Metadata["network"] = config.chainID.String() + ethClient, err := ethclient.Dial(config.rpcUrl) + if err != nil { + return eigenSdkUtils.WrapError("failed to create new eth client", err) + } + + // Temp to test modify Allocations + config.delegationManagerAddress = gethcommon.HexToAddress("0xD9DFF502e91aE5887399C8ca11a0708dc1ee1cbf") + + if config.broadcast { + confirm, err := p.Confirm( + "This will initialize the allocation delay for operator. You won't be able to set or change it again. Do you want to continue?", + ) + if err != nil { + return err + } + if !confirm { + logger.Info("Operation cancelled") + return nil + } + eLWriter, err := common.GetELWriter( + config.operatorAddress, + config.signerConfig, + ethClient, + elcontracts.Config{ + DelegationManagerAddress: config.delegationManagerAddress, + }, + p, + config.chainID, + logger, + ) + + if err != nil { + return eigenSdkUtils.WrapError("failed to get EL writer", err) + } + + receipt, err := eLWriter.InitializeAllocationDelay(ctx, config.allocationDelay, true) + if err != nil { + return err + } + common.PrintTransactionInfo(receipt.TxHash.String(), config.chainID) + } else { + noSendTxOpts := common.GetNoSendTxOpts(config.operatorAddress) + _, _, contractBindings, err := elcontracts.BuildClients(elcontracts.Config{ + DelegationManagerAddress: config.delegationManagerAddress, + }, ethClient, nil, logger, nil) + if err != nil { + return err + } + // If operator is a smart contract, we can't estimate gas using geth + // since balance of contract can be 0, as it can be called by an EOA + // to claim. So we hardcode the gas limit to 150_000 so that we can + // create unsigned tx without gas limit estimation from contract bindings + if common.IsSmartContractAddress(config.operatorAddress, ethClient) { + // address is a smart contract + noSendTxOpts.GasLimit = 150_000 + } + + unsignedTx, err := contractBindings.DelegationManager.InitializeAllocationDelay(noSendTxOpts, config.allocationDelay) + if err != nil { + return eigenSdkUtils.WrapError("failed to create unsigned tx", err) + } + + if config.outputType == string(common.OutputType_Calldata) { + calldataHex := gethcommon.Bytes2Hex(unsignedTx.Data()) + if !common.IsEmptyString(config.output) { + err = common.WriteToFile([]byte(calldataHex), config.output) + if err != nil { + return err + } + logger.Infof("Call data written to file: %s", config.output) + } else { + fmt.Println(calldataHex) + } + } else { + if !common.IsEmptyString(config.output) { + fmt.Println("output file not supported for pretty output type") + fmt.Println() + } + fmt.Printf("Allocation delay %d will be set for operator %s\n", config.allocationDelay, config.operatorAddress.String()) + } + txFeeDetails := common.GetTxFeeDetails(unsignedTx) + fmt.Println() + txFeeDetails.Print() + fmt.Println("To broadcast the transaction, use the --broadcast flag") + } + + return nil +} + +func getInitializeAllocationDelayFlags() []cli.Flag { + baseFlags := []cli.Flag{ + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + &flags.OutputFileFlag, + &flags.OutputTypeFlag, + &flags.BroadcastFlag, + &flags.VerboseFlag, + &flags.OperatorAddressFlag, + } + allFlags := append(baseFlags, flags.GetSignerFlags()...) + sort.Sort(cli.FlagsByName(allFlags)) + return allFlags +} + +func readAndValidateAllocationDelayConfig(c *cli.Context, logger logging.Logger) (*allocationDelayConfig, error) { + args := c.Args() + if args.Len() != 1 { + return nil, fmt.Errorf("accepts 1 arg, received %d", args.Len()) + } + + allocationDelayString := c.Args().First() + allocationDelayInt, err := strconv.Atoi(allocationDelayString) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to convert allocation delay to int", err) + } + + network := c.String(flags.NetworkFlag.Name) + environment := c.String(EnvironmentFlag.Name) + rpcUrl := c.String(flags.ETHRpcUrlFlag.Name) + output := c.String(flags.OutputFileFlag.Name) + outputType := c.String(flags.OutputTypeFlag.Name) + broadcast := c.Bool(flags.BroadcastFlag.Name) + operatorAddress := c.String(flags.OperatorAddressFlag.Name) + + chainID := utils.NetworkNameToChainId(network) + logger.Debugf("Using chain ID: %s", chainID.String()) + + if common.IsEmptyString(environment) { + environment = common.GetEnvFromNetwork(network) + } + logger.Debugf("Using network %s and environment: %s", network, environment) + + // Get signerConfig + signerConfig, err := common.GetSignerConfig(c, logger) + if err != nil { + // We don't want to throw error since people can still use it to generate the claim + // without broadcasting it + logger.Debugf("Failed to get signer config: %s", err) + } + + delegationManagerAddress, err := utils.GetDelegationManagerAddress(chainID) + if err != nil { + return nil, err + } + + return &allocationDelayConfig{ + network: network, + rpcUrl: rpcUrl, + environment: environment, + chainID: chainID, + output: output, + outputType: outputType, + broadcast: broadcast, + operatorAddress: gethcommon.HexToAddress(operatorAddress), + signerConfig: signerConfig, + delegationManagerAddress: gethcommon.HexToAddress(delegationManagerAddress), + allocationDelay: uint32(allocationDelayInt), + }, nil +} diff --git a/pkg/operator/allocations/show.go b/pkg/operator/allocations/show.go new file mode 100644 index 0000000..9cb8a61 --- /dev/null +++ b/pkg/operator/allocations/show.go @@ -0,0 +1,155 @@ +package allocations + +import ( + "sort" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common" + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/Layr-Labs/eigenlayer-cli/pkg/utils" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + eigenSdkUtils "github.com/Layr-Labs/eigensdk-go/utils" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/urfave/cli/v2" +) + +func ShowCmd(p utils.Prompter) *cli.Command { + showCmd := &cli.Command{ + Name: "show", + Usage: "Show allocations", + After: telemetry.AfterRunAction(), + Description: ` +Command to show allocations +`, + Flags: getShowFlags(), + Action: func(cCtx *cli.Context) error { + return showAction(cCtx, p) + }, + } + return showCmd +} + +func showAction(cCtx *cli.Context, p utils.Prompter) error { + ctx := cCtx.Context + logger := common.GetLogger(cCtx) + + config, err := readAndValidateShowConfig(cCtx, &logger) + if err != nil { + return err + } + cCtx.App.Metadata["network"] = config.chainID.String() + + ethClient, err := ethclient.Dial(config.rpcUrl) + if err != nil { + return eigenSdkUtils.WrapError("failed to create new eth client", err) + } + + // Temp to test modify allocations + config.delegationManagerAddress = gethcommon.HexToAddress("0xD9DFF502e91aE5887399C8ca11a0708dc1ee1cbf") + + elReader, err := elcontracts.NewReaderFromConfig( + elcontracts.Config{ + DelegationManagerAddress: config.delegationManagerAddress, + }, + ethClient, + logger, + ) + if err != nil { + return eigenSdkUtils.WrapError("failed to create new reader from config", err) + } + + // for each strategy address, get the allocatable magnitude + for _, strategyAddress := range config.strategyAddresses { + allocatableMagnitude, err := elReader.GetAllocatableMagnitude( + &bind.CallOpts{Context: ctx}, + config.operatorAddress, + strategyAddress, + ) + if err != nil { + return eigenSdkUtils.WrapError("failed to get allocatable magnitude", err) + } + logger.Debugf("Allocatable magnitude for strategy %v: %d", strategyAddress, allocatableMagnitude) + } + + opSet, slashableMagnitudes, err := elReader.GetCurrentSlashableMagnitudes( + &bind.CallOpts{Context: ctx}, + config.operatorAddress, + config.strategyAddresses, + ) + if err != nil { + return eigenSdkUtils.WrapError("failed to get slashable magnitude", err) + } + + slashableMagnitudeHolders := make(SlashableMagnitudeHolders, 0) + for i, strategyAddress := range config.strategyAddresses { + slashableMagnitude := slashableMagnitudes[i] + for j, opSet := range opSet { + slashableMagnitudeHolders = append(slashableMagnitudeHolders, SlashableMagnitudesHolder{ + StrategyAddress: strategyAddress, + AVSAddress: opSet.Avs, + OperatorSetId: opSet.OperatorSetId, + SlashableMagnitude: slashableMagnitude[j], + }) + } + } + + if config.outputType == string(common.OutputType_Json) { + slashableMagnitudeHolders.PrintJSON() + } else { + slashableMagnitudeHolders.PrintPretty() + } + + return nil +} + +func readAndValidateShowConfig(cCtx *cli.Context, logger *logging.Logger) (*showConfig, error) { + network := cCtx.String(flags.NetworkFlag.Name) + rpcUrl := cCtx.String(flags.ETHRpcUrlFlag.Name) + environment := cCtx.String(flags.EnvironmentFlag.Name) + operatorAddress := gethcommon.HexToAddress(cCtx.String(flags.OperatorAddressFlag.Name)) + avsAddresses := common.ConvertStringSliceToGethAddressSlice(cCtx.StringSlice(flags.AVSAddressesFlag.Name)) + strategyAddresses := common.ConvertStringSliceToGethAddressSlice(cCtx.StringSlice(flags.StrategyAddressesFlag.Name)) + outputFile := cCtx.String(flags.OutputFileFlag.Name) + outputType := cCtx.String(flags.OutputTypeFlag.Name) + + chainId := utils.NetworkNameToChainId(network) + delegationManagerAddress, err := utils.GetDelegationManagerAddress(chainId) + if err != nil { + return nil, err + } + + return &showConfig{ + network: network, + rpcUrl: rpcUrl, + environment: environment, + operatorAddress: operatorAddress, + avsAddresses: avsAddresses, + strategyAddresses: strategyAddresses, + output: outputFile, + outputType: outputType, + delegationManagerAddress: gethcommon.HexToAddress(delegationManagerAddress), + }, nil +} + +func getShowFlags() []cli.Flag { + baseFlags := []cli.Flag{ + &flags.OperatorAddressFlag, + &flags.AVSAddressesFlag, + &flags.StrategyAddressesFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + &flags.VerboseFlag, + &flags.OutputFileFlag, + &flags.OutputTypeFlag, + } + + sort.Sort(cli.FlagsByName(baseFlags)) + return baseFlags +} diff --git a/pkg/operator/allocations/testdata/allocations1.csv b/pkg/operator/allocations/testdata/allocations1.csv new file mode 100644 index 0000000..f6d1311 --- /dev/null +++ b/pkg/operator/allocations/testdata/allocations1.csv @@ -0,0 +1,5 @@ +avs_address,operator_set_id,strategy_address,bips +0x2222AAC0C980Cc029624b7ff55B88Bc6F63C538f,1,0x49989b32351Eb9b8ab2d5623cF22E7F7C23e5630,2000 +0x2222AAC0C980Cc029624b7ff55B88Bc6F63C538f,3,0x49989b32351Eb9b8ab2d5623cF22E7F7C23e5630,1000 +0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76,4,0x232326fE4F8C2f83E3eB2318F090557b7CD02222,3000 +0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76,5,0x545456fE4F8C2f83E3eB2318F090557b7CD04567,4000 \ No newline at end of file diff --git a/pkg/operator/allocations/testdata/allocations_duplicate.csv b/pkg/operator/allocations/testdata/allocations_duplicate.csv new file mode 100644 index 0000000..0da774f --- /dev/null +++ b/pkg/operator/allocations/testdata/allocations_duplicate.csv @@ -0,0 +1,6 @@ +avs_address,operator_set_id,strategy_address,bips +0x2222AAC0C980Cc029624b7ff55B88Bc6F63C538f,1,0x49989b32351Eb9b8ab2d5623cF22E7F7C23e5630,2000 +0x2222AAC0C980Cc029624b7ff55B88Bc6F63C538f,3,0x49989b32351Eb9b8ab2d5623cF22E7F7C23e5630,1000 +0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76,4,0x232326fE4F8C2f83E3eB2318F090557b7CD02222,3000 +0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76,5,0x545456fE4F8C2f83E3eB2318F090557b7CD04567,4000 +0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76,5,0x545456fE4F8C2f83E3eB2318F090557b7CD04567,5000 \ No newline at end of file diff --git a/pkg/operator/allocations/types.go b/pkg/operator/allocations/types.go new file mode 100644 index 0000000..d30c830 --- /dev/null +++ b/pkg/operator/allocations/types.go @@ -0,0 +1,148 @@ +package allocations + +import ( + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/types" + + contractIAllocationManager "github.com/Layr-Labs/eigensdk-go/contracts/bindings/IAllocationManager" + + gethcommon "github.com/ethereum/go-ethereum/common" +) + +type BulkModifyAllocations struct { + Allocations []contractIAllocationManager.IAllocationManagerMagnitudeAllocation + AllocatableMagnitudes map[gethcommon.Address]uint64 +} + +func (b *BulkModifyAllocations) Print() { + for _, a := range b.Allocations { + fmt.Printf( + "Strategy: %s, Expected Total Magnitude: %d, Allocatable Magnitude %d\n", + a.Strategy.Hex(), + a.ExpectedTotalMagnitude, + b.AllocatableMagnitudes[a.Strategy], + ) + for i, opSet := range a.OperatorSets { + fmt.Printf( + "Operator Set: %d, AVS: %s, Magnitude: %d\n", + opSet.OperatorSetId, + opSet.Avs.Hex(), + a.Magnitudes[i], + ) + } + fmt.Println() + } +} + +type updateConfig struct { + network string + rpcUrl string + environment string + chainID *big.Int + output string + outputType string + broadcast bool + operatorAddress gethcommon.Address + avsAddress gethcommon.Address + strategyAddress gethcommon.Address + delegationManagerAddress gethcommon.Address + operatorSetId uint32 + bipsToAllocate uint64 + signerConfig *types.SignerConfig + csvFilePath string +} + +type allocation struct { + AvsAddress gethcommon.Address `csv:"avs_address"` + OperatorSetId uint32 `csv:"operator_set_id"` + StrategyAddress gethcommon.Address `csv:"strategy_address"` + Bips uint64 `csv:"bips"` +} + +type allocationDelayConfig struct { + network string + rpcUrl string + environment string + chainID *big.Int + output string + outputType string + broadcast bool + operatorAddress gethcommon.Address + signerConfig *types.SignerConfig + allocationDelay uint32 + delegationManagerAddress gethcommon.Address +} + +type showConfig struct { + network string + rpcUrl string + environment string + chainID *big.Int + output string + outputType string + operatorAddress gethcommon.Address + delegationManagerAddress gethcommon.Address + avsAddresses []gethcommon.Address + strategyAddresses []gethcommon.Address +} + +type SlashableMagnitudeHolders []SlashableMagnitudesHolder + +type SlashableMagnitudesHolder struct { + StrategyAddress gethcommon.Address + AVSAddress gethcommon.Address + OperatorSetId uint32 + SlashableMagnitude uint64 +} + +func (s SlashableMagnitudeHolders) PrintPretty() { + // Define column headers and widths + headers := []string{"Strategy Address", "AVS Address", "Operator Set ID", "Slashable Magnitude"} + widths := []int{43, 43, 16, 20} + + // print dashes + for _, width := range widths { + fmt.Print("+" + strings.Repeat("-", width+1)) + } + fmt.Println("+") + + // Print header + for i, header := range headers { + fmt.Printf("| %-*s", widths[i], header) + } + fmt.Println("|") + + // Print separator + for _, width := range widths { + fmt.Print("|", strings.Repeat("-", width+1)) + } + fmt.Println("|") + + // Print data rows + for _, holder := range s { + fmt.Printf("| %-*s| %-*s| %-*d| %-*d|\n", + widths[0], holder.StrategyAddress.Hex(), + widths[1], holder.AVSAddress.Hex(), + widths[2], holder.OperatorSetId, + widths[3], holder.SlashableMagnitude) + } + + // print dashes + for _, width := range widths { + fmt.Print("+" + strings.Repeat("-", width+1)) + } + fmt.Println("+") +} + +func (s SlashableMagnitudeHolders) PrintJSON() { + json, err := json.MarshalIndent(s, "", " ") + if err != nil { + fmt.Println("Error marshalling to JSON:", err) + return + } + fmt.Println(string(json)) +} diff --git a/pkg/operator/allocations/update.go b/pkg/operator/allocations/update.go new file mode 100644 index 0000000..d63f2f5 --- /dev/null +++ b/pkg/operator/allocations/update.go @@ -0,0 +1,494 @@ +package allocations + +import ( + "context" + "errors" + "fmt" + "math/big" + "os" + "sort" + "sync" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common" + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/Layr-Labs/eigenlayer-cli/pkg/utils" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + contractIAllocationManager "github.com/Layr-Labs/eigensdk-go/contracts/bindings/IAllocationManager" + "github.com/Layr-Labs/eigensdk-go/logging" + eigenSdkUtils "github.com/Layr-Labs/eigensdk-go/utils" + + "github.com/gocarina/gocsv" + "github.com/urfave/cli/v2" +) + +type elChainReader interface { + GetTotalMagnitudes( + opts *bind.CallOpts, + operatorAddress gethcommon.Address, + strategyAddresses []gethcommon.Address, + ) ([]uint64, error) + GetAllocatableMagnitude( + opts *bind.CallOpts, + operator gethcommon.Address, + strategy gethcommon.Address, + ) (uint64, error) +} + +func UpdateCmd(p utils.Prompter) *cli.Command { + updateCmd := &cli.Command{ + Name: "update", + Usage: "Update allocations", + UsageText: "update", + Description: ` + Command to update allocations + `, + Flags: getUpdateFlags(), + After: telemetry.AfterRunAction(), + Action: func(context *cli.Context) error { + return updateAllocations(context, p) + }, + } + + return updateCmd +} + +func updateAllocations(cCtx *cli.Context, p utils.Prompter) error { + ctx := cCtx.Context + logger := common.GetLogger(cCtx) + + config, err := readAndValidateUpdateFlags(cCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate update flags", err) + } + cCtx.App.Metadata["network"] = config.chainID.String() + + ethClient, err := ethclient.Dial(config.rpcUrl) + if err != nil { + return eigenSdkUtils.WrapError("failed to create new eth client", err) + } + + // Temp to test modify Allocations + config.delegationManagerAddress = gethcommon.HexToAddress("0xD9DFF502e91aE5887399C8ca11a0708dc1ee1cbf") + + elReader, err := elcontracts.NewReaderFromConfig( + elcontracts.Config{ + DelegationManagerAddress: config.delegationManagerAddress, + }, + ethClient, + logger, + ) + if err != nil { + return eigenSdkUtils.WrapError("failed to create new reader from config", err) + } + + allocationsToUpdate, err := generateAllocationsParams(ctx, elReader, config, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to generate Allocations params", err) + } + + if config.broadcast { + if config.signerConfig == nil { + return errors.New("signer is required for broadcasting") + } + logger.Info("Broadcasting magnitude allocation update...") + eLWriter, err := common.GetELWriter( + config.operatorAddress, + config.signerConfig, + ethClient, + elcontracts.Config{ + DelegationManagerAddress: config.delegationManagerAddress, + }, + p, + config.chainID, + logger, + ) + if err != nil { + return eigenSdkUtils.WrapError("failed to get EL writer", err) + } + + receipt, err := eLWriter.ModifyAllocations( + ctx, + config.operatorAddress, + allocationsToUpdate.Allocations, + contractIAllocationManager.ISignatureUtilsSignatureWithSaltAndExpiry{ + Expiry: big.NewInt(0), + }, + true, + ) + if err != nil { + return err + } + common.PrintTransactionInfo(receipt.TxHash.String(), config.chainID) + } else { + noSendTxOpts := common.GetNoSendTxOpts(config.operatorAddress) + _, _, contractBindings, err := elcontracts.BuildClients(elcontracts.Config{ + DelegationManagerAddress: config.delegationManagerAddress, + }, ethClient, nil, logger, nil) + if err != nil { + return err + } + // If operator is a smart contract, we can't estimate gas using geth + // since balance of contract can be 0, as it can be called by an EOA + // to claim. So we hardcode the gas limit to 150_000 so that we can + // create unsigned tx without gas limit estimation from contract bindings + if common.IsSmartContractAddress(config.operatorAddress, ethClient) { + // address is a smart contract + noSendTxOpts.GasLimit = 150_000 + } + + unsignedTx, err := contractBindings.AllocationManager.ModifyAllocations( + noSendTxOpts, + config.operatorAddress, + allocationsToUpdate.Allocations, + contractIAllocationManager.ISignatureUtilsSignatureWithSaltAndExpiry{ + Expiry: big.NewInt(0), + }, + ) + if err != nil { + return eigenSdkUtils.WrapError("failed to create unsigned tx", err) + } + + if config.outputType == string(common.OutputType_Calldata) { + calldataHex := gethcommon.Bytes2Hex(unsignedTx.Data()) + if !common.IsEmptyString(config.output) { + err = common.WriteToFile([]byte(calldataHex), config.output) + if err != nil { + return err + } + logger.Infof("Call data written to file: %s", config.output) + } else { + fmt.Println(calldataHex) + } + } else { + if !common.IsEmptyString(config.output) { + fmt.Println("output file not supported for pretty output type") + fmt.Println() + } + allocationsToUpdate.Print() + } + txFeeDetails := common.GetTxFeeDetails(unsignedTx) + fmt.Println() + txFeeDetails.Print() + fmt.Println("To broadcast the transaction, use the --broadcast flag") + } + + return nil +} + +func getUpdateFlags() []cli.Flag { + baseFlags := []cli.Flag{ + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + &flags.OutputFileFlag, + &flags.OutputTypeFlag, + &flags.BroadcastFlag, + &flags.VerboseFlag, + &flags.AVSAddressFlag, + &flags.StrategyAddressFlag, + &flags.OperatorAddressFlag, + &flags.OperatorSetIdFlag, + &flags.CSVFileFlag, + &BipsToAllocateFlag, + } + allFlags := append(baseFlags, flags.GetSignerFlags()...) + sort.Sort(cli.FlagsByName(allFlags)) + return allFlags +} + +func generateAllocationsParams( + ctx context.Context, + elReader elChainReader, + config *updateConfig, + logger logging.Logger, +) (*BulkModifyAllocations, error) { + allocations := make([]contractIAllocationManager.IAllocationManagerMagnitudeAllocation, 0) + var allocatableMagnitudes map[gethcommon.Address]uint64 + + var err error + if len(config.csvFilePath) == 0 { + magnitude, err := elReader.GetTotalMagnitudes( + &bind.CallOpts{Context: ctx}, + config.operatorAddress, + []gethcommon.Address{config.strategyAddress}, + ) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to get latest total magnitude", err) + } + allocatableMagnitude, err := elReader.GetAllocatableMagnitude( + &bind.CallOpts{Context: ctx}, + config.operatorAddress, + config.strategyAddress, + ) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to get allocatable magnitude", err) + } + logger.Debugf("Total Magnitude: %d", magnitude) + logger.Debugf("Allocatable Magnitude: %d", allocatableMagnitude) + logger.Debugf("Bips to allocate: %d", config.bipsToAllocate) + magnitudeToUpdate := calculateMagnitudeToUpdate(magnitude[0], config.bipsToAllocate) + logger.Debugf("Magnitude to update: %d", magnitudeToUpdate) + malloc := contractIAllocationManager.IAllocationManagerMagnitudeAllocation{ + Strategy: config.strategyAddress, + ExpectedTotalMagnitude: magnitude[0], + OperatorSets: []contractIAllocationManager.OperatorSet{ + { + Avs: config.avsAddress, + OperatorSetId: config.operatorSetId, + }, + }, + Magnitudes: []uint64{magnitudeToUpdate}, + } + allocations = append(allocations, malloc) + } else { + allocations, allocatableMagnitudes, err = computeAllocations(config.csvFilePath, config.operatorAddress, elReader) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to compute allocations", err) + } + } + + return &BulkModifyAllocations{ + Allocations: allocations, + AllocatableMagnitudes: allocatableMagnitudes, + }, nil +} + +func computeAllocations( + filePath string, + operatorAddress gethcommon.Address, + elReader elChainReader, +) ([]contractIAllocationManager.IAllocationManagerMagnitudeAllocation, map[gethcommon.Address]uint64, error) { + allocations, err := parseAllocationsCSV(filePath) + if err != nil { + return nil, nil, eigenSdkUtils.WrapError("failed to parse allocations csv", err) + } + + err = validateDataFromCSV(allocations) + if err != nil { + return nil, nil, eigenSdkUtils.WrapError("failed to validate data from csv", err) + } + + strategies := getUniqueStrategies(allocations) + strategyTotalMagnitudes, err := getMagnitudes(strategies, operatorAddress, elReader) + if err != nil { + return nil, nil, eigenSdkUtils.WrapError("failed to get total magnitudes", err) + } + + allocatableMagnitudePerStrategy, err := parallelGetAllocatableMagnitudes(strategies, operatorAddress, elReader) + if err != nil { + return nil, nil, eigenSdkUtils.WrapError("failed to get allocatable magnitudes", err) + } + + magnitudeAllocations := convertAllocationsToMagnitudeAllocations(allocations, strategyTotalMagnitudes) + return magnitudeAllocations, allocatableMagnitudePerStrategy, nil +} + +func validateDataFromCSV(allocations []allocation) error { + // check for duplicated (avs_address,operator_set_id,strategy_address) + tuples := make(map[string]struct{}) + + for _, alloc := range allocations { + tuple := fmt.Sprintf("%s_%d_%s", alloc.AvsAddress.Hex(), alloc.OperatorSetId, alloc.StrategyAddress.Hex()) + if _, exists := tuples[tuple]; exists { + return fmt.Errorf( + "duplicate combination found: avs_address=%s, operator_set_id=%d, strategy_address=%s", + alloc.AvsAddress.Hex(), + alloc.OperatorSetId, + alloc.StrategyAddress.Hex(), + ) + } + tuples[tuple] = struct{}{} + } + + return nil +} + +func parallelGetAllocatableMagnitudes( + strategies []gethcommon.Address, + operatorAddress gethcommon.Address, + elReader elChainReader, +) (map[gethcommon.Address]uint64, error) { + strategyAllocatableMagnitudes := make(map[gethcommon.Address]uint64, len(strategies)) + var wg sync.WaitGroup + errChan := make(chan error, len(strategies)) + + for _, s := range strategies { + wg.Add(1) + go func(strategy gethcommon.Address) { + defer wg.Done() + magnitude, err := elReader.GetAllocatableMagnitude(&bind.CallOpts{}, operatorAddress, strategy) + if err != nil { + errChan <- err + return + } + strategyAllocatableMagnitudes[strategy] = magnitude + }(s) + } + + wg.Wait() + close(errChan) + + if len(errChan) > 0 { + return nil, <-errChan // Return the first error encountered + } + + return strategyAllocatableMagnitudes, nil +} + +func getMagnitudes( + strategies []gethcommon.Address, + operatorAddress gethcommon.Address, + reader elChainReader, +) (map[gethcommon.Address]uint64, error) { + strategyTotalMagnitudes := make(map[gethcommon.Address]uint64, len(strategies)) + totalMagnitudes, err := reader.GetTotalMagnitudes( + &bind.CallOpts{Context: context.Background()}, + operatorAddress, + strategies, + ) + if err != nil { + return nil, err + } + i := 0 + for _, strategy := range strategies { + strategyTotalMagnitudes[strategy] = totalMagnitudes[i] + i++ + } + + return strategyTotalMagnitudes, nil +} + +func getUniqueStrategies(allocations []allocation) []gethcommon.Address { + uniqueStrategies := make(map[gethcommon.Address]struct{}) + for _, a := range allocations { + uniqueStrategies[a.StrategyAddress] = struct{}{} + } + strategies := make([]gethcommon.Address, 0, len(uniqueStrategies)) + for s := range uniqueStrategies { + strategies = append(strategies, s) + } + return strategies +} + +func parseAllocationsCSV(filePath string) ([]allocation, error) { + var allocations []allocation + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + if err := gocsv.UnmarshalFile(file, &allocations); err != nil { + return nil, err + } + + return allocations, nil +} + +func convertAllocationsToMagnitudeAllocations( + allocations []allocation, + strategyTotalMagnitudes map[gethcommon.Address]uint64, +) []contractIAllocationManager.IAllocationManagerMagnitudeAllocation { + magnitudeAllocations := make([]contractIAllocationManager.IAllocationManagerMagnitudeAllocation, 0) + operatorSetsPerStragyMap := make(map[gethcommon.Address][]contractIAllocationManager.OperatorSet) + magnitudeAllocationsPerStrategyMap := make(map[gethcommon.Address][]uint64) + for _, a := range allocations { + totalMag := strategyTotalMagnitudes[a.StrategyAddress] + magnitudeToUpdate := calculateMagnitudeToUpdate(totalMag, a.Bips) + + operatorSets, ok := operatorSetsPerStragyMap[a.StrategyAddress] + if !ok { + operatorSets = make([]contractIAllocationManager.OperatorSet, 0) + } + operatorSets = append(operatorSets, contractIAllocationManager.OperatorSet{ + Avs: a.AvsAddress, + OperatorSetId: a.OperatorSetId, + }) + operatorSetsPerStragyMap[a.StrategyAddress] = operatorSets + + magnitudes := magnitudeAllocationsPerStrategyMap[a.StrategyAddress] + magnitudes = append(magnitudes, magnitudeToUpdate) + magnitudeAllocationsPerStrategyMap[a.StrategyAddress] = magnitudes + } + + for strategy, operatorSets := range operatorSetsPerStragyMap { + magnitudeAllocations = append(magnitudeAllocations, contractIAllocationManager.IAllocationManagerMagnitudeAllocation{ + Strategy: strategy, + ExpectedTotalMagnitude: strategyTotalMagnitudes[strategy], + OperatorSets: operatorSets, + Magnitudes: magnitudeAllocationsPerStrategyMap[strategy], + }) + } + return magnitudeAllocations +} + +func calculateMagnitudeToUpdate(totalMagnitude uint64, bipsToAllocate uint64) uint64 { + bigMagnitude := big.NewInt(int64(totalMagnitude)) + bigBipsToAllocate := big.NewInt(int64(bipsToAllocate)) + bigBipsMultiplier := big.NewInt(10_000) + bigMagnitudeToUpdate := bigMagnitude.Mul(bigMagnitude, bigBipsToAllocate).Div(bigMagnitude, bigBipsMultiplier) + return bigMagnitudeToUpdate.Uint64() +} + +func readAndValidateUpdateFlags(cCtx *cli.Context, logger logging.Logger) (*updateConfig, error) { + network := cCtx.String(flags.NetworkFlag.Name) + environment := cCtx.String(flags.EnvironmentFlag.Name) + logger.Debugf("Using network %s and environment: %s", network, environment) + + rpcUrl := cCtx.String(flags.ETHRpcUrlFlag.Name) + output := cCtx.String(flags.OutputFileFlag.Name) + outputType := cCtx.String(flags.OutputTypeFlag.Name) + broadcast := cCtx.Bool(flags.BroadcastFlag.Name) + + operatorAddress := gethcommon.HexToAddress(cCtx.String(flags.OperatorAddressFlag.Name)) + avsAddress := gethcommon.HexToAddress(cCtx.String(flags.AVSAddressFlag.Name)) + strategyAddress := gethcommon.HexToAddress(cCtx.String(flags.StrategyAddressFlag.Name)) + operatorSetId := uint32(cCtx.Uint64(flags.OperatorSetIdFlag.Name)) + bipsToAllocate := cCtx.Uint64(BipsToAllocateFlag.Name) + logger.Debugf( + "Operator address: %s, AVS address: %s, Strategy address: %s, Bips to allocate: %d", + operatorAddress.Hex(), + avsAddress.Hex(), + strategyAddress.Hex(), + bipsToAllocate, + ) + + // Get signerConfig + signerConfig, err := common.GetSignerConfig(cCtx, logger) + if err != nil { + // We don't want to throw error since people can still use it to generate the claim + // without broadcasting it + logger.Debugf("Failed to get signer config: %s", err) + } + + csvFilePath := cCtx.String(flags.CSVFileFlag.Name) + chainId := utils.NetworkNameToChainId(network) + + delegationManagerAddress, err := utils.GetDelegationManagerAddress(chainId) + if err != nil { + return nil, err + } + + return &updateConfig{ + network: network, + rpcUrl: rpcUrl, + environment: environment, + output: output, + outputType: outputType, + broadcast: broadcast, + operatorAddress: operatorAddress, + avsAddress: avsAddress, + strategyAddress: strategyAddress, + bipsToAllocate: bipsToAllocate, + signerConfig: signerConfig, + csvFilePath: csvFilePath, + operatorSetId: operatorSetId, + chainID: chainId, + delegationManagerAddress: gethcommon.HexToAddress(delegationManagerAddress), + }, nil +} diff --git a/pkg/operator/allocations/update_test.go b/pkg/operator/allocations/update_test.go new file mode 100644 index 0000000..153397f --- /dev/null +++ b/pkg/operator/allocations/update_test.go @@ -0,0 +1,275 @@ +package allocations + +import ( + "context" + "errors" + "math" + "os" + "testing" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/testutils" + + contractIAllocationManager "github.com/Layr-Labs/eigensdk-go/contracts/bindings/IAllocationManager" + "github.com/Layr-Labs/eigensdk-go/logging" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + + "github.com/stretchr/testify/assert" +) + +const ( + initialMagnitude = 1e18 +) + +type fakeElChainReader struct { + // operator --> strategy --> magnitude + allocatableMagnitudeMap map[gethcommon.Address]map[gethcommon.Address]uint64 + totalMagnitudeMap map[gethcommon.Address]map[gethcommon.Address]uint64 +} + +func newFakeElChainReader( + allocatableMagnitudeMap map[gethcommon.Address]map[gethcommon.Address]uint64, + totalMagnitudeMap map[gethcommon.Address]map[gethcommon.Address]uint64, +) *fakeElChainReader { + return &fakeElChainReader{ + allocatableMagnitudeMap: allocatableMagnitudeMap, + totalMagnitudeMap: totalMagnitudeMap, + } +} + +func (f *fakeElChainReader) GetTotalMagnitudes( + opts *bind.CallOpts, + operator gethcommon.Address, + strategyAddresses []gethcommon.Address, +) ([]uint64, error) { + stratMap, ok := f.totalMagnitudeMap[operator] + if !ok { + return []uint64{}, errors.New("operator not found") + } + + // iterate over strategyAddresses and return the corresponding magnitudes + magnitudes := make([]uint64, 0, len(strategyAddresses)) + for _, strategy := range strategyAddresses { + magnitude, ok := stratMap[strategy] + if !ok { + magnitude = 0 + } + magnitudes = append(magnitudes, magnitude) + } + return magnitudes, nil +} + +func (f *fakeElChainReader) GetAllocatableMagnitude( + opts *bind.CallOpts, + operator gethcommon.Address, + strategy gethcommon.Address, +) (uint64, error) { + stratMap, ok := f.allocatableMagnitudeMap[operator] + if !ok { + return initialMagnitude, nil + } + + magnitude, ok := stratMap[strategy] + if !ok { + return initialMagnitude, nil + } + return magnitude, nil +} + +func TestGenerateAllocationsParams(t *testing.T) { + avsAddress := testutils.GenerateRandomEthereumAddressString() + strategyAddress := testutils.GenerateRandomEthereumAddressString() + operatorAddress := testutils.GenerateRandomEthereumAddressString() + tests := []struct { + name string + config *updateConfig + expectError bool + expectedAllocations *BulkModifyAllocations + }{ + { + name: "simple single allocation without csv", + config: &updateConfig{ + operatorAddress: gethcommon.HexToAddress("0x2222AAC0C980Cc029624b7ff55B88Bc6F63C538f"), + avsAddress: gethcommon.HexToAddress(avsAddress), + strategyAddress: gethcommon.HexToAddress(strategyAddress), + bipsToAllocate: 1000, + operatorSetId: 1, + }, + expectError: false, + expectedAllocations: &BulkModifyAllocations{ + Allocations: []contractIAllocationManager.IAllocationManagerMagnitudeAllocation{ + { + Strategy: gethcommon.HexToAddress(strategyAddress), + ExpectedTotalMagnitude: initialMagnitude, + OperatorSets: []contractIAllocationManager.OperatorSet{ + { + OperatorSetId: 1, + Avs: gethcommon.HexToAddress(avsAddress), + }, + }, + Magnitudes: []uint64{1e17}, + }, + }, + }, + }, + { + name: "csv file allocations1.csv", + config: &updateConfig{ + csvFilePath: "testdata/allocations1.csv", + operatorAddress: gethcommon.HexToAddress(operatorAddress), + }, + expectError: false, + expectedAllocations: &BulkModifyAllocations{ + Allocations: []contractIAllocationManager.IAllocationManagerMagnitudeAllocation{ + { + Strategy: gethcommon.HexToAddress("0x49989b32351Eb9b8ab2d5623cF22E7F7C23e5630"), + ExpectedTotalMagnitude: initialMagnitude, + OperatorSets: []contractIAllocationManager.OperatorSet{ + { + OperatorSetId: 1, + Avs: gethcommon.HexToAddress("0x2222AAC0C980Cc029624b7ff55B88Bc6F63C538f"), + }, + { + OperatorSetId: 3, + Avs: gethcommon.HexToAddress("0x2222AAC0C980Cc029624b7ff55B88Bc6F63C538f"), + }, + }, + Magnitudes: []uint64{2e17, 1e17}, + }, + { + Strategy: gethcommon.HexToAddress("0x232326fE4F8C2f83E3eB2318F090557b7CD02222"), + ExpectedTotalMagnitude: initialMagnitude, + OperatorSets: []contractIAllocationManager.OperatorSet{ + { + OperatorSetId: 4, + Avs: gethcommon.HexToAddress("0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76"), + }, + }, + Magnitudes: []uint64{3e17}, + }, + { + Strategy: gethcommon.HexToAddress("0x545456fE4F8C2f83E3eB2318F090557b7CD04567"), + ExpectedTotalMagnitude: initialMagnitude, + OperatorSets: []contractIAllocationManager.OperatorSet{ + { + OperatorSetId: 5, + Avs: gethcommon.HexToAddress("0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76"), + }, + }, + Magnitudes: []uint64{4e17}, + }, + }, + AllocatableMagnitudes: map[gethcommon.Address]uint64{ + gethcommon.HexToAddress("0x49989b32351Eb9b8ab2d5623cF22E7F7C23e5630"): initialMagnitude, + gethcommon.HexToAddress("0x232326fE4F8C2f83E3eB2318F090557b7CD02222"): initialMagnitude, + gethcommon.HexToAddress("0x545456fE4F8C2f83E3eB2318F090557b7CD04567"): initialMagnitude, + }, + }, + }, + { + name: "csv file allocations_duplicate.csv", + config: &updateConfig{ + csvFilePath: "testdata/allocations_duplicate.csv", + operatorAddress: gethcommon.HexToAddress(operatorAddress), + }, + expectError: true, + }, + } + + elReader := newFakeElChainReader( + map[gethcommon.Address]map[gethcommon.Address]uint64{ + gethcommon.HexToAddress("0x2222AAC0C980Cc029624b7ff55B88Bc6F63C538f"): { + gethcommon.HexToAddress(strategyAddress): initialMagnitude, + }, + gethcommon.HexToAddress(operatorAddress): { + gethcommon.HexToAddress("0x49989b32351Eb9b8ab2d5623cF22E7F7C23e5630"): initialMagnitude, + gethcommon.HexToAddress("0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76"): initialMagnitude, + gethcommon.HexToAddress("0x545456fE4F8C2f83E3eB2318F090557b7CD04567"): initialMagnitude, + }, + }, + map[gethcommon.Address]map[gethcommon.Address]uint64{ + gethcommon.HexToAddress("0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76"): { + gethcommon.HexToAddress(strategyAddress): initialMagnitude, + }, + gethcommon.HexToAddress(operatorAddress): { + gethcommon.HexToAddress("0x49989b32351Eb9b8ab2d5623cF22E7F7C23e5630"): initialMagnitude, + gethcommon.HexToAddress("0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76"): initialMagnitude, + gethcommon.HexToAddress("0x545456fE4F8C2f83E3eB2318F090557b7CD04567"): initialMagnitude, + }, + }, + ) + + logger := logging.NewTextSLogger(os.Stdout, &logging.SLoggerOptions{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + allocations, err := generateAllocationsParams(context.Background(), elReader, tt.config, logger) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedAllocations, allocations) + } + }) + } +} + +func TestCalculateMagnitudeToUpdate(t *testing.T) { + tests := []struct { + name string + totalMagnitude uint64 + bipsToAllocate uint64 + expectedMagnitude uint64 + }{ + { + name: "Valid inputs", + totalMagnitude: 1e18, + bipsToAllocate: 1000, + expectedMagnitude: 1e17, + }, + { + name: "Zero total magnitude", + totalMagnitude: 0, + bipsToAllocate: 1000, + expectedMagnitude: 0, + }, + { + name: "Zero bips to allocate", + totalMagnitude: 1e18, + bipsToAllocate: 0, + expectedMagnitude: 0, + }, + { + name: "Max uint64 values", + totalMagnitude: math.MaxUint64, + bipsToAllocate: math.MaxUint64, + expectedMagnitude: 0, // Result of overflow + }, + { + name: "Valid inputs 1", + totalMagnitude: 1e18, + bipsToAllocate: 2555, + expectedMagnitude: 2555e14, + }, + { + name: "Valid inputs 2", + totalMagnitude: 1e18, + bipsToAllocate: 313, + expectedMagnitude: 313e14, + }, + { + name: "Valid inputs 3", + totalMagnitude: 1e18, + bipsToAllocate: 3, + expectedMagnitude: 3e14, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := calculateMagnitudeToUpdate(tt.totalMagnitude, tt.bipsToAllocate) + assert.Equal(t, tt.expectedMagnitude, result) + }) + } +} diff --git a/pkg/operator/config/create.go b/pkg/operator/config/create.go index f07e308..ee12af0 100644 --- a/pkg/operator/config/create.go +++ b/pkg/operator/config/create.go @@ -6,6 +6,7 @@ import ( "fmt" "math/big" "os" + "strconv" "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common" "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" @@ -158,6 +159,39 @@ func promptOperatorInfo(config *types.OperatorConfig, p utils.Prompter) (types.O } config.EthRPCUrl = rpcUrl + // Prompt for allocation delay + allocationDelay, err := p.InputInteger( + "Enter your allocation delay (in seconds, default is 17.5 days):", + "1512000", + "", + func(i int64) error { + if i < 0 { + return errors.New("allocation delay should be non-negative") + } + return nil + }, + ) + if err != nil { + return types.OperatorConfig{}, err + } + + // confirm again + confirm, err := p.Confirm( + "Are you sure you want to set the allocation delay to " + strconv.FormatInt( + allocationDelay, + 10, + ) + " seconds? This cannot be changed once set.", + ) + if err != nil { + return types.OperatorConfig{}, err + } + + if confirm { + config.Operator.AllocationDelay = uint32(allocationDelay) + } else { + return types.OperatorConfig{}, errors.New("operator cancelled") + } + // Prompt for network & set chainId chainId, err := p.Select("Select your network:", []string{"mainnet", "holesky", "local"}) if err != nil { diff --git a/pkg/operator/register.go b/pkg/operator/register.go index 964649a..d02e3d2 100644 --- a/pkg/operator/register.go +++ b/pkg/operator/register.go @@ -45,6 +45,7 @@ func RegisterCmd(p utils.Prompter) *cli.Command { configurationFilePath := args.Get(0) operatorCfg, err := common.ValidateAndReturnConfig(configurationFilePath, logger) + logger.Debugf("operatorCfg: %v", operatorCfg) if err != nil { return err } diff --git a/pkg/rewards/claim.go b/pkg/rewards/claim.go index 8721960..fb157ce 100644 --- a/pkg/rewards/claim.go +++ b/pkg/rewards/claim.go @@ -182,11 +182,7 @@ func Claim(cCtx *cli.Context, p utils.Prompter) error { // since balance of contract can be 0, as it can be called by an EOA // to claim. So we hardcode the gas limit to 150_000 so that we can // create unsigned tx without gas limit estimation from contract bindings - code, err := ethClient.CodeAt(ctx, config.ClaimerAddress, nil) - if err != nil { - return eigenSdkUtils.WrapError("failed to get code at address", err) - } - if len(code) > 0 { + if common.IsSmartContractAddress(config.ClaimerAddress, ethClient) { // Claimer is a smart contract noSendTxOpts.GasLimit = 150_000 }