diff --git a/.gitignore b/.gitignore index ef990a37..9f1cd9e5 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/cmd/eigenlayer/main.go b/cmd/eigenlayer/main.go index 57367989..a7f23519 100644 --- a/cmd/eigenlayer/main.go +++ b/cmd/eigenlayer/main.go @@ -43,6 +43,7 @@ func main() { app.Commands = append(app.Commands, pkg.RewardsCmd(prompter)) app.Commands = append(app.Commands, pkg.KeysCmd(prompter)) app.Commands = append(app.Commands, pkg.EigenPodCmd(prompter)) + app.Commands = append(app.Commands, pkg.UserCmd()) if err := app.Run(os.Args); err != nil { _, err := fmt.Fprintln(os.Stderr, err) diff --git a/go.mod b/go.mod index df6f6200..00be986c 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/Layr-Labs/eigenlayer-contracts v0.3.2-mainnet-rewards github.com/Layr-Labs/eigenlayer-rewards-proofs v0.2.12 github.com/Layr-Labs/eigenpod-proofs-generation v0.0.14-stable.0.20240730152248-5c11a259293e - github.com/Layr-Labs/eigensdk-go v0.1.13-0.20241023200243-565bb4438918 + github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241210000422-beb1a3c4502f github.com/blang/semver/v4 v4.0.0 github.com/consensys/gnark-crypto v0.12.1 github.com/ethereum/go-ethereum v1.14.5 diff --git a/go.sum b/go.sum index 1784a9a8..4d332187 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,14 @@ github.com/Layr-Labs/eigenlayer-rewards-proofs v0.2.12 h1:G5Q1SnLmFbEjhOkky3vIHk github.com/Layr-Labs/eigenlayer-rewards-proofs v0.2.12/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.13-0.20241023200243-565bb4438918 h1:Itl141PoMFzq58ZTo4Nu/CyH+x8f4BH6OmBNhZ6Z2/I= -github.com/Layr-Labs/eigensdk-go v0.1.13-0.20241023200243-565bb4438918/go.mod h1:aYdNURUhaqeYOS+Cq12TfSdPbjFfiLaHkxPdR4Exq/s= +github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241122062350-76ffacff7c4e h1:l+iq4tg0iHZjHOHWzLxoLDVEhhEEuK0iCBiuWXqZpy0= +github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241122062350-76ffacff7c4e/go.mod h1:aYdNURUhaqeYOS+Cq12TfSdPbjFfiLaHkxPdR4Exq/s= +github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241204193922-c60d25970459 h1:37Upmqc4RTTNjmtzkEshKZu4yis8ogSEnrQmLqZ8cxU= +github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241204193922-c60d25970459/go.mod h1:aYdNURUhaqeYOS+Cq12TfSdPbjFfiLaHkxPdR4Exq/s= +github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241205221048-fe823e1c912f h1:A/rKI4aDTTgfzDacgKwyV/XMFdibxbc2WXavaWPzn1Q= +github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241205221048-fe823e1c912f/go.mod h1:aYdNURUhaqeYOS+Cq12TfSdPbjFfiLaHkxPdR4Exq/s= +github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241210000422-beb1a3c4502f h1:D94Vf+dALr9W0Ie18lZ8QDPvOAFX8FBbIpyVAtCUL1A= +github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241210000422-beb1a3c4502f/go.mod h1:aYdNURUhaqeYOS+Cq12TfSdPbjFfiLaHkxPdR4Exq/s= 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 b6adf130..ff7fe70a 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 59123dbc..5cd9162e 100644 --- a/pkg/internal/common/eth.go +++ b/pkg/internal/common/eth.go @@ -3,8 +3,10 @@ package common import ( "fmt" "math/big" + "strconv" "strings" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" ) @@ -46,3 +48,47 @@ 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 +} + +func ShortEthAddress(address common.Address) string { + return fmt.Sprintf("%s...%s", address.Hex()[:6], address.Hex()[len(address.Hex())-4:]) +} + +func Uint64ToString(num uint64) string { + return strconv.FormatUint(num, 10) +} + +func FormatNumberWithUnderscores(numStr string) string { + + // If the number is less than 1000, no formatting is needed + if len(numStr) <= 3 { + return numStr + } + + // Calculate the number of groups of 3 digits + groups := (len(numStr) - 1) / 3 + + // Create a slice to hold the result + result := make([]byte, len(numStr)+groups) + + // Fill the result slice from right to left + resultIndex := len(result) - 1 + for i := len(numStr) - 1; i >= 0; i-- { + if (len(numStr)-i-1)%3 == 0 && i != len(numStr)-1 { + result[resultIndex] = '_' + resultIndex-- + } + result[resultIndex] = numStr[i] + resultIndex-- + } + + return string(result) +} diff --git a/pkg/internal/common/flags/avs.go b/pkg/internal/common/flags/avs.go new file mode 100644 index 00000000..702a6d3c --- /dev/null +++ b/pkg/internal/common/flags/avs.go @@ -0,0 +1,47 @@ +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"}, + } + + OperatorSetIdsFlag = cli.Uint64SliceFlag{ + Name: "operator-set-ids", + Usage: "Operator set IDs. Comma separated list of operator set IDs", + Aliases: []string{"osids"}, + EnvVars: []string{"OPERATOR_SET_IDS"}, + } +) diff --git a/pkg/internal/common/flags/general.go b/pkg/internal/common/flags/general.go index 194421ce..7ddeaf35 100644 --- a/pkg/internal/common/flags/general.go +++ b/pkg/internal/common/flags/general.go @@ -90,4 +90,32 @@ var ( EnvVars: []string{"EXPIRY"}, Value: 3600, } + + 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"}, + } + + DelegationManagerAddressFlag = cli.StringFlag{ + Name: "delegation-manager-address", + Aliases: []string{"dma"}, + Usage: "Optional delegation manager address. This can be used if you are testing against your own deployment of eigenlayer contracts", + EnvVars: []string{"DELEGATION_MANAGER_ADDRESS"}, + } ) diff --git a/pkg/internal/common/helper.go b/pkg/internal/common/helper.go index 6cfe066a..bbd282ad 100644 --- a/pkg/internal/common/helper.go +++ b/pkg/internal/common/helper.go @@ -3,6 +3,7 @@ package common import ( "context" "crypto/ecdsa" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -14,8 +15,6 @@ import ( "strings" "time" - "github.com/urfave/cli/v2" - "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" "github.com/Layr-Labs/eigenlayer-cli/pkg/types" "github.com/Layr-Labs/eigenlayer-cli/pkg/utils" @@ -36,6 +35,15 @@ import ( "github.com/ethereum/go-ethereum/ethclient" "github.com/fatih/color" + "github.com/urfave/cli/v2" +) + +const ( + mainnet = "mainnet" + testnet = "testnet" + local = "local" + selectorHexIdLength = 10 + addressPrefix = "0x" ) var ChainMetadataMap = map[int64]types.ChainMetadata{ @@ -44,6 +52,7 @@ var ChainMetadataMap = map[int64]types.ChainMetadata{ ELDelegationManagerAddress: "0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A", ELAVSDirectoryAddress: "0x135dda560e946695d6f155dacafc6f1f25c1f5af", ELRewardsCoordinatorAddress: "0x7750d328b314EfFa365A0402CcfD489B80B0adda", + ELPermissionManagerAddress: "", WebAppUrl: "https://app.eigenlayer.xyz/operator", ProofStoreBaseURL: "https://eigenlabs-rewards-mainnet-ethereum.s3.amazonaws.com", }, @@ -52,6 +61,7 @@ var ChainMetadataMap = map[int64]types.ChainMetadata{ ELDelegationManagerAddress: "0xA44151489861Fe9e3055d95adC98FbD462B948e7", ELAVSDirectoryAddress: "0x055733000064333CaDDbC92763c58BF0192fFeBf", ELRewardsCoordinatorAddress: "0xAcc1fb458a1317E886dB376Fc8141540537E68fE", + ELPermissionManagerAddress: "", WebAppUrl: "https://holesky.eigenlayer.xyz/operator", ProofStoreBaseURL: "https://eigenlabs-rewards-testnet-holesky.s3.amazonaws.com", }, @@ -60,6 +70,7 @@ var ChainMetadataMap = map[int64]types.ChainMetadata{ ELDelegationManagerAddress: "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9", ELAVSDirectoryAddress: "0x0165878A594ca255338adfa4d48449f69242Eb8F", ELRewardsCoordinatorAddress: "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", + ELPermissionManagerAddress: "", WebAppUrl: "", ProofStoreBaseURL: "", }, @@ -321,6 +332,26 @@ func GetAVSDirectoryAddress(chainID *big.Int) (string, error) { } } +func GetDelegationManagerAddress(chainID *big.Int) (string, error) { + chainIDInt := chainID.Int64() + chainMetadata, ok := ChainMetadataMap[chainIDInt] + if !ok { + return "", fmt.Errorf("chain ID %d not supported", chainIDInt) + } else { + return chainMetadata.ELDelegationManagerAddress, nil + } +} + +func GetPermissionManagerAddress(chainID *big.Int) (string, error) { + chainIDInt := chainID.Int64() + chainMetadata, ok := ChainMetadataMap[chainIDInt] + if !ok { + return "", fmt.Errorf("chain ID %d not supported", chainIDInt) + } else { + return chainMetadata.ELDelegationManagerAddress, nil + } +} + func GetTransactionLink(txHash string, chainId *big.Int) string { chainIDInt := chainId.Int64() chainMetadata, ok := ChainMetadataMap[chainIDInt] @@ -480,7 +511,7 @@ func GetNoSendTxOpts(from common.Address) *bind.TransactOpts { } func Trim0x(s string) string { - return strings.TrimPrefix(s, "0x") + return strings.TrimPrefix(s, addressPrefix) } func Sign(digest []byte, cfg types.SignerConfig, p utils.Prompter) ([]byte, error) { @@ -533,3 +564,34 @@ func Sign(digest []byte, cfg types.SignerConfig, p utils.Prompter) ([]byte, erro return signed, nil } + +func ValidateAndConvertSelectorString(selector string) ([4]byte, error) { + if len(selector) != selectorHexIdLength || selector[:2] != addressPrefix { + return [4]byte{}, errors.New("selector must be a 4-byte hex string prefixed with '0x'") + } + + decoded, err := hex.DecodeString(selector[2:]) + if err != nil { + return [4]byte{}, eigenSdkUtils.WrapError("invalid hex encoding: %v", err) + } + + if len(decoded) != 4 { + return [4]byte{}, fmt.Errorf("decoded selector must be 4 bytes, got %d bytes", len(decoded)) + } + + var selectorBytes [4]byte + copy(selectorBytes[:], decoded) + + return selectorBytes, nil +} + +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 f3ca2ce4..03e64f78 100644 --- a/pkg/operator.go +++ b/pkg/operator.go @@ -18,6 +18,9 @@ func OperatorCmd(p utils.Prompter) *cli.Command { operator.UpdateCmd(p), operator.UpdateMetadataURICmd(p), operator.GetApprovalCmd(p), + operator.AllocationsCmd(p), + operator.DeregisterCommand(p), + operator.RegisterOperatorSetsCommand(p), }, } diff --git a/pkg/operator/allocations.go b/pkg/operator/allocations.go new file mode 100644 index 00000000..67d5a304 --- /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.SetDelayCmd(p), + }, + } + + return allocationsCmd +} diff --git a/pkg/operator/allocations/README.md b/pkg/operator/allocations/README.md new file mode 100644 index 00000000..3f8cf16c --- /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 00000000..cb096919 --- /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/set_allocation_delay.go b/pkg/operator/allocations/set_allocation_delay.go new file mode 100644 index 00000000..82af2b8c --- /dev/null +++ b/pkg/operator/allocations/set_allocation_delay.go @@ -0,0 +1,208 @@ +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 SetDelayCmd(p utils.Prompter) *cli.Command { + setDelayCmd := &cli.Command{ + Name: "set-delay", + UsageText: "set-delay [flags] ", + Usage: "Set the allocation delay for operator in blocks", + Description: "Set the allocation delay for operator. It will take effect after the delay period", + Flags: getSetAllocationDelayFlags(), + After: telemetry.AfterRunAction(), + Action: func(c *cli.Context) error { + return setDelayAction(c, p) + }, + } + + return setDelayCmd +} + +func setDelayAction(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) + } + + if config.broadcast { + confirm, err := p.Confirm( + "This will set the allocation delay for operator. 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.SetAllocationDelay(ctx, config.operatorAddress, 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.AllocationManager.SetAllocationDelay(noSendTxOpts, config.operatorAddress, 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 getSetAllocationDelayFlags() []cli.Flag { + baseFlags := []cli.Flag{ + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + &flags.OutputFileFlag, + &flags.OutputTypeFlag, + &flags.BroadcastFlag, + &flags.VerboseFlag, + &flags.OperatorAddressFlag, + &flags.DelegationManagerAddressFlag, + } + 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 := c.String(flags.DelegationManagerAddressFlag.Name) + if delegationManagerAddress == "" { + delegationManagerAddress, err = common.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 00000000..27208dbc --- /dev/null +++ b/pkg/operator/allocations/show.go @@ -0,0 +1,318 @@ +package allocations + +import ( + "fmt" + "math/big" + "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" + allocationmanager "github.com/Layr-Labs/eigensdk-go/contracts/bindings/AllocationManager" + "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" +) + +var ( + // PrecisionFactor comes from the allocation manager contract + PrecisionFactor = big.NewInt(1e18) +) + +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) + } + + 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) + } + + /* + 1. Get the allocatable magnitude for all strategies + */ + for _, strategyAddress := range config.strategyAddresses { + allocatableMagnitude, err := elReader.GetAllocatableMagnitude( + ctx, + config.operatorAddress, + strategyAddress, + ) + if err != nil { + return eigenSdkUtils.WrapError("failed to get allocatable magnitude", err) + } + logger.Debugf( + "Allocatable magnitude for strategy %v: %s", + strategyAddress, + common.FormatNumberWithUnderscores(common.Uint64ToString(allocatableMagnitude)), + ) + } + + /* + 2. Get the total magnitude for all strategies + */ + totalMagnitudeMap := make(map[string]uint64) + totalMagnitudes, err := elReader.GetMaxMagnitudes( + ctx, + config.operatorAddress, + config.strategyAddresses, + ) + if err != nil { + return eigenSdkUtils.WrapError("failed to get allocatable magnitude", err) + } + for i, strategyAddress := range config.strategyAddresses { + totalMagnitudeMap[strategyAddress.String()] = totalMagnitudes[i] + } + + /* + 3. Get allocation info for the operator + */ + allAllocations := make(map[string][]elcontracts.AllocationInfo, len(config.strategyAddresses)) + for _, strategyAddress := range config.strategyAddresses { + allocations, err := elReader.GetAllocationInfo( + ctx, + config.operatorAddress, + strategyAddress, + ) + if err != nil { + return eigenSdkUtils.WrapError("failed to get allocations", err) + } + allAllocations[strategyAddress.String()] = allocations + } + + /* + 4. Get the operator's registered operator sets + */ + registeredOperatorSets, err := elReader.GetRegisteredSets(ctx, config.operatorAddress) + if err != nil { + return eigenSdkUtils.WrapError("failed to get registered operator sets", err) + } + registeredOperatorSetsMap := make(map[string]allocationmanager.OperatorSet) + for _, opSet := range registeredOperatorSets { + registeredOperatorSetsMap[getUniqueKey(opSet.Avs, opSet.Id)] = opSet + } + + /* + 5. Get the operator scaled shares for all strategies + */ + operatorDelegatedSharesMap := make(map[string]*big.Int) + shares, err := elReader.GetOperatorShares(ctx, config.operatorAddress, config.strategyAddresses) + if err != nil { + return eigenSdkUtils.WrapError("failed to get operator shares", err) + } + for i, strategyAddress := range config.strategyAddresses { + operatorDelegatedSharesMap[strategyAddress.String()] = shares[i] + } + + /* + 6. Using all of the above, calculate SlashableMagnitudeHolders object + for displaying the allocation state of the operator + */ + slashableMagnitudeHolders := make(SlashableMagnitudeHolders, 0) + dergisteredOpsets := make(DeregsiteredOperatorSets, 0) + for strategy, allocations := range allAllocations { + strategyShares := operatorDelegatedSharesMap[strategy] + for _, alloc := range allocations { + currentShares, currentSharesPercentage := getSharesFromMagnitude( + strategyShares, + alloc.CurrentMagnitude.Uint64(), + ) + newMagnitudeBigInt := big.NewInt(0) + if alloc.PendingDiff.Cmp(big.NewInt(0)) != 0 { + newMagnitudeBigInt = big.NewInt(0).Add(alloc.CurrentMagnitude, alloc.PendingDiff) + } + newShares, newSharesPercentage := getSharesFromMagnitude(strategyShares, newMagnitudeBigInt.Uint64()) + + // Check if the operator set is not registered and add it to the unregistered list + // Then skip the rest of the loop + if _, ok := registeredOperatorSetsMap[getUniqueKey(alloc.AvsAddress, alloc.OperatorSetId)]; !ok { + dergisteredOpsets = append(dergisteredOpsets, DeregisteredOperatorSet{ + StrategyAddress: gethcommon.HexToAddress(strategy), + AVSAddress: alloc.AvsAddress, + OperatorSetId: alloc.OperatorSetId, + SlashableMagnitude: alloc.CurrentMagnitude.Uint64(), + Shares: currentShares, + SharesPercentage: currentSharesPercentage.String(), + }) + continue + } + + // Add the operator set to the registered list + slashableMagnitudeHolders = append(slashableMagnitudeHolders, SlashableMagnitudesHolder{ + StrategyAddress: gethcommon.HexToAddress(strategy), + AVSAddress: alloc.AvsAddress, + OperatorSetId: alloc.OperatorSetId, + SlashableMagnitude: alloc.CurrentMagnitude.Uint64(), + Shares: currentShares, + SharesPercentage: currentSharesPercentage.String(), + NewMagnitude: newMagnitudeBigInt.Uint64(), + UpdateBlock: alloc.EffectBlock, + NewAllocationShares: newShares, + UpcomingSharesPercentage: newSharesPercentage.String(), + }) + } + } + + for key, val := range operatorDelegatedSharesMap { + fmt.Printf("Strategy Address: %s, Shares %s\n", key, val.String()) + } + + currBlockNumber, err := ethClient.BlockNumber(ctx) + if err != nil { + return eigenSdkUtils.WrapError("failed to get current block number", err) + } + delay, err := elReader.GetAllocationDelay(ctx, config.operatorAddress) + if err != nil { + return err + } + fmt.Println() + fmt.Printf("Current allocation delay: %d blocks\n", delay) + fmt.Println() + fmt.Printf( + "------------------ Allocation State for %s (Block: %d) ---------------------\n", + config.operatorAddress.String(), + currBlockNumber, + ) + if config.outputType == string(common.OutputType_Json) { + slashableMagnitudeHolders.PrintJSON() + } else { + slashableMagnitudeHolders.PrintPretty() + } + + if len(dergisteredOpsets) > 0 { + fmt.Println() + fmt.Printf( + "NOTE: You have %d deregistered operator sets which have nonzero allocations as listed below. Please deallocate to use those funds.\n", + len(dergisteredOpsets), + ) + if config.outputType == string(common.OutputType_Json) { + dergisteredOpsets.PrintJSON() + } else { + dergisteredOpsets.PrintPretty() + } + } + + return nil +} + +func getSharesFromMagnitude(totalScaledShare *big.Int, magnitude uint64) (*big.Int, *big.Float) { + + /* + * shares = totalScaledShare * magnitude / PrecisionFactor + * percentageShares = (shares / totalScaledShare) * 100 + */ + // Check for zero magnitude or totalScaledShare to avoid divide-by-zero errors + if magnitude == 0 || totalScaledShare.Cmp(big.NewInt(0)) == 0 { + return big.NewInt(0), big.NewFloat(0) + } + + slashableMagBigInt := big.NewInt(1) + slashableMagBigInt = slashableMagBigInt.SetUint64(magnitude) + + scaledOpShares := big.NewInt(1) + scaledOpShares = scaledOpShares.Set(totalScaledShare) + scaledOpShares = scaledOpShares.Div(scaledOpShares, PrecisionFactor) + shares := scaledOpShares.Mul(scaledOpShares, slashableMagBigInt) + + percentageShares := big.NewInt(1) + percentageShares = percentageShares.Mul(scaledOpShares, big.NewInt(100)) + percentageSharesFloat := new( + big.Float, + ).Quo(new(big.Float).SetInt(percentageShares), new(big.Float).SetInt(totalScaledShare)) + + return shares, percentageSharesFloat +} + +func getUniqueKey(strategyAddress gethcommon.Address, opSetId uint32) string { + return fmt.Sprintf("%s-%d", strategyAddress.String(), opSetId) +} + +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 := cCtx.String(flags.DelegationManagerAddressFlag.Name) + var err error + if delegationManagerAddress == "" { + delegationManagerAddress, err = common.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, + &flags.DelegationManagerAddressFlag, + } + + 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 00000000..f6d13110 --- /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 00000000..0da774fb --- /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 00000000..2ceb1ac0 --- /dev/null +++ b/pkg/operator/allocations/types.go @@ -0,0 +1,294 @@ +package allocations + +import ( + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common" + "github.com/Layr-Labs/eigenlayer-cli/pkg/types" + + allocationmanager "github.com/Layr-Labs/eigensdk-go/contracts/bindings/AllocationManager" + + gethcommon "github.com/ethereum/go-ethereum/common" +) + +type BulkModifyAllocations struct { + Allocations []allocationmanager.IAllocationManagerTypesAllocateParams + AllocatableMagnitudes map[gethcommon.Address]uint64 +} + +func (b *BulkModifyAllocations) PrintPretty() { + + fmt.Println() + fmt.Println("Allocations to be Updated") + allocations := b.Allocations + headers := []string{ + "Strategy", + "Allocatable Magnitude", + "Operator Set ID", + "AVS", + "Magnitude", + } + widths := []int{20, 25, 20, 20, 25} + + // 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 _, a := range allocations { + for i, strategy := range a.Strategies { + fmt.Printf( + "| %-*s| %-*s| %-*d| %-*s| %-*s|\n", + widths[0], + common.ShortEthAddress(strategy), + widths[1], + common.FormatNumberWithUnderscores(common.Uint64ToString(b.AllocatableMagnitudes[strategy])), + widths[2], + a.OperatorSet.Id, + widths[3], + common.ShortEthAddress(a.OperatorSet.Avs), + widths[4], + common.FormatNumberWithUnderscores(common.Uint64ToString(a.NewMagnitudes[i])), + ) + } + } + + // print dashes + for _, width := range widths { + fmt.Print("+" + strings.Repeat("-", width+1)) + } + + 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 + isSilent bool +} + +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 + NewMagnitude uint64 + NewAllocationShares *big.Int + UpdateBlock uint32 + Shares *big.Int + SharesPercentage string + UpcomingSharesPercentage string +} + +func (s SlashableMagnitudeHolders) PrintPretty() { + // Define column headers and widths + headers := []string{ + "Strategy Address", + "AVS Address", + "OperatorSet ID", + "Slashable Shares (Wei)", + "Shares %", + "Upcoming Shares (Wei)", + "Upcoming Shares %", + "Update Block", + } + widths := []int{len(headers[0]) + 1, len(headers[1]) + 3, 15, 30, 25, 30, 25, 25} + + // 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 { + + upcomingSharesDisplay := common.FormatNumberWithUnderscores(holder.NewAllocationShares.String()) + + fmt.Printf("| %-*s| %-*s| %-*d| %-*s| %-*s| %-*s| %-*s| %-*d|\n", + widths[0], common.ShortEthAddress(holder.StrategyAddress), + widths[1], common.ShortEthAddress(holder.AVSAddress), + widths[2], holder.OperatorSetId, + widths[3], common.FormatNumberWithUnderscores(holder.Shares.String()), + widths[4], holder.SharesPercentage+" %", + widths[5], upcomingSharesDisplay, + widths[6], holder.UpcomingSharesPercentage+" %", + widths[7], holder.UpdateBlock, + ) + } + + // print dashes + for _, width := range widths { + fmt.Print("+" + strings.Repeat("-", width+1)) + } + fmt.Println("+") +} + +func (s SlashableMagnitudeHolders) PrintJSON() { + obj, err := json.MarshalIndent(s, "", " ") + if err != nil { + fmt.Println("Error marshalling to JSON:", err) + return + } + fmt.Println(string(obj)) +} + +type DeregsiteredOperatorSets []DeregisteredOperatorSet +type DeregisteredOperatorSet struct { + StrategyAddress gethcommon.Address + AVSAddress gethcommon.Address + OperatorSetId uint32 + SlashableMagnitude uint64 + Shares *big.Int + SharesPercentage string +} + +func (s DeregsiteredOperatorSets) PrintPretty() { + // Define column headers and widths + headers := []string{ + "Strategy Address", + "AVS Address", + "OperatorSet ID", + "Slashable Shares (Wei)", + "Shares %", + } + widths := []int{len(headers[0]) + 1, len(headers[1]) + 3, 15, 30, 25} + + // 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| %-*s| %-*s|\n", + widths[0], common.ShortEthAddress(holder.StrategyAddress), + widths[1], common.ShortEthAddress(holder.AVSAddress), + widths[2], holder.OperatorSetId, + widths[3], common.FormatNumberWithUnderscores(holder.Shares.String()), + widths[4], holder.SharesPercentage+" %", + ) + } + + // print dashes + for _, width := range widths { + fmt.Print("+" + strings.Repeat("-", width+1)) + } + fmt.Println("+") +} + +func (s DeregsiteredOperatorSets) PrintJSON() { + obj, err := json.MarshalIndent(s, "", " ") + if err != nil { + fmt.Println("Error marshalling to JSON:", err) + return + } + fmt.Println(string(obj)) +} + +type AllocationDetails struct { + StrategyAddress gethcommon.Address + AVSAddress gethcommon.Address + OperatorSetId uint32 + Allocation uint64 + Timestamp uint32 +} + +type AllocDetails struct { + Magnitude uint64 + Timestamp uint32 +} diff --git a/pkg/operator/allocations/update.go b/pkg/operator/allocations/update.go new file mode 100644 index 00000000..2f196c24 --- /dev/null +++ b/pkg/operator/allocations/update.go @@ -0,0 +1,492 @@ +package allocations + +import ( + "context" + "errors" + "fmt" + "math/big" + "os" + "sort" + "sync" + + 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" + allocationmanager "github.com/Layr-Labs/eigensdk-go/contracts/bindings/AllocationManager" + "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 { + GetMaxMagnitudes( + ctx context.Context, + operatorAddress gethcommon.Address, + strategyAddresses []gethcommon.Address, + ) ([]uint64, error) + GetAllocatableMagnitude( + ctx context.Context, + 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 of slashable stake + `, + 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) + } + + 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, + 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, + ) + 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.PrintPretty() + } + if !config.isSilent { + 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, + &flags.DelegationManagerAddressFlag, + &flags.SilentFlag, + &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([]allocationmanager.IAllocationManagerTypesAllocateParams, 0) + var allocatableMagnitudes map[gethcommon.Address]uint64 + + var err error + if len(config.csvFilePath) == 0 { + magnitude, err := elReader.GetMaxMagnitudes( + 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( + 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 := allocationmanager.IAllocationManagerTypesAllocateParams{ + Strategies: []gethcommon.Address{config.strategyAddress}, + OperatorSet: allocationmanager.OperatorSet{ + Avs: config.avsAddress, + Id: config.operatorSetId, + }, + NewMagnitudes: []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, +) ([]allocationmanager.IAllocationManagerTypesAllocateParams, 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(context.Background(), 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.GetMaxMagnitudes( + 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, +) []allocationmanager.IAllocationManagerTypesAllocateParams { + magnitudeAllocations := make([]allocationmanager.IAllocationManagerTypesAllocateParams, 0) + strategiesPerOperatorSetMap := make(map[allocationmanager.OperatorSet][]gethcommon.Address) + magnitudeAllocationsPerOperatorSetMap := make(map[allocationmanager.OperatorSet][]uint64) + for _, a := range allocations { + totalMag := strategyTotalMagnitudes[a.StrategyAddress] + magnitudeToUpdate := calculateMagnitudeToUpdate(totalMag, a.Bips) + + opSet := allocationmanager.OperatorSet{Avs: a.AvsAddress, Id: a.OperatorSetId} + strategies, ok := strategiesPerOperatorSetMap[opSet] + if !ok { + strategies = make([]gethcommon.Address, 0) + } + + strategies = append(strategies, a.StrategyAddress) + strategiesPerOperatorSetMap[opSet] = strategies + + magnitudes := magnitudeAllocationsPerOperatorSetMap[opSet] + magnitudes = append(magnitudes, magnitudeToUpdate) + magnitudeAllocationsPerOperatorSetMap[opSet] = magnitudes + } + + for opSet, strategies := range strategiesPerOperatorSetMap { + magnitudeAllocations = append( + magnitudeAllocations, + allocationmanager.IAllocationManagerTypesAllocateParams{ + OperatorSet: opSet, + Strategies: strategies, + NewMagnitudes: magnitudeAllocationsPerOperatorSetMap[opSet], + }, + ) + } + + 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) + isSilent := cCtx.Bool(flags.SilentFlag.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 := cCtx.String(flags.DelegationManagerAddressFlag.Name) + if delegationManagerAddress == "" { + delegationManagerAddress, err = common.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), + isSilent: isSilent, + }, nil +} diff --git a/pkg/operator/allocations/update_test.go b/pkg/operator/allocations/update_test.go new file mode 100644 index 00000000..a97dd217 --- /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" + + allocationmanager "github.com/Layr-Labs/eigensdk-go/contracts/bindings/AllocationManager" + "github.com/Layr-Labs/eigensdk-go/logging" + + 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) GetMaxMagnitudes( + ctx context.Context, + 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( + ctx context.Context, + 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: []allocationmanager.IAllocationManagerTypesAllocateParams{ + { + Strategies: []gethcommon.Address{gethcommon.HexToAddress(strategyAddress)}, + OperatorSet: allocationmanager.OperatorSet{ + Avs: gethcommon.HexToAddress(avsAddress), + Id: 1, + }, + NewMagnitudes: []uint64{1e17}, + }, + }, + }, + }, + { + name: "csv file allocations1.csv", + config: &updateConfig{ + csvFilePath: "testdata/allocations1.csv", + operatorAddress: gethcommon.HexToAddress(operatorAddress), + }, + expectError: false, + expectedAllocations: &BulkModifyAllocations{ + Allocations: []allocationmanager.IAllocationManagerTypesAllocateParams{ + { + Strategies: []gethcommon.Address{ + gethcommon.HexToAddress("0x49989b32351Eb9b8ab2d5623cF22E7F7C23e5630"), + }, + OperatorSet: allocationmanager.OperatorSet{ + Avs: gethcommon.HexToAddress("0x2222AAC0C980Cc029624b7ff55B88Bc6F63C538f"), + Id: 1, + }, + NewMagnitudes: []uint64{2e17}, + }, + { + Strategies: []gethcommon.Address{ + gethcommon.HexToAddress("0x49989b32351Eb9b8ab2d5623cF22E7F7C23e5630"), + }, + OperatorSet: allocationmanager.OperatorSet{ + Avs: gethcommon.HexToAddress("0x2222AAC0C980Cc029624b7ff55B88Bc6F63C538f"), + Id: 3, + }, + NewMagnitudes: []uint64{1e17}, + }, + { + Strategies: []gethcommon.Address{ + gethcommon.HexToAddress("0x232326fE4F8C2f83E3eB2318F090557b7CD02222"), + }, + OperatorSet: allocationmanager.OperatorSet{ + Avs: gethcommon.HexToAddress("0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76"), + Id: 4, + }, + NewMagnitudes: []uint64{3e17}, + }, + { + Strategies: []gethcommon.Address{ + gethcommon.HexToAddress("0x545456fE4F8C2f83E3eB2318F090557b7CD04567"), + }, + OperatorSet: allocationmanager.OperatorSet{ + Avs: gethcommon.HexToAddress("0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76"), + Id: 5, + }, + NewMagnitudes: []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("0x2222AAC0C980Cc029624b7ff55B88Bc6F63C538f"): { + gethcommon.HexToAddress(strategyAddress): initialMagnitude, + }, + gethcommon.HexToAddress(operatorAddress): { + gethcommon.HexToAddress("0x49989b32351Eb9b8ab2d5623cF22E7F7C23e5630"): initialMagnitude, + gethcommon.HexToAddress("0x111116fE4F8C2f83E3eB2318F090557b7CD0BF76"): initialMagnitude, + gethcommon.HexToAddress("0x545456fE4F8C2f83E3eB2318F090557b7CD04567"): initialMagnitude, + gethcommon.HexToAddress("0x232326fE4F8C2f83E3eB2318F090557b7CD02222"): 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 f07e308c..ee12af0c 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/deregister_operator_sets.go b/pkg/operator/deregister_operator_sets.go new file mode 100644 index 00000000..7e908112 --- /dev/null +++ b/pkg/operator/deregister_operator_sets.go @@ -0,0 +1,237 @@ +package operator + +import ( + "fmt" + "math" + "strings" + + "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" + allocationmanager "github.com/Layr-Labs/eigensdk-go/contracts/bindings/AllocationManager" + "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 DeregisterCommand(p utils.Prompter) *cli.Command { + getDeregisterCmd := &cli.Command{ + Name: "deregister-operator-sets", + Usage: "Deregister operator from specified operator sets", + UsageText: "deregister-operator-sets [flags]", + Description: ` +Deregister operator from operator sets. +This command doesn't automatically deallocate your slashable stake from that operator set so you will have to use the 'operator allocations update' command to deallocate your stake from the operator set. + +To find what operator set you are part of, use the 'eigenlayer operator allocations show' command. + +`, + Flags: getDeregistrationFlags(), + After: telemetry.AfterRunAction(), + Action: func(context *cli.Context) error { + return deregisterAction(context, p) + }, + } + return getDeregisterCmd +} + +func deregisterAction(cCtx *cli.Context, p utils.Prompter) error { + ctx := cCtx.Context + logger := common.GetLogger(cCtx) + + config, err := readAndValidateDeregisterConfig(cCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError(err, "failed to read and validate deregister config") + } + + 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) + } + + if config.broadcast { + if config.signerConfig == nil { + return fmt.Errorf("signer config is required to broadcast the transaction") + } + logger.Info("Signing and broadcasting deregistration transaction") + 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.DeregisterFromOperatorSets( + ctx, + config.operatorAddress, + elcontracts.DeregistrationRequest{ + AVSAddress: config.avsAddress, + OperatorSetIds: config.operatorSetIds, + WaitForReceipt: true, + }) + if err != nil { + return eigenSdkUtils.WrapError("failed to deregister from operator sets", 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.DeregisterFromOperatorSets( + noSendTxOpts, + allocationmanager.IAllocationManagerTypesDeregisterParams{ + Operator: config.operatorAddress, + Avs: config.avsAddress, + OperatorSetIds: config.operatorSetIds, + }, + ) + if err != nil { + return eigenSdkUtils.WrapError("failed to create unsigned transaction", 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.Println() + fmt.Println("Deregitering from operator sets: ", config.operatorSetIds) + } + if !config.isSilent { + txFeeDetails := common.GetTxFeeDetails(unsignedTx) + fmt.Println() + txFeeDetails.Print() + fmt.Println("To broadcast the transaction, use the --broadcast flag") + fmt.Println() + + msg1 := "| NOTE: This command doesn't automatically deallocate your slashable stake from that operator set." + msg2 := "| You will have to use the 'eigenlayer operator allocations update' command to deallocate your stake from the operator set." + width := int(math.Max(float64(len(msg1)), float64(len(msg2))) + 1) + fmt.Println("+" + strings.Repeat("-", width) + "+") + fmt.Println(msg1 + strings.Repeat(" ", width-len(msg1)) + " |") + fmt.Println(msg2 + strings.Repeat(" ", width-len(msg2)) + " |") + fmt.Println("+" + strings.Repeat("-", width) + "+") + } + + } + return nil +} + +func readAndValidateDeregisterConfig(cCtx *cli.Context, logger logging.Logger) (*DeregisterConfig, 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) + isSilent := cCtx.Bool(flags.SilentFlag.Name) + + operatorAddress := gethcommon.HexToAddress(cCtx.String(flags.OperatorAddressFlag.Name)) + avsAddress := gethcommon.HexToAddress(cCtx.String(flags.AVSAddressFlag.Name)) + + // 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) + } + + chainId := utils.NetworkNameToChainId(network) + + delegationManagerAddress := cCtx.String(flags.DelegationManagerAddressFlag.Name) + if delegationManagerAddress == "" { + delegationManagerAddress, err = common.GetDelegationManagerAddress(chainId) + if err != nil { + return nil, err + } + } + + operatorSetIdsString := cCtx.Uint64Slice(flags.OperatorSetIdsFlag.Name) + operatorSetIds := make([]uint32, len(operatorSetIdsString)) + for i, id := range operatorSetIdsString { + operatorSetIds[i] = uint32(id) + } + + config := &DeregisterConfig{ + avsAddress: avsAddress, + operatorSetIds: operatorSetIds, + operatorAddress: operatorAddress, + network: network, + environment: environment, + broadcast: broadcast, + rpcUrl: rpcUrl, + chainID: chainId, + signerConfig: signerConfig, + output: output, + outputType: outputType, + delegationManagerAddress: gethcommon.HexToAddress(delegationManagerAddress), + isSilent: isSilent, + } + + return config, nil +} + +func getDeregistrationFlags() []cli.Flag { + baseFlags := []cli.Flag{ + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + &flags.OutputFileFlag, + &flags.OutputTypeFlag, + &flags.BroadcastFlag, + &flags.VerboseFlag, + &flags.AVSAddressFlag, + &flags.OperatorAddressFlag, + &flags.OperatorSetIdsFlag, + &flags.DelegationManagerAddressFlag, + &flags.SilentFlag, + } + + allFlags := append(baseFlags, flags.GetSignerFlags()...) + sort.Sort(cli.FlagsByName(allFlags)) + return allFlags +} diff --git a/pkg/operator/register.go b/pkg/operator/register.go index c202e082..1b47d3b2 100644 --- a/pkg/operator/register.go +++ b/pkg/operator/register.go @@ -44,6 +44,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/operator/register_operator_sets.go b/pkg/operator/register_operator_sets.go new file mode 100644 index 00000000..a4b76e28 --- /dev/null +++ b/pkg/operator/register_operator_sets.go @@ -0,0 +1,223 @@ +package operator + +import ( + "fmt" + "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" + allocationmanager "github.com/Layr-Labs/eigensdk-go/contracts/bindings/AllocationManager" + "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 RegisterOperatorSetsCommand(p utils.Prompter) *cli.Command { + registerOperatorSetsCmd := &cli.Command{ + Name: "register-operator-sets", + Usage: "register operator from specified operator sets", + UsageText: "register-operator-sets [flags]", + Description: ` +register operator sets for operator. + +To find what operator set you are registered for, use the 'eigenlayer operator allocations show' command. + +`, + Flags: getRegistrationFlags(), + After: telemetry.AfterRunAction(), + Action: func(context *cli.Context) error { + return registerOperatorSetsAction(context, p) + }, + } + return registerOperatorSetsCmd +} + +func registerOperatorSetsAction(cCtx *cli.Context, p utils.Prompter) error { + ctx := cCtx.Context + logger := common.GetLogger(cCtx) + + config, err := readAndValidateRegisterOperatorSetsConfig(cCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError(err, "failed to read and validate register config") + } + + 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) + } + + if config.broadcast { + if config.signerConfig == nil { + return fmt.Errorf("signer config is required to broadcast the transaction") + } + logger.Info("Signing and broadcasting registration transaction") + 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.RegisterForOperatorSets( + ctx, + elcontracts.RegistrationRequest{ + AVSAddress: config.avsAddress, + OperatorSetIds: config.operatorSetIds, + WaitForReceipt: true, + }) + if err != nil { + return eigenSdkUtils.WrapError("failed to deregister from operator sets", 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.RegisterForOperatorSets( + noSendTxOpts, + config.operatorAddress, + allocationmanager.IAllocationManagerTypesRegisterParams{ + Avs: config.avsAddress, + OperatorSetIds: config.operatorSetIds, + }, + ) + if err != nil { + return eigenSdkUtils.WrapError("failed to create unsigned transaction", 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.Println() + fmt.Println("Registering from operator sets: ", config.operatorSetIds) + } + if !config.isSilent { + txFeeDetails := common.GetTxFeeDetails(unsignedTx) + fmt.Println() + txFeeDetails.Print() + fmt.Println("To broadcast the transaction, use the --broadcast flag") + } + + } + return nil +} + +func readAndValidateRegisterOperatorSetsConfig(cCtx *cli.Context, logger logging.Logger) (*RegisterConfig, 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) + isSilent := cCtx.Bool(flags.SilentFlag.Name) + + operatorAddress := gethcommon.HexToAddress(cCtx.String(flags.OperatorAddressFlag.Name)) + avsAddress := gethcommon.HexToAddress(cCtx.String(flags.AVSAddressFlag.Name)) + + // 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) + } + + chainId := utils.NetworkNameToChainId(network) + + delegationManagerAddress := cCtx.String(flags.DelegationManagerAddressFlag.Name) + if delegationManagerAddress == "" { + delegationManagerAddress, err = common.GetDelegationManagerAddress(chainId) + if err != nil { + return nil, err + } + } + + operatorSetIdsString := cCtx.Uint64Slice(flags.OperatorSetIdsFlag.Name) + operatorSetIds := make([]uint32, len(operatorSetIdsString)) + for i, id := range operatorSetIdsString { + operatorSetIds[i] = uint32(id) + } + + config := &RegisterConfig{ + avsAddress: avsAddress, + operatorSetIds: operatorSetIds, + operatorAddress: operatorAddress, + network: network, + environment: environment, + broadcast: broadcast, + rpcUrl: rpcUrl, + chainID: chainId, + signerConfig: signerConfig, + output: output, + outputType: outputType, + delegationManagerAddress: gethcommon.HexToAddress(delegationManagerAddress), + isSilent: isSilent, + } + + return config, nil +} + +func getRegistrationFlags() []cli.Flag { + baseFlags := []cli.Flag{ + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + &flags.OutputFileFlag, + &flags.OutputTypeFlag, + &flags.BroadcastFlag, + &flags.VerboseFlag, + &flags.AVSAddressFlag, + &flags.OperatorAddressFlag, + &flags.OperatorSetIdsFlag, + &flags.DelegationManagerAddressFlag, + &flags.SilentFlag, + } + + allFlags := append(baseFlags, flags.GetSignerFlags()...) + sort.Sort(cli.FlagsByName(allFlags)) + return allFlags +} diff --git a/pkg/operator/types.go b/pkg/operator/types.go new file mode 100644 index 00000000..2f4b4fdb --- /dev/null +++ b/pkg/operator/types.go @@ -0,0 +1,40 @@ +package operator + +import ( + "math/big" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/types" + "github.com/ethereum/go-ethereum/common" +) + +type DeregisterConfig struct { + avsAddress common.Address + operatorSetIds []uint32 + operatorAddress common.Address + network string + environment string + broadcast bool + rpcUrl string + chainID *big.Int + signerConfig *types.SignerConfig + output string + outputType string + delegationManagerAddress common.Address + isSilent bool +} + +type RegisterConfig struct { + avsAddress common.Address + operatorSetIds []uint32 + operatorAddress common.Address + network string + environment string + broadcast bool + rpcUrl string + chainID *big.Int + signerConfig *types.SignerConfig + output string + outputType string + delegationManagerAddress common.Address + isSilent bool +} diff --git a/pkg/operator/update_metadata_uri.go b/pkg/operator/update_metadata_uri.go index b8588018..ad8ce2a9 100644 --- a/pkg/operator/update_metadata_uri.go +++ b/pkg/operator/update_metadata_uri.go @@ -77,7 +77,12 @@ Requires the same file used for registration as argument return eigenSdkUtils.WrapError("failed to get EL writer", err) } - receipt, err := elWriter.UpdateMetadataURI(context.Background(), operatorCfg.Operator.MetadataUrl, true) + receipt, err := elWriter.UpdateMetadataURI( + context.Background(), + gethcommon.HexToAddress(operatorCfg.Operator.Address), + operatorCfg.Operator.MetadataUrl, + true, + ) if err != nil { fmt.Printf("%s Error while updating operator metadata uri\n", utils.EmojiCrossMark) return err diff --git a/pkg/rewards/claim.go b/pkg/rewards/claim.go index 6a825bcd..3010cf80 100644 --- a/pkg/rewards/claim.go +++ b/pkg/rewards/claim.go @@ -39,7 +39,7 @@ type elChainReader interface { GetRootIndexFromHash(ctx context.Context, hash [32]byte) (uint32, error) GetCurrentClaimableDistributionRoot( ctx context.Context, - ) (rewardscoordinator.IRewardsCoordinatorDistributionRoot, error) + ) (rewardscoordinator.IRewardsCoordinatorTypesDistributionRoot, error) CurrRewardsCalculationEndTimestamp(ctx context.Context) (uint32, error) GetCumulativeClaimed(ctx context.Context, earnerAddress, tokenAddress gethcommon.Address) (*big.Int, error) } @@ -146,11 +146,11 @@ func Claim(cCtx *cli.Context, p utils.Prompter) error { return eigenSdkUtils.WrapError("failed to generate claim proof for earner", err) } - elClaim := rewardscoordinator.IRewardsCoordinatorRewardsMerkleClaim{ + elClaim := rewardscoordinator.IRewardsCoordinatorTypesRewardsMerkleClaim{ RootIndex: claim.RootIndex, EarnerIndex: claim.EarnerIndex, EarnerTreeProof: claim.EarnerTreeProof, - EarnerLeaf: rewardscoordinator.IRewardsCoordinatorEarnerTreeMerkleLeaf{ + EarnerLeaf: rewardscoordinator.IRewardsCoordinatorTypesEarnerTreeMerkleLeaf{ Earner: claim.EarnerLeaf.Earner, EarnerTokenRoot: claim.EarnerLeaf.EarnerTokenRoot, }, @@ -207,11 +207,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 } @@ -382,10 +378,10 @@ func filterClaimableTokenAddresses( func convertClaimTokenLeaves( claimTokenLeaves []contractrewardscoordinator.IRewardsCoordinatorTokenTreeMerkleLeaf, -) []rewardscoordinator.IRewardsCoordinatorTokenTreeMerkleLeaf { - var tokenLeaves []rewardscoordinator.IRewardsCoordinatorTokenTreeMerkleLeaf +) []rewardscoordinator.IRewardsCoordinatorTypesTokenTreeMerkleLeaf { + var tokenLeaves []rewardscoordinator.IRewardsCoordinatorTypesTokenTreeMerkleLeaf for _, claimTokenLeaf := range claimTokenLeaves { - tokenLeaves = append(tokenLeaves, rewardscoordinator.IRewardsCoordinatorTokenTreeMerkleLeaf{ + tokenLeaves = append(tokenLeaves, rewardscoordinator.IRewardsCoordinatorTypesTokenTreeMerkleLeaf{ Token: claimTokenLeaf.Token, CumulativeEarnings: claimTokenLeaf.CumulativeEarnings, }) diff --git a/pkg/rewards/claim_test.go b/pkg/rewards/claim_test.go index 6d9f9e81..e2fd3a4a 100644 --- a/pkg/rewards/claim_test.go +++ b/pkg/rewards/claim_test.go @@ -27,7 +27,7 @@ import ( ) type fakeELReader struct { - roots []rewardscoordinator.IRewardsCoordinatorDistributionRoot + roots []rewardscoordinator.IRewardsCoordinatorTypesDistributionRoot earnerTokenClaimedMap map[common.Address]map[common.Address]*big.Int } @@ -35,8 +35,8 @@ func newFakeELReader( now time.Time, earnerTokenClaimedMap map[common.Address]map[common.Address]*big.Int, ) *fakeELReader { - roots := make([]rewardscoordinator.IRewardsCoordinatorDistributionRoot, 0) - rootOne := rewardscoordinator.IRewardsCoordinatorDistributionRoot{ + roots := make([]rewardscoordinator.IRewardsCoordinatorTypesDistributionRoot, 0) + rootOne := rewardscoordinator.IRewardsCoordinatorTypesDistributionRoot{ Root: [32]byte{0x01}, RewardsCalculationEndTimestamp: uint32(now.Add(-time.Hour).Unix()), ActivatedAt: uint32(now.Add(time.Hour).Unix()), @@ -44,14 +44,14 @@ func newFakeELReader( } // This is the current claimable distribution root - rootTwo := rewardscoordinator.IRewardsCoordinatorDistributionRoot{ + rootTwo := rewardscoordinator.IRewardsCoordinatorTypesDistributionRoot{ Root: [32]byte{0x02}, RewardsCalculationEndTimestamp: uint32(now.Add(48 * -time.Hour).Unix()), ActivatedAt: uint32(now.Add(-24 * time.Hour).Unix()), Disabled: false, } - rootThree := rewardscoordinator.IRewardsCoordinatorDistributionRoot{ + rootThree := rewardscoordinator.IRewardsCoordinatorTypesDistributionRoot{ Root: [32]byte{0x03}, RewardsCalculationEndTimestamp: uint32(now.Add(32 * -time.Hour).Unix()), ActivatedAt: uint32(now.Add(-12 * time.Minute).Unix()), @@ -84,7 +84,7 @@ func (f *fakeELReader) GetRootIndexFromHash(ctx context.Context, hash [32]byte) func (f *fakeELReader) GetCurrentClaimableDistributionRoot( ctx context.Context, -) (rewardscoordinator.IRewardsCoordinatorDistributionRoot, error) { +) (rewardscoordinator.IRewardsCoordinatorTypesDistributionRoot, error) { // iterate from end to start since we want the latest active root // and the roots are sorted in ascending order of activation time for i := len(f.roots) - 1; i >= 0; i-- { @@ -93,7 +93,9 @@ func (f *fakeELReader) GetCurrentClaimableDistributionRoot( } } - return rewardscoordinator.IRewardsCoordinatorDistributionRoot{}, errors.New("no active distribution root found") + return rewardscoordinator.IRewardsCoordinatorTypesDistributionRoot{}, errors.New( + "no active distribution root found", + ) } func (f *fakeELReader) GetCumulativeClaimed( diff --git a/pkg/types/chain_metadata.go b/pkg/types/chain_metadata.go index 531ad353..f59f3538 100644 --- a/pkg/types/chain_metadata.go +++ b/pkg/types/chain_metadata.go @@ -5,6 +5,7 @@ type ChainMetadata struct { ELDelegationManagerAddress string ELAVSDirectoryAddress string ELRewardsCoordinatorAddress string + ELPermissionManagerAddress string WebAppUrl string ProofStoreBaseURL string } diff --git a/pkg/user/admin/accept.go b/pkg/user/admin/accept.go new file mode 100644 index 00000000..9477ac92 --- /dev/null +++ b/pkg/user/admin/accept.go @@ -0,0 +1,33 @@ +package admin + +import ( + "sort" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func AcceptCmd() *cli.Command { + acceptCmd := &cli.Command{ + Name: "accept-admin", + Usage: "user admin accept-admin --account-address ", + UsageText: "Accepts a user to become admin who is currently pending admin acceptance.", + Description: ` + Accepts a user to become admin who is currently pending admin acceptance. + `, + After: telemetry.AfterRunAction(), + Flags: acceptFlags(), + } + + return acceptCmd +} + +func acceptFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return append(cmdFlags, flags.GetSignerFlags()...) +} diff --git a/pkg/user/admin/add_pending.go b/pkg/user/admin/add_pending.go new file mode 100644 index 00000000..a0b2bdd2 --- /dev/null +++ b/pkg/user/admin/add_pending.go @@ -0,0 +1,34 @@ +package admin + +import ( + "sort" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func AddPendingCmd() *cli.Command { + addPendingCmd := &cli.Command{ + Name: "add-pending-admin", + Usage: "user admin add-pending-admin --account-address --admin-address ", + UsageText: "Add an admin to be pending until accepted.", + Description: ` + Add an admin to be pending until accepted. + `, + After: telemetry.AfterRunAction(), + Flags: addPendingFlags(), + } + + return addPendingCmd +} + +func addPendingFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AdminAddressFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return append(cmdFlags, flags.GetSignerFlags()...) +} diff --git a/pkg/user/admin/admin.go b/pkg/user/admin/admin.go new file mode 100644 index 00000000..653dbc83 --- /dev/null +++ b/pkg/user/admin/admin.go @@ -0,0 +1,34 @@ +package admin + +import ( + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func AdminCmd() *cli.Command { + adminCmd := &cli.Command{ + Name: "admin", + Usage: "user admin ", + UsageText: "Manage admin users.", + Description: ` + Manage admin users. + `, + After: telemetry.AfterRunAction(), + Flags: []cli.Flag{ + &flags.VerboseFlag, + }, + Subcommands: []*cli.Command{ + AcceptCmd(), + AddPendingCmd(), + IsAdminCmd(), + IsPendingCmd(), + ListCmd(), + ListPendingCmd(), + RemoveCmd(), + RemovePendingCmd(), + }, + } + + return adminCmd +} diff --git a/pkg/user/admin/flags.go b/pkg/user/admin/flags.go new file mode 100644 index 00000000..4cec13cd --- /dev/null +++ b/pkg/user/admin/flags.go @@ -0,0 +1,36 @@ +package admin + +import "github.com/urfave/cli/v2" + +var ( + AccountAddressFlag = cli.StringFlag{ + Name: "account-address", + Aliases: []string{"aa"}, + Usage: "user admin ... --account-address \"0x...\"", + EnvVars: []string{"ACCOUNT_ADDRESS"}, + } + AdminAddressFlag = cli.StringFlag{ + Name: "admin-address", + Aliases: []string{"aa"}, + Usage: "user admin ... --admin-address \"0x...\"", + EnvVars: []string{"ADMIN_ADDRESS"}, + } + CallerAddressFlag = cli.StringFlag{ + Name: "caller-address", + Aliases: []string{"ca"}, + Usage: "user admin ... --caller-address \"0x...\"", + EnvVars: []string{"CALLER_ADDRESS"}, + } + PendingAdminAddressFlag = cli.StringFlag{ + Name: "pending-admin-address", + Aliases: []string{"paa"}, + Usage: "user admin ... --pending-admin-address \"0x...\"", + EnvVars: []string{"PENDING_ADMIN_ADDRESS"}, + } + PermissionControllerAddressFlag = cli.StringFlag{ + Name: "permission-controller-address", + Aliases: []string{"pca"}, + Usage: "user admin ... --permission-controller-address \"0x...\"", + EnvVars: []string{"PERMISSION_CONTROLLER_ADDRESS"}, + } +) diff --git a/pkg/user/admin/is_admin.go b/pkg/user/admin/is_admin.go new file mode 100644 index 00000000..6bd2d385 --- /dev/null +++ b/pkg/user/admin/is_admin.go @@ -0,0 +1,26 @@ +package admin + +import ( + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func IsAdminCmd() *cli.Command { + isAdmin := &cli.Command{ + Name: "is-admin", + Usage: "user admin is-admin --account-address --caller-address ", + UsageText: "Checks if a user is an admin.", + Description: ` + Checks if a user is an admin. + `, + After: telemetry.AfterRunAction(), + Flags: []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &CallerAddressFlag, + }, + } + + return isAdmin +} diff --git a/pkg/user/admin/is_pending.go b/pkg/user/admin/is_pending.go new file mode 100644 index 00000000..72661139 --- /dev/null +++ b/pkg/user/admin/is_pending.go @@ -0,0 +1,26 @@ +package admin + +import ( + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func IsPendingCmd() *cli.Command { + isPendingCmd := &cli.Command{ + Name: "is-pending-admin", + Usage: "user admin is-pending-admin --account-address --pending-admin-address ", + UsageText: "Checks if a user is pending acceptance to admin.", + Description: ` + Checks if a user is pending acceptance to admin. + `, + After: telemetry.AfterRunAction(), + Flags: []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &PendingAdminAddressFlag, + }, + } + + return isPendingCmd +} diff --git a/pkg/user/admin/list.go b/pkg/user/admin/list.go new file mode 100644 index 00000000..b4fd204d --- /dev/null +++ b/pkg/user/admin/list.go @@ -0,0 +1,25 @@ +package admin + +import ( + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func ListCmd() *cli.Command { + listCmd := &cli.Command{ + Name: "list-admins", + Usage: "user admin list-admins --account-address ", + UsageText: "List all users who are admins.", + Description: ` + List all users who are admins. + `, + After: telemetry.AfterRunAction(), + Flags: []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + }, + } + + return listCmd +} diff --git a/pkg/user/admin/list_pending.go b/pkg/user/admin/list_pending.go new file mode 100644 index 00000000..b12f0c65 --- /dev/null +++ b/pkg/user/admin/list_pending.go @@ -0,0 +1,25 @@ +package admin + +import ( + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func ListPendingCmd() *cli.Command { + listPendingCmd := &cli.Command{ + Name: "list-pending-admins", + Usage: "user admin list-pending-admins --account-address ", + UsageText: "List all users who are pending admin acceptance.", + Description: ` + List all users who are pending admin acceptance. + `, + After: telemetry.AfterRunAction(), + Flags: []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + }, + } + + return listPendingCmd +} diff --git a/pkg/user/admin/remove.go b/pkg/user/admin/remove.go new file mode 100644 index 00000000..cf8600f2 --- /dev/null +++ b/pkg/user/admin/remove.go @@ -0,0 +1,34 @@ +package admin + +import ( + "sort" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func RemoveCmd() *cli.Command { + removeCmd := &cli.Command{ + Name: "remove-admin", + Usage: "user admin remove-admin --account-address --admin-address ", + UsageText: "The remove command allows you to remove an admin user.", + Description: ` + The remove command allows you to remove an admin user. + `, + After: telemetry.AfterRunAction(), + Flags: removeFlags(), + } + + return removeCmd +} + +func removeFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AdminAddressFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return append(cmdFlags, flags.GetSignerFlags()...) +} diff --git a/pkg/user/admin/remove_pending.go b/pkg/user/admin/remove_pending.go new file mode 100644 index 00000000..9795f32e --- /dev/null +++ b/pkg/user/admin/remove_pending.go @@ -0,0 +1,34 @@ +package admin + +import ( + "sort" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func RemovePendingCmd() *cli.Command { + removeCmd := &cli.Command{ + Name: "remove-pending-admin", + Usage: "user admin remove-pending-admin --account-address --admin-address ", + UsageText: "Remove a user who is pending admin acceptance.", + Description: ` + Remove a user who is pending admin acceptance. + `, + After: telemetry.AfterRunAction(), + Flags: removePendingFlags(), + } + + return removeCmd +} + +func removePendingFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AdminAddressFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return append(cmdFlags, flags.GetSignerFlags()...) +} diff --git a/pkg/user/appointee/appointee.go b/pkg/user/appointee/appointee.go new file mode 100644 index 00000000..1f1f4282 --- /dev/null +++ b/pkg/user/appointee/appointee.go @@ -0,0 +1,32 @@ +package appointee + +import ( + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func AppointeeCmd() *cli.Command { + appointeeCmd := &cli.Command{ + Name: "appointee", + Usage: "user appointee ", + UsageText: "User permission management operations.", + Description: ` + User permission management operations. + `, + After: telemetry.AfterRunAction(), + Flags: []cli.Flag{ + &flags.VerboseFlag, + }, + Subcommands: []*cli.Command{ + BatchSetCmd(), + canCallCmd(generateUserCanCallReader), + ListCmd(generateListUsersReader), + ListPermissionsCmd(generateListUserPermissionsReader), + RemoveCmd(), + SetCmd(), + }, + } + + return appointeeCmd +} diff --git a/pkg/user/appointee/batch_set.go b/pkg/user/appointee/batch_set.go new file mode 100644 index 00000000..cfd504f4 --- /dev/null +++ b/pkg/user/appointee/batch_set.go @@ -0,0 +1,33 @@ +package appointee + +import ( + "sort" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func BatchSetCmd() *cli.Command { + batchSetCmd := &cli.Command{ + Name: "batch set", + Usage: "user appointee batch-set --batch-set-file ", + UsageText: "Appoint multiple users permissions at a time.", + Description: ` + Appoint multiple users permissions at a time. + `, + After: telemetry.AfterRunAction(), + Flags: batchSetFlags(), + } + + return batchSetCmd +} + +func batchSetFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &BatchSetFileFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return append(cmdFlags, flags.GetSignerFlags()...) +} diff --git a/pkg/user/appointee/can_call.go b/pkg/user/appointee/can_call.go new file mode 100644 index 00000000..59d4b958 --- /dev/null +++ b/pkg/user/appointee/can_call.go @@ -0,0 +1,147 @@ +package appointee + +import ( + "context" + "fmt" + "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" + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/urfave/cli/v2" +) + +type UserCanCallReader interface { + UserCanCall( + ctx context.Context, + userAddress gethcommon.Address, + callerAddress gethcommon.Address, + target gethcommon.Address, + selector [4]byte, + ) (bool, error) +} + +func canCallCmd(readerGenerator func(logging.Logger, *canCallConfig) (UserCanCallReader, error)) *cli.Command { + cmd := &cli.Command{ + Name: "can-call", + Usage: "user appointee can-call --account-address --caller-address --taget-address --selector ", + UsageText: "Checks if a user has a specific permission.", + Description: ` + Checks if a user has a specific permission. + `, + Action: func(c *cli.Context) error { + return canCall(c, readerGenerator) + }, + After: telemetry.AfterRunAction(), + Flags: canCallFlags(), + } + + return cmd +} + +func canCall(cliCtx *cli.Context, generator func(logging.Logger, *canCallConfig) (UserCanCallReader, error)) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateUserConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user can call config", err) + } + cliCtx.App.Metadata["network"] = config.ChainID.String() + elReader, err := generator(logger, config) + if err != nil { + return err + } + + result, err := elReader.UserCanCall(ctx, config.UserAddress, config.CallerAddress, config.Target, config.Selector) + fmt.Printf("CanCall Result: %v\n", result) + fmt.Printf("Selector, Target and User: %s, %x, %s\n", config.Target, string(config.Selector[:]), config.UserAddress) + return err +} + +func readAndValidateUserConfig(cliContext *cli.Context, logger logging.Logger) (*canCallConfig, error) { + userAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + callerAddress := gethcommon.HexToAddress(cliContext.String(CallerAddressFlag.Name)) + ethRpcUrl := cliContext.String(flags.ETHRpcUrlFlag.Name) + network := cliContext.String(flags.NetworkFlag.Name) + environment := cliContext.String(flags.EnvironmentFlag.Name) + target := gethcommon.HexToAddress(cliContext.String(TargetAddressFlag.Name)) + selector := cliContext.String(SelectorFlag.Name) + selectorBytes, err := common.ValidateAndConvertSelectorString(selector) + if err != nil { + return nil, err + } + + if environment == "" { + environment = common.GetEnvFromNetwork(network) + } + + chainID := utils.NetworkNameToChainId(network) + permissionManagerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + if common.IsEmptyString(permissionManagerAddress) { + permissionManagerAddress, err = common.GetPermissionManagerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionManager address: %s", + environment, + network, + chainID, + permissionManagerAddress, + ) + + return &canCallConfig{ + Network: network, + RPCUrl: ethRpcUrl, + UserAddress: userAddress, + CallerAddress: callerAddress, + Target: target, + Selector: selectorBytes, + PermissionManagerAddress: gethcommon.HexToAddress(permissionManagerAddress), + ChainID: chainID, + Environment: environment, + }, nil +} + +func generateUserCanCallReader( + logger logging.Logger, + config *canCallConfig, +) (UserCanCallReader, error) { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to create new eth client", err) + } + elReader, err := elcontracts.NewReaderFromConfig( + elcontracts.Config{ + PermissionsControllerAddress: config.PermissionManagerAddress, + }, + ethClient, + logger, + ) + return elReader, err +} + +func canCallFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &AccountAddressFlag, + &CallerAddressFlag, + &TargetAddressFlag, + &SelectorFlag, + &PermissionControllerAddressFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} diff --git a/pkg/user/appointee/can_call_test.go b/pkg/user/appointee/can_call_test.go new file mode 100644 index 00000000..b9f89bcd --- /dev/null +++ b/pkg/user/appointee/can_call_test.go @@ -0,0 +1,137 @@ +package appointee + +import ( + "context" + "errors" + "testing" + + "github.com/Layr-Labs/eigensdk-go/logging" + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +type mockElChainReader struct { + canCallFunc func( + ctx context.Context, + userAddress gethcommon.Address, + callerAddress gethcommon.Address, + target gethcommon.Address, + selector [4]byte, + ) (bool, error) +} + +func newMockElChainReader() mockElChainReader { + return mockElChainReader{ + canCallFunc: func(ctx context.Context, userAddress, callerAddress, target gethcommon.Address, selector [4]byte) (bool, error) { + return true, nil + }, + } +} + +func newErrorMockElChainReader(expectedError string) mockElChainReader { + return mockElChainReader{ + canCallFunc: func(ctx context.Context, userAddress, callerAddress, target gethcommon.Address, selector [4]byte) (bool, error) { + return false, errors.New(expectedError) + }, + } +} + +func (m *mockElChainReader) UserCanCall( + ctx context.Context, + userAddress, callerAddress, + target gethcommon.Address, + selector [4]byte, +) (bool, error) { + return m.canCallFunc(ctx, userAddress, callerAddress, target, selector) +} + +func TestCanCallCmd_Success(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{canCallCmd(generateMockReader())} + + args := []string{ + "TestCanCallCmd_Success", + "can-call", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--caller-address", "0x9876543210fedcba9876543210fedcba98765432", + "--target-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--selector", "0x1A2B3C4D", + "--network", "holesky", + "--permission-controller-address", "0xe4dB7125ef7a9D99F809B6b7788f75c8D84d8455", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestCanCallCmd_UserCanCallError(t *testing.T) { + errString := "Error while executing call from reader" + mockReader := newErrorMockElChainReader(errString) + + app := cli.NewApp() + app.Commands = []*cli.Command{ + canCallCmd(func(logger logging.Logger, config *canCallConfig) (UserCanCallReader, error) { + return UserCanCallReader(&mockReader), nil + }), + } + + args := []string{ + "TestCanCallCmd_UserCanCallError", + "can-call", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--caller-address", "0x9876543210fedcba9876543210fedcba98765432", + "--target-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--selector", "0x1A2B3C4D", + "--network", "holesky", + "--permission-controller-address", "0xe4dB7125ef7a9D99F809B6b7788f75c8D84d8455", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), errString) +} + +func TestCanCallCmd_InvalidSelector(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{canCallCmd(generateMockReader())} + + args := []string{ + "TestCanCallCmd_InvalidSelector", + "can-call", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--caller-address", "0x9876543210fedcba9876543210fedcba98765432", + "--target-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--selector", "incorrect-format", + "--permission-controller-address", "0xe4dB7125ef7a9D99F809B6b7788f75c8D84d8455", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), "selector must be a 4-byte hex string prefixed with '0x'") + + args = []string{ + "TestCanCallCmd_InvalidSelector", + "can-call", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--caller-address", "0x9876543210fedcba9876543210fedcba98765432", + "--target-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--selector", "0xincorrect-format", + "--permission-controller-address", "0xe4dB7125ef7a9D99F809B6b7788f75c8D84d8455", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + } + + err = app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), "selector must be a 4-byte hex string prefixed with '0x'") +} + +func generateMockReader() func(logger logging.Logger, config *canCallConfig) (UserCanCallReader, error) { + return func(logger logging.Logger, config *canCallConfig) (UserCanCallReader, error) { + mockReader := newMockElChainReader() + return UserCanCallReader(&mockReader), nil + } +} diff --git a/pkg/user/appointee/flags.go b/pkg/user/appointee/flags.go new file mode 100644 index 00000000..3c4a6e37 --- /dev/null +++ b/pkg/user/appointee/flags.go @@ -0,0 +1,48 @@ +package appointee + +import "github.com/urfave/cli/v2" + +var ( + AccountAddressFlag = cli.StringFlag{ + Name: "account-address", + Aliases: []string{"aa"}, + Usage: "The Ethereum address of the user. Example: --account-address \"0x...\"", + EnvVars: []string{"ACCOUNT_ADDRESS"}, + } + AppointeeAddressFlag = cli.StringFlag{ + Name: "appointee-address", + Aliases: []string{"appa"}, + Usage: "The Ethereum address of the user. Example: --appointee-address \"0x...\"", + EnvVars: []string{"APPOINTEE_ADDRESS"}, + } + CallerAddressFlag = cli.StringFlag{ + Name: "caller-address", + Aliases: []string{"ca"}, + Usage: "The Ethereum address of the caller. Example: --caller-address \"0x...\"", + EnvVars: []string{"CALLER_ADDRESS"}, + } + SelectorFlag = cli.StringFlag{ + Name: "selector", + Aliases: []string{"s"}, + Usage: "The selector for managing permissions to protocol operations. A selector is a smart contract method.", + EnvVars: []string{"SELECTOR"}, + } + TargetAddressFlag = cli.StringFlag{ + Name: "target-address", + Aliases: []string{"ta"}, + Usage: "The contract address for managing permissions to protocol operations.", + EnvVars: []string{"TARGET_ADDRESS"}, + } + BatchSetFileFlag = cli.StringFlag{ + Name: "batch-set-file", + Aliases: []string{"bsf"}, + Usage: "A YAML file for executing a batch of set permission operations.", + EnvVars: []string{"BATCH_SET_FILE"}, + } + PermissionControllerAddressFlag = cli.StringFlag{ + Name: "permission-controller-address", + Aliases: []string{"pca"}, + Usage: "The Ethereum address of the Permission Manager. Example: --permission-controller-address \"0x...\"", + EnvVars: []string{"PERMISSION_CONTROLLER_ADDRESS"}, + } +) diff --git a/pkg/user/appointee/list.go b/pkg/user/appointee/list.go new file mode 100644 index 00000000..a6278160 --- /dev/null +++ b/pkg/user/appointee/list.go @@ -0,0 +1,161 @@ +package appointee + +import ( + "context" + "fmt" + "sort" + "strings" + + "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" +) + +type ListUsersReader interface { + ListUsers( + ctx context.Context, + userAddress gethcommon.Address, + target gethcommon.Address, + selector [4]byte, + ) ([]gethcommon.Address, error) +} + +func ListCmd(readerGenerator func(logging.Logger, *listUsersConfig) (ListUsersReader, error)) *cli.Command { + listCmd := &cli.Command{ + Name: "list", + Usage: "user appointee list --account-address --target-address --selector ", + UsageText: "Lists all appointed users for an account with the provided permissions.", + Description: ` + Lists all appointed users for an account with the provided permissions. + `, + Action: func(c *cli.Context) error { + return listAppointees(c, readerGenerator) + }, + After: telemetry.AfterRunAction(), + Flags: listFlags(), + } + + return listCmd +} + +func listAppointees( + cliCtx *cli.Context, + generator func(logging.Logger, *listUsersConfig) (ListUsersReader, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateListUsersConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user can call config", err) + } + + elReader, err := generator(logger, config) + if err != nil { + return err + } + + users, err := elReader.ListUsers(ctx, config.UserAddress, config.Target, config.Selector) + if err != nil { + return err + } + printResults(config, users) + return nil +} + +func printResults(config *listUsersConfig, users []gethcommon.Address) { + fmt.Printf( + "Selector, Target and Appointer: %s, %x, %s", + config.Target, + string(config.Selector[:]), + config.UserAddress, + ) + fmt.Println(strings.Repeat("-", 80)) + + for _, user := range users { + fmt.Printf("User Id: 0x%b\n", user) + } +} + +func readAndValidateListUsersConfig(cliContext *cli.Context, logger logging.Logger) (*listUsersConfig, error) { + userAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + ethRpcUrl := cliContext.String(flags.ETHRpcUrlFlag.Name) + network := cliContext.String(flags.NetworkFlag.Name) + environment := cliContext.String(flags.EnvironmentFlag.Name) + target := gethcommon.HexToAddress(cliContext.String(TargetAddressFlag.Name)) + selector := cliContext.String(SelectorFlag.Name) + selectorBytes, err := common.ValidateAndConvertSelectorString(selector) + if err != nil { + return nil, err + } + + if environment == "" { + environment = common.GetEnvFromNetwork(network) + } + + chainID := utils.NetworkNameToChainId(network) + cliContext.App.Metadata["network"] = chainID.String() + permissionManagerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + if common.IsEmptyString(permissionManagerAddress) { + permissionManagerAddress, err = common.GetPermissionManagerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionManager address: %s", + environment, + network, + chainID, + permissionManagerAddress, + ) + + return &listUsersConfig{ + Network: network, + RPCUrl: ethRpcUrl, + UserAddress: userAddress, + Target: target, + Selector: selectorBytes, + PermissionManagerAddress: gethcommon.HexToAddress(permissionManagerAddress), + ChainID: chainID, + Environment: environment, + }, nil +} + +func generateListUsersReader(logger logging.Logger, config *listUsersConfig) (ListUsersReader, error) { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to create new eth client", err) + } + elReader, err := elcontracts.NewReaderFromConfig( + elcontracts.Config{ + PermissionsControllerAddress: config.PermissionManagerAddress, + }, + ethClient, + logger, + ) + return elReader, err +} + +func listFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &TargetAddressFlag, + &SelectorFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} diff --git a/pkg/user/appointee/list_permissions.go b/pkg/user/appointee/list_permissions.go new file mode 100644 index 00000000..bffcee52 --- /dev/null +++ b/pkg/user/appointee/list_permissions.go @@ -0,0 +1,160 @@ +package appointee + +import ( + "context" + "fmt" + "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" + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/urfave/cli/v2" +) + +type PermissionsReader interface { + ListUserPermissions( + ctx context.Context, + appointed gethcommon.Address, + userAddress gethcommon.Address, + ) ([]gethcommon.Address, [][4]byte, error) +} + +func ListPermissionsCmd( + readerGenerator func(logging.Logger, *listUserPermissionsConfig) (PermissionsReader, error), +) *cli.Command { + cmd := &cli.Command{ + Name: "list-permissions", + Usage: "user appointee list-permissions --account-address --appointee-address ", + UsageText: "List all permissions for a user.", + Description: ` + List all permissions of a user. + `, + Action: func(c *cli.Context) error { + return listPermissions(c, readerGenerator) + }, + After: telemetry.AfterRunAction(), + Flags: listPermissionFlags(), + } + + return cmd +} + +func listPermissions( + cliCtx *cli.Context, + generator func(logging.Logger, *listUserPermissionsConfig) (PermissionsReader, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateListUserPermissionsConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate list user permissions config", err) + } + + cliCtx.App.Metadata["network"] = config.ChainID.String() + reader, err := generator(logger, config) + if err != nil { + return err + } + + users, permissions, err := reader.ListUserPermissions(ctx, config.AccountAddress, config.UserAddress) + if err != nil { + return err + } + printPermissions(config, users, permissions) + return nil +} + +func readAndValidateListUserPermissionsConfig( + cliContext *cli.Context, + logger logging.Logger, +) (*listUserPermissionsConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + userAddress := gethcommon.HexToAddress(cliContext.String(AppointeeAddressFlag.Name)) + ethRpcUrl := cliContext.String(flags.ETHRpcUrlFlag.Name) + network := cliContext.String(flags.NetworkFlag.Name) + environment := cliContext.String(flags.EnvironmentFlag.Name) + + if environment == "" { + environment = common.GetEnvFromNetwork(network) + } + + chainID := utils.NetworkNameToChainId(network) + permissionManagerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + var err error + if common.IsEmptyString(permissionManagerAddress) { + permissionManagerAddress, err = common.GetPermissionManagerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionManager address: %s", + environment, + network, + chainID, + permissionManagerAddress, + ) + + return &listUserPermissionsConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + UserAddress: userAddress, + PermissionManagerAddress: gethcommon.HexToAddress(permissionManagerAddress), + ChainID: chainID, + Environment: environment, + }, nil +} + +func printPermissions(config *listUserPermissionsConfig, targets []gethcommon.Address, selectors [][4]byte) { + fmt.Printf("User: %s\n", config.UserAddress) + fmt.Printf("Appointed by: %s\n", config.AccountAddress) + fmt.Println("====================================================================================") + + for _, target := range targets { + for _, selector := range selectors { + fmt.Printf("Target: %s, Selector: %x\n", target, selector) + } + + } +} + +func generateListUserPermissionsReader( + logger logging.Logger, + config *listUserPermissionsConfig, +) (PermissionsReader, error) { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to create new eth client", err) + } + elReader, err := elcontracts.NewReaderFromConfig( + elcontracts.Config{ + PermissionsControllerAddress: config.PermissionManagerAddress, + }, + ethClient, + logger, + ) + return elReader, err +} + +func listPermissionFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AppointeeAddressFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} diff --git a/pkg/user/appointee/list_permissions_test.go b/pkg/user/appointee/list_permissions_test.go new file mode 100644 index 00000000..e7f71e3d --- /dev/null +++ b/pkg/user/appointee/list_permissions_test.go @@ -0,0 +1,116 @@ +package appointee + +import ( + "context" + "errors" + "testing" + + "github.com/Layr-Labs/eigensdk-go/logging" + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +type mockListPermissionsReader struct { + listPermissionsFunc func( + ctx context.Context, + appointed gethcommon.Address, + userAddress gethcommon.Address, + ) ([]gethcommon.Address, [][4]byte, error) +} + +func newMockListPermissionsReader( + users []gethcommon.Address, + permissions [][4]byte, + err error, +) *mockListPermissionsReader { + return &mockListPermissionsReader{ + listPermissionsFunc: func(ctx context.Context, appointed, userAddress gethcommon.Address) ([]gethcommon.Address, [][4]byte, error) { + return users, permissions, err + }, + } +} + +func (m *mockListPermissionsReader) ListUserPermissions( + ctx context.Context, + appointed gethcommon.Address, + userAddress gethcommon.Address, +) ([]gethcommon.Address, [][4]byte, error) { + return m.listPermissionsFunc(ctx, appointed, userAddress) +} + +func generateMockListPermissionsReader( + users []gethcommon.Address, + permissions [][4]byte, + err error, +) func(logging.Logger, *listUserPermissionsConfig) (PermissionsReader, error) { + return func(logger logging.Logger, config *listUserPermissionsConfig) (PermissionsReader, error) { + return newMockListPermissionsReader(users, permissions, err), nil + } +} + +func TestListPermissions_Success(t *testing.T) { + expectedUsers := []gethcommon.Address{ + gethcommon.HexToAddress("0x1234567890abcdef1234567890abcdef12345678"), + } + expectedPermissions := [][4]byte{ + {0x1A, 0x2B, 0x3C, 0x4D}, + } + + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListPermissionsCmd(generateMockListPermissionsReader(expectedUsers, expectedPermissions, nil)), + } + + args := []string{ + "TestListPermissions_Success", + "list-permissions", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--appointee-address", "0x9876543210fedcba9876543210fedcba98765432", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestListPermissions_ReaderError(t *testing.T) { + expectedError := "Error fetching permissions" + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListPermissionsCmd(generateMockListPermissionsReader(nil, nil, errors.New(expectedError))), + } + + args := []string{ + "TestListPermissions_ReaderError", + "list-permissions", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--appointee-address", "0x9876543210fedcba9876543210fedcba98765432", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} + +func TestListPermissions_NoPermissions(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListPermissionsCmd(generateMockListPermissionsReader([]gethcommon.Address{}, [][4]byte{}, nil)), + } + + args := []string{ + "TestListPermissions_NoPermissions", + "list-permissions", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--appointee-address", "0x9876543210fedcba9876543210fedcba98765432", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + } + + err := app.Run(args) + assert.NoError(t, err) +} diff --git a/pkg/user/appointee/list_test.go b/pkg/user/appointee/list_test.go new file mode 100644 index 00000000..cbc6911c --- /dev/null +++ b/pkg/user/appointee/list_test.go @@ -0,0 +1,134 @@ +package appointee + +import ( + "context" + "errors" + "testing" + + "github.com/Layr-Labs/eigensdk-go/logging" + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +type mockListUsersReader struct { + listUsersFunc func( + ctx context.Context, + userAddress gethcommon.Address, + target gethcommon.Address, + selector [4]byte, + ) ([]gethcommon.Address, error) +} + +func newMockListUsersReader(users []gethcommon.Address, err error) *mockListUsersReader { + return &mockListUsersReader{ + listUsersFunc: func(ctx context.Context, userAddress, target gethcommon.Address, selector [4]byte) ([]gethcommon.Address, error) { + return users, err + }, + } +} + +func (m *mockListUsersReader) ListUsers( + ctx context.Context, + userAddress, target gethcommon.Address, + selector [4]byte, +) ([]gethcommon.Address, error) { + return m.listUsersFunc(ctx, userAddress, target, selector) +} + +func generateMockListReader( + users []gethcommon.Address, + err error, +) func(logging.Logger, *listUsersConfig) (ListUsersReader, error) { + return func(logger logging.Logger, config *listUsersConfig) (ListUsersReader, error) { + return newMockListUsersReader(users, err), nil + } +} + +func TestListAppointees_Success(t *testing.T) { + expectedUsers := []gethcommon.Address{ + gethcommon.HexToAddress("0x1234567890abcdef1234567890abcdef12345678"), + gethcommon.HexToAddress("0x9876543210fedcba9876543210fedcba98765432"), + } + + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListCmd(generateMockListReader(expectedUsers, nil)), + } + + args := []string{ + "TestListAppointees_Success", + "list", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--target-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--selector", "0x1A2B3C4D", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestListAppointees_ReaderError(t *testing.T) { + expectedError := "Error fetching appointees" + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListCmd(generateMockListReader(nil, errors.New(expectedError))), + } + + args := []string{ + "TestListAppointees_ReaderError", + "list", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--target-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--selector", "0x1A2B3C4D", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} + +func TestListAppointees_InvalidSelector(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListCmd(generateMockListReader(nil, nil)), + } + + args := []string{ + "TestListAppointees_InvalidSelector", + "list", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--target-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--selector", "invalid", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), "selector must be a 4-byte hex string prefixed with '0x'") +} + +func TestListAppointees_NoUsers(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListCmd(generateMockListReader([]gethcommon.Address{}, nil)), + } + + args := []string{ + "TestListAppointees_NoUsers", + "list", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--target-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--selector", "0x1A2B3C4D", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + } + + err := app.Run(args) + assert.NoError(t, err) +} diff --git a/pkg/user/appointee/remove.go b/pkg/user/appointee/remove.go new file mode 100644 index 00000000..3cf7d684 --- /dev/null +++ b/pkg/user/appointee/remove.go @@ -0,0 +1,36 @@ +package appointee + +import ( + "sort" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func RemoveCmd() *cli.Command { + removeCmd := &cli.Command{ + Name: "remove", + Usage: "user appointee remove --account-address --appointee-address --target-address --selector ", + UsageText: "Remove a user's permission", + Description: ` + Remove a user's permission'. + `, + After: telemetry.AfterRunAction(), + Flags: removeCommandFlags(), + } + + return removeCmd +} + +func removeCommandFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AppointeeAddressFlag, + &TargetAddressFlag, + &SelectorFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return append(cmdFlags, flags.GetSignerFlags()...) +} diff --git a/pkg/user/appointee/set.go b/pkg/user/appointee/set.go new file mode 100644 index 00000000..87e34d39 --- /dev/null +++ b/pkg/user/appointee/set.go @@ -0,0 +1,36 @@ +package appointee + +import ( + "sort" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common/flags" + "github.com/Layr-Labs/eigenlayer-cli/pkg/telemetry" + "github.com/urfave/cli/v2" +) + +func SetCmd() *cli.Command { + setCmd := &cli.Command{ + Name: "set", + Usage: "user appointee set --account-address --appointee-address --target-address --selector ", + UsageText: "Grant a user a permission.", + Description: ` + Grant a user a permission.'. + `, + After: telemetry.AfterRunAction(), + Flags: setCommandFlags(), + } + + return setCmd +} + +func setCommandFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AppointeeAddressFlag, + &TargetAddressFlag, + &SelectorFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return append(cmdFlags, flags.GetSignerFlags()...) +} diff --git a/pkg/user/appointee/types.go b/pkg/user/appointee/types.go new file mode 100644 index 00000000..9ec83ffc --- /dev/null +++ b/pkg/user/appointee/types.go @@ -0,0 +1,41 @@ +package appointee + +import ( + "math/big" + + gethcommon "github.com/ethereum/go-ethereum/common" +) + +type canCallConfig struct { + Network string + RPCUrl string + UserAddress gethcommon.Address + CallerAddress gethcommon.Address + Target gethcommon.Address + Selector [4]byte + PermissionManagerAddress gethcommon.Address + ChainID *big.Int + Environment string +} + +type listUsersConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + UserAddress gethcommon.Address + Target gethcommon.Address + Selector [4]byte + PermissionManagerAddress gethcommon.Address + ChainID *big.Int + Environment string +} + +type listUserPermissionsConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + UserAddress gethcommon.Address + PermissionManagerAddress gethcommon.Address + ChainID *big.Int + Environment string +} diff --git a/pkg/users.go b/pkg/users.go new file mode 100644 index 00000000..44b09b51 --- /dev/null +++ b/pkg/users.go @@ -0,0 +1,20 @@ +package pkg + +import ( + "github.com/Layr-Labs/eigenlayer-cli/pkg/user/admin" + "github.com/Layr-Labs/eigenlayer-cli/pkg/user/appointee" + "github.com/urfave/cli/v2" +) + +func UserCmd() *cli.Command { + var userCmd = &cli.Command{ + Name: "user", + Usage: "Manage user permissions", + Subcommands: []*cli.Command{ + admin.AdminCmd(), + appointee.AppointeeCmd(), + }, + } + + return userCmd +}