diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index a2c7acd5..0c36c569 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -23,7 +23,7 @@ jobs: with: repository: layr-labs/eigensdk-go token: ${{ github.token }} - ref: dev + ref: 549e0185cee644d0a6fc9c9863f1cf76d9ef971f - name: Run anvil chain run: | @@ -96,7 +96,7 @@ jobs: with: repository: layr-labs/eigensdk-go token: ${{ github.token }} - ref: dev + ref: 549e0185cee644d0a6fc9c9863f1cf76d9ef971f - name: Run anvil chain run: | nohup make start-anvil-with-contracts-deployed > nohup.out 2>&1 & @@ -157,4 +157,53 @@ jobs: --claimer-address 0x2222AAC0C980Cc029624b7ff55B88Bc6F63C538f \ --web3signer-url http://127.0.0.1:9001 \ --verbose \ - --broadcast \ No newline at end of file + --broadcast + + UserIntegrationTests: + name: Integration Test - User Commands + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly-c4a984fbf2c48b793c8cd53af84f56009dd1070c + + - name: Checkout eigensdk-go + uses: actions/checkout@v4 + with: + repository: layr-labs/eigensdk-go + token: ${{ github.token }} + ref: 549e0185cee644d0a6fc9c9863f1cf76d9ef971f + + - name: Run anvil chain + run: | + nohup make start-anvil-with-contracts-deployed > nohup.out 2>&1 & + + - name: Install EigenLayer CLI + uses: actions/checkout@v4 + with: + path: eigenlayer-cli + + - name: Setup BATS + uses: mig4/setup-bats@v1 + + - name: Install EigenLayer CLI + run: | + cd eigenlayer-cli + make build + ./bin/eigenlayer --version + + - name: Prepare Integration Test Environment + run: | + echo "Preparing environment for integration tests." + cd eigenlayer-cli + echo "CLI_PATH=${GITHUB_WORKSPACE}/bin/eigenlayer" >> .env + + - name: Run User Command Integration Tests + run: | + cd eigenlayer-cli/tests/user + ./user-integration-tests-runner.sh \ No newline at end of file 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..29e414d8 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(prompter)) if err := app.Run(os.Args); err != nil { _, err := fmt.Fprintln(os.Stderr, err) diff --git a/go.mod b/go.mod index d96aada1..77690e53 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.14-0.20241212190947-9985122d81fe + github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241217234459-1dd4a5c5b30a github.com/blang/semver/v4 v4.0.0 github.com/consensys/gnark-crypto v0.12.1 github.com/ethereum/go-ethereum v1.14.5 @@ -27,7 +27,10 @@ require ( ) require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/Microsoft/hcsshim v0.11.4 // indirect github.com/StackExchange/wmi v1.2.1 // indirect github.com/attestantio/go-eth2-client v0.19.9 // indirect github.com/aws/aws-sdk-go-v2 v1.26.1 // indirect @@ -53,21 +56,32 @@ require ( github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/consensys/bavard v0.1.13 // indirect + github.com/containerd/containerd v1.7.12 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/cpuguy83/dockercfg v0.3.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/crate-crypto/go-kzg-4844 v1.0.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deckarep/golang-set/v2 v2.6.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/distribution/reference v0.5.0 // indirect + github.com/docker/docker v25.0.6+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/ethereum/c-kzg-4844 v1.0.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/ferranbt/fastssz v0.1.3 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/goccy/go-yaml v1.9.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect @@ -77,6 +91,8 @@ require ( github.com/klauspost/compress v1.17.1 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/lmittmann/tint v1.0.4 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -85,8 +101,16 @@ require ( github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mmcloughlin/addchain v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/client_golang v1.19.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect @@ -97,11 +121,17 @@ require ( github.com/rs/zerolog v1.29.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect + github.com/shirou/gopsutil/v3 v3.23.12 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/supranational/blst v0.3.11 // indirect github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect + github.com/testcontainers/testcontainers-go v0.30.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect go.opentelemetry.io/otel v1.24.0 // indirect go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/otel/trace v1.24.0 // indirect diff --git a/go.sum b/go.sum index 3ec027e3..daf197ad 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= @@ -12,10 +14,8 @@ 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.14-0.20241211225219-79336bf6e886 h1:+7AijqdfRXdDc3zvj02Alqsk6Qd3owvlqPYQN1Hc1ME= -github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241211225219-79336bf6e886/go.mod h1:aYdNURUhaqeYOS+Cq12TfSdPbjFfiLaHkxPdR4Exq/s= -github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241212190947-9985122d81fe h1:FeXxapvtEbbTbEWsrcBTTzQ2u2quGJ9HNYQVSk5JZ8g= -github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241212190947-9985122d81fe/go.mod h1:aYdNURUhaqeYOS+Cq12TfSdPbjFfiLaHkxPdR4Exq/s= +github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241217234459-1dd4a5c5b30a h1:spyS+Tp1PgVIPmAesVVRuOkC3jAZRyKXhttAieTBxmg= +github.com/Layr-Labs/eigensdk-go v0.1.14-0.20241217234459-1dd4a5c5b30a/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= @@ -121,8 +121,9 @@ github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c h1:uQYC5Z1mdLR github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c/go.mod h1:geZJZH3SzKCqnz5VT0q/DyIG/tvu/dZk+VIfXicupJs= github.com/crate-crypto/go-kzg-4844 v1.0.0 h1:TsSgHwrkTKecKJ4kadtHi4b3xHW5dCFUDFnUp1TsawI= github.com/crate-crypto/go-kzg-4844 v1.0.0/go.mod h1:1kMhvPgI0Ky3yIa+9lFySEBUBXkYxeOi8ZF1sYioxhc= -github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -172,6 +173,7 @@ github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= @@ -213,6 +215,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= @@ -223,6 +227,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -252,6 +258,8 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/klauspost/compress v1.17.1 h1:NE3C767s2ak2bweCZo3+rdP4U/HoyVXLv/X9f2gPS5g= github.com/klauspost/compress v1.17.1/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= @@ -369,16 +377,23 @@ github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11 github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/status-im/keycard-go v0.2.0 h1:QDLFswOQu1r5jsycloeQh3bVU8n/NatHHaZobtDnDzA= github.com/status-im/keycard-go v0.2.0/go.mod h1:wlp8ZLbsmrF6g6WjugPAx+IzoLrkdf9+mHxBEeo3Hbg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/supranational/blst v0.3.11 h1:LyU6FolezeWAhvQk0k6O/d49jqgO52MSDDfYgbeoEm4= @@ -405,6 +420,7 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= @@ -413,10 +429,18 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= +go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= @@ -435,12 +459,14 @@ golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -452,6 +478,7 @@ golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= @@ -470,14 +497,17 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -485,6 +515,7 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -503,7 +534,9 @@ golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -512,6 +545,9 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= +google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d h1:DoPTO70H+bcDXcd39vOqb2viZxgqeBeSGtZ55yZU4/Q= +google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk= google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= @@ -544,5 +580,7 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= +gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU= rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA= 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 4077c1a5..c24f55fd 100644 --- a/pkg/internal/common/flags/general.go +++ b/pkg/internal/common/flags/general.go @@ -104,4 +104,32 @@ var ( Usage: "Input file for batch rewards claim", EnvVars: []string{"BATCH_CLAIM_FILE"}, } + + 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"}, + } + + CallerAddressFlag = cli.StringFlag{ + Name: "caller-address", + Aliases: []string{"ca"}, + Usage: "This is the address of the caller who is calling the contract function. If it is not provided, the operator address will be used as the caller address", + EnvVars: []string{"CALLER_ADDRESS"}, + } ) diff --git a/pkg/internal/common/helper.go b/pkg/internal/common/helper.go index 6cfe066a..8b61a4da 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,32 +35,44 @@ 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{ MainnetChainId: { - BlockExplorerUrl: "https://etherscan.io/tx", - ELDelegationManagerAddress: "0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A", - ELAVSDirectoryAddress: "0x135dda560e946695d6f155dacafc6f1f25c1f5af", - ELRewardsCoordinatorAddress: "0x7750d328b314EfFa365A0402CcfD489B80B0adda", - WebAppUrl: "https://app.eigenlayer.xyz/operator", - ProofStoreBaseURL: "https://eigenlabs-rewards-mainnet-ethereum.s3.amazonaws.com", + BlockExplorerUrl: "https://etherscan.io/tx", + ELDelegationManagerAddress: "0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A", + ELAVSDirectoryAddress: "0x135dda560e946695d6f155dacafc6f1f25c1f5af", + ELRewardsCoordinatorAddress: "0x7750d328b314EfFa365A0402CcfD489B80B0adda", + ELPermissionControllerAddress: "", + WebAppUrl: "https://app.eigenlayer.xyz/operator", + ProofStoreBaseURL: "https://eigenlabs-rewards-mainnet-ethereum.s3.amazonaws.com", }, HoleskyChainId: { - BlockExplorerUrl: "https://holesky.etherscan.io/tx", - ELDelegationManagerAddress: "0xA44151489861Fe9e3055d95adC98FbD462B948e7", - ELAVSDirectoryAddress: "0x055733000064333CaDDbC92763c58BF0192fFeBf", - ELRewardsCoordinatorAddress: "0xAcc1fb458a1317E886dB376Fc8141540537E68fE", - WebAppUrl: "https://holesky.eigenlayer.xyz/operator", - ProofStoreBaseURL: "https://eigenlabs-rewards-testnet-holesky.s3.amazonaws.com", + BlockExplorerUrl: "https://holesky.etherscan.io/tx", + ELDelegationManagerAddress: "0xA44151489861Fe9e3055d95adC98FbD462B948e7", + ELAVSDirectoryAddress: "0x055733000064333CaDDbC92763c58BF0192fFeBf", + ELRewardsCoordinatorAddress: "0xAcc1fb458a1317E886dB376Fc8141540537E68fE", + ELPermissionControllerAddress: "0x598cb226B591155F767dA17AfE7A2241a68C5C10", + WebAppUrl: "https://holesky.eigenlayer.xyz/operator", + ProofStoreBaseURL: "https://eigenlabs-rewards-testnet-holesky.s3.amazonaws.com", }, AnvilChainId: { - BlockExplorerUrl: "", - ELDelegationManagerAddress: "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9", - ELAVSDirectoryAddress: "0x0165878A594ca255338adfa4d48449f69242Eb8F", - ELRewardsCoordinatorAddress: "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", - WebAppUrl: "", - ProofStoreBaseURL: "", + BlockExplorerUrl: "", + ELDelegationManagerAddress: "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9", + ELAVSDirectoryAddress: "0x0165878A594ca255338adfa4d48449f69242Eb8F", + ELRewardsCoordinatorAddress: "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6", + ELPermissionControllerAddress: "0x3Aa5ebB10DC797CAC828524e59A333d0A371443c", + 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 GetPermissionControllerAddress(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.ELPermissionControllerAddress, 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 d34d1742..5a9c6bf3 100644 --- a/pkg/operator.go +++ b/pkg/operator.go @@ -22,6 +22,9 @@ func OperatorCmd(p utils.Prompter) *cli.Command { operator.GetOperatorSplitCmd(p), operator.GetOperatorPISplitCmd(p), operator.SetOperatorPISplitCmd(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..fedfc127 --- /dev/null +++ b/pkg/operator/allocations/set_allocation_delay.go @@ -0,0 +1,216 @@ +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.callerAddress, + 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.callerAddress) + _, _, 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.callerAddress, 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, + &flags.CallerAddressFlag, + } + 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() + allocationDelayUint, err := strconv.ParseUint(allocationDelayString, 10, 32) + 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) + + callerAddress := c.String(flags.CallerAddressFlag.Name) + if common.IsEmptyString(callerAddress) { + logger.Infof("Caller address not provided. Using operator address (%s) as caller address", operatorAddress) + callerAddress = operatorAddress + } + + 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(allocationDelayUint), + callerAddress: gethcommon.HexToAddress(callerAddress), + }, nil +} diff --git a/pkg/operator/allocations/show.go b/pkg/operator/allocations/show.go new file mode 100644 index 00000000..15184bca --- /dev/null +++ b/pkg/operator/allocations/show.go @@ -0,0 +1,418 @@ +package allocations + +import ( + "context" + "errors" + "fmt" + "math/big" + "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" + 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 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 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, get Slashable Shares for the operator + */ + slashableSharesMap, err := getSlashableShares( + ctx, + config.operatorAddress, + registeredOperatorSets, + config.strategyAddresses, + elReader, + ) + if err != nil { + return eigenSdkUtils.WrapError("failed to get slashable shares", err) + } + + /* + 7. Using all of the above, calculate SlashableMagnitudeHolders object + for displaying the allocation state of the operator + */ + slashableMagnitudeHolders, dergisteredOpsets, err := prepareAllocationsData( + allAllocations, + registeredOperatorSetsMap, + operatorDelegatedSharesMap, + totalMagnitudeMap, + slashableSharesMap, + logger, + ) + if err != nil { + return err + } + + for key, val := range operatorDelegatedSharesMap { + fmt.Printf("Strategy Address: %s, Shares %s\n", key, common.FormatNumberWithUnderscores(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 { + if !common.IsEmptyString(config.output) { + if !strings.HasSuffix(config.output, ".csv") { + return errors.New("output file must be a .csv file") + } + err = slashableMagnitudeHolders.WriteToCSV(config.output) + if err != nil { + return err + } + logger.Infof("Allocation state written to file: %s", config.output) + } 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 prepareAllocationsData( + allAllocations map[string][]elcontracts.AllocationInfo, + registeredOperatorSetsMap map[string]allocationmanager.OperatorSet, + operatorDelegatedSharesMap map[string]*big.Int, + totalMagnitudeMap map[string]uint64, + slashableSharesMap map[gethcommon.Address]map[string]*big.Int, + logger logging.Logger, +) (SlashableMagnitudeHolders, DeregsiteredOperatorSets, error) { + slashableMagnitudeHolders := make(SlashableMagnitudeHolders, 0) + dergisteredOpsets := make(DeregsiteredOperatorSets, 0) + for strategy, allocations := range allAllocations { + logger.Debugf("Strategy: %s, Allocations: %v", strategy, allocations) + totalStrategyShares := operatorDelegatedSharesMap[strategy] + totalMagnitude := totalMagnitudeMap[strategy] + for _, alloc := range allocations { + + // 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 { + currentShares, currentSharesPercentage := getSharesFromMagnitude( + totalStrategyShares, + alloc.CurrentMagnitude.Uint64(), + totalMagnitude, + ) + + // If the operator set is not registered and has no shares, skip it + // This comes as valid scenario since we iterate first over + // strategy addresses and then over allocations. + // This can be fixed by first going over allocations and then over strategy addresses + // We will fix this in a subsequent PR and improve (TODO: shrimalmadhur) + if currentShares == nil || currentShares.Cmp(big.NewInt(0)) == 0 { + continue + } + + dergisteredOpsets = append(dergisteredOpsets, DeregisteredOperatorSet{ + StrategyAddress: gethcommon.HexToAddress(strategy), + AVSAddress: alloc.AvsAddress, + OperatorSetId: alloc.OperatorSetId, + SlashableMagnitude: alloc.CurrentMagnitude.Uint64(), + Shares: currentShares, + SharesPercentage: currentSharesPercentage.String(), + }) + continue + } + + // If the total shares in that strategy are zero, skip the operator set + if totalStrategyShares == nil || totalStrategyShares.Cmp(big.NewInt(0)) == 0 { + continue + } + currentShares := slashableSharesMap[gethcommon.HexToAddress(strategy)][getUniqueKey(alloc.AvsAddress, alloc.OperatorSetId)] + currentSharesPercentage := getSharePercentage(currentShares, totalStrategyShares) + + newMagnitudeBigInt := big.NewInt(0) + if alloc.PendingDiff != nil && alloc.PendingDiff.Cmp(big.NewInt(0)) != 0 { + newMagnitudeBigInt = big.NewInt(0).Add(alloc.CurrentMagnitude, alloc.PendingDiff) + } + + newShares, newSharesPercentage := getSharesFromMagnitude( + totalStrategyShares, + newMagnitudeBigInt.Uint64(), + totalMagnitude, + ) + + // 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(), + }) + } + } + return slashableMagnitudeHolders, dergisteredOpsets, nil +} + +func getSharePercentage(shares *big.Int, totalShares *big.Int) *big.Float { + percentageShares := big.NewInt(1) + percentageShares = percentageShares.Mul(shares, big.NewInt(100)) + percentageSharesFloat := new( + big.Float, + ).Quo(new(big.Float).SetInt(percentageShares), new(big.Float).SetInt(totalShares)) + return percentageSharesFloat +} + +func getSlashableShares( + ctx context.Context, + operatorAddress gethcommon.Address, + opSets []allocationmanager.OperatorSet, + strategyAddresses []gethcommon.Address, + reader elChainReader, +) (map[gethcommon.Address]map[string]*big.Int, error) { + result := make(map[gethcommon.Address]map[string]*big.Int) + for _, opSet := range opSets { + slashableSharesMap, err := reader.GetSlashableShares(ctx, operatorAddress, opSet, strategyAddresses) + if err != nil { + return nil, err + } + + for strat, shares := range slashableSharesMap { + if _, ok := result[strat]; !ok { + result[strat] = make(map[string]*big.Int) + } + result[strat][getUniqueKey(opSet.Avs, opSet.Id)] = shares + } + } + return result, nil +} + +func getSharesFromMagnitude(totalShare *big.Int, magnitude uint64, totalMagnitude uint64) (*big.Int, *big.Float) { + /* + * shares = totalShare * magnitude / totalMagnitude + * percentageShares = (shares / totalShare) * 100 + */ + // Check for zero magnitude or totalScaledShare to avoid divide-by-zero errors + if magnitude == 0 || totalShare.Cmp(big.NewInt(0)) == 0 { + return big.NewInt(0), big.NewFloat(0) + } + + opShares := big.NewInt(1) + opShares = opShares.Set(totalShare) + shares := opShares.Mul(opShares, big.NewInt(int64(magnitude))) + shares = shares.Div(shares, big.NewInt(int64(totalMagnitude))) + + percentageShares := big.NewInt(1) + percentageShares = percentageShares.Mul(opShares, big.NewInt(100)) + percentageSharesFloat := new( + big.Float, + ).Quo(new(big.Float).SetInt(percentageShares), new(big.Float).SetInt(totalShare)) + + return shares, percentageSharesFloat +} + +func getUniqueKey(avsAddress gethcommon.Address, opSetId uint32) string { + return fmt.Sprintf("%s-%d", avsAddress.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/show_test.go b/pkg/operator/allocations/show_test.go new file mode 100644 index 00000000..698d07cf --- /dev/null +++ b/pkg/operator/allocations/show_test.go @@ -0,0 +1,194 @@ +package allocations + +import ( + "math/big" + "testing" + + "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/testutils" + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" +) + +func TestPrepareAllocationsData(t *testing.T) { + avsAddress := gethcommon.HexToAddress("0xa1") + + tests := []struct { + name string + allAllocations map[string][]elcontracts.AllocationInfo + registeredOperatorSets map[string]allocationmanager.OperatorSet + operatorDelegatedShares map[string]*big.Int + totalMagnitude map[string]uint64 + slashableShares map[gethcommon.Address]map[string]*big.Int + expectedSlashable SlashableMagnitudeHolders + expectedDeregistered DeregsiteredOperatorSets + expectedErr error + }{ + { + name: "Happy path - registered operator set with pending changes", + allAllocations: map[string][]elcontracts.AllocationInfo{ + "0x1": { + { + CurrentMagnitude: big.NewInt(100), + PendingDiff: big.NewInt(50), + EffectBlock: 1000, + OperatorSetId: 1, + AvsAddress: avsAddress, + }, + }, + }, + registeredOperatorSets: map[string]allocationmanager.OperatorSet{ + getUniqueKey(avsAddress, 1): { + Avs: gethcommon.HexToAddress("0xa1"), + Id: 1, + }, + }, + operatorDelegatedShares: map[string]*big.Int{ + "0x1": big.NewInt(1000), + }, + totalMagnitude: map[string]uint64{ + "0x1": 200, + }, + slashableShares: map[gethcommon.Address]map[string]*big.Int{ + gethcommon.HexToAddress("0x1"): { + getUniqueKey(avsAddress, 1): big.NewInt(500), + }, + }, + expectedSlashable: SlashableMagnitudeHolders{ + { + StrategyAddress: gethcommon.HexToAddress("0x1"), + AVSAddress: gethcommon.HexToAddress("0xa1"), + OperatorSetId: 1, + SlashableMagnitude: 100, + NewMagnitude: 150, + UpdateBlock: 1000, + Shares: big.NewInt(500), + SharesPercentage: "50", + NewAllocationShares: big.NewInt(750), + UpcomingSharesPercentage: "75", + }, + }, + expectedDeregistered: DeregsiteredOperatorSets{}, + expectedErr: nil, + }, + { + name: "Deregistered operator set", + allAllocations: map[string][]elcontracts.AllocationInfo{ + "0x1": { + { + CurrentMagnitude: big.NewInt(100), + PendingDiff: big.NewInt(0), + EffectBlock: 1000, + OperatorSetId: 1, + AvsAddress: gethcommon.HexToAddress("0xa1"), + }, + }, + }, + registeredOperatorSets: map[string]allocationmanager.OperatorSet{}, // Empty map means operator set is not registered + operatorDelegatedShares: map[string]*big.Int{ + "0x1": big.NewInt(1000), + }, + totalMagnitude: map[string]uint64{ + "0x1": 200, + }, + slashableShares: map[gethcommon.Address]map[string]*big.Int{ + gethcommon.HexToAddress("0x1"): { + getUniqueKey(avsAddress, 1): big.NewInt(500), + }, + }, + expectedSlashable: SlashableMagnitudeHolders{}, + expectedDeregistered: DeregsiteredOperatorSets{ + { + StrategyAddress: gethcommon.HexToAddress("0x1"), + AVSAddress: gethcommon.HexToAddress("0xa1"), + OperatorSetId: 1, + SlashableMagnitude: 100, + Shares: big.NewInt(500), + SharesPercentage: "50", + }, + }, + expectedErr: nil, + }, + { + name: "Zero total shares - should skip", + allAllocations: map[string][]elcontracts.AllocationInfo{ + "0x1": { + { + CurrentMagnitude: big.NewInt(100), + PendingDiff: big.NewInt(50), + EffectBlock: 1000, + OperatorSetId: 1, + AvsAddress: gethcommon.HexToAddress("0xa1"), + }, + }, + }, + registeredOperatorSets: map[string]allocationmanager.OperatorSet{ + getUniqueKey(avsAddress, 1): { + Avs: gethcommon.HexToAddress("0xa1"), + Id: 1, + }, + }, + operatorDelegatedShares: map[string]*big.Int{ + "0x1": big.NewInt(0), // Zero total shares + }, + totalMagnitude: map[string]uint64{ + "0x1": 200, + }, + slashableShares: map[gethcommon.Address]map[string]*big.Int{ + gethcommon.HexToAddress("0x1"): { + getUniqueKey(avsAddress, 1): big.NewInt(0), + }, + }, + expectedSlashable: SlashableMagnitudeHolders{}, + expectedDeregistered: DeregsiteredOperatorSets{}, + expectedErr: nil, + }, + { + name: "Empty allocations", + allAllocations: map[string][]elcontracts.AllocationInfo{}, + registeredOperatorSets: map[string]allocationmanager.OperatorSet{ + getUniqueKey(avsAddress, 1): { + Avs: gethcommon.HexToAddress("0xa1"), + Id: 1, + }, + }, + operatorDelegatedShares: map[string]*big.Int{ + "0x1": big.NewInt(1000), + }, + totalMagnitude: map[string]uint64{ + "0x1": 200, + }, + slashableShares: map[gethcommon.Address]map[string]*big.Int{ + gethcommon.HexToAddress("0x1"): { + getUniqueKey(avsAddress, 1): big.NewInt(500), + }, + }, + expectedSlashable: SlashableMagnitudeHolders{}, + expectedDeregistered: DeregsiteredOperatorSets{}, + expectedErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + slashable, deregistered, err := prepareAllocationsData( + tt.allAllocations, + tt.registeredOperatorSets, + tt.operatorDelegatedShares, + tt.totalMagnitude, + tt.slashableShares, + testutils.GetTestLogger(), + ) + + if tt.expectedErr != nil { + assert.Error(t, err) + assert.Equal(t, tt.expectedErr, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedSlashable, slashable) + assert.Equal(t, tt.expectedDeregistered, deregistered) + } + }) + } +} 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..b5a9104f --- /dev/null +++ b/pkg/operator/allocations/types.go @@ -0,0 +1,356 @@ +package allocations + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "math/big" + "os" + "reflect" + "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 + callerAddress 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 + callerAddress 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 `csv:"strategy_address"` + AVSAddress gethcommon.Address `csv:"avs_address"` + OperatorSetId uint32 `csv:"operator_set_id"` + SlashableMagnitude uint64 `csv:"-"` + NewMagnitude uint64 `csv:"-"` + Shares *big.Int `csv:"shares"` + SharesPercentage string `csv:"shares_percentage"` + NewAllocationShares *big.Int `csv:"new_allocation_shares"` + UpcomingSharesPercentage string `csv:"upcoming_shares_percentage"` + UpdateBlock uint32 `csv:"update_block"` +} + +func (s SlashableMagnitudeHolders) WriteToCSV(filePath string) error { + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("failed to create file: %v", err) + } + defer file.Close() + + writer := csv.NewWriter(file) + defer writer.Flush() + + // Get fields and their CSV names, excluding skipped fields + var headers []string + var fieldIndices []int + val := reflect.ValueOf(SlashableMagnitudesHolder{}) + typ := val.Type() + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + csvTag := field.Tag.Get("csv") + + // Skip if tag is "-" + if csvTag == "-" { + continue + } + + // Use tag value if present, otherwise use field name + if csvTag != "" { + headers = append(headers, csvTag) + } else { + headers = append(headers, field.Name) + } + fieldIndices = append(fieldIndices, i) + } + + // Write headers + if err := writer.Write(headers); err != nil { + return fmt.Errorf("failed to write headers: %v", err) + } + + // Write data rows + for _, eachRow := range s { + val := reflect.ValueOf(eachRow) + row := make([]string, len(fieldIndices)) + // Only include non-skipped fields + for i, fieldIndex := range fieldIndices { + field := val.Field(fieldIndex) + row[i] = fmt.Sprintf("%v", field.Interface()) + } + + if err := writer.Write(row); err != nil { + return fmt.Errorf("failed to write row: %v", err) + } + } + + return nil +} + +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..c0a0facf --- /dev/null +++ b/pkg/operator/allocations/update.go @@ -0,0 +1,506 @@ +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) + GetSlashableShares( + ctx context.Context, + operatorAddress gethcommon.Address, + operatorSet allocationmanager.OperatorSet, + strategies []gethcommon.Address, + ) (map[gethcommon.Address]*big.Int, 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.callerAddress, + 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.callerAddress) + _, _, 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.callerAddress, 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, + &flags.CallerAddressFlag, + &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 := cCtx.String(flags.OperatorAddressFlag.Name) + callerAddress := cCtx.String(flags.CallerAddressFlag.Name) + if common.IsEmptyString(callerAddress) { + logger.Infof("Caller address not provided. Using operator address (%s) as caller address", operatorAddress) + callerAddress = operatorAddress + } + + 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, + 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: gethcommon.HexToAddress(operatorAddress), + callerAddress: gethcommon.HexToAddress(callerAddress), + 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..e85e942b --- /dev/null +++ b/pkg/operator/allocations/update_test.go @@ -0,0 +1,286 @@ +package allocations + +import ( + "context" + "errors" + "math" + "math/big" + "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 (f *fakeElChainReader) GetSlashableShares( + ctx context.Context, + operatorAddress gethcommon.Address, + operatorSet allocationmanager.OperatorSet, + strategies []gethcommon.Address, +) (map[gethcommon.Address]*big.Int, error) { + return nil, errors.New("not implemented") +} + +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.ElementsMatch(t, tt.expectedAllocations.Allocations, allocations.Allocations) + assert.Equal(t, tt.expectedAllocations.AllocatableMagnitudes, allocations.AllocatableMagnitudes) + } + }) + } +} + +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..72951532 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" @@ -118,36 +119,31 @@ func promptOperatorInfo(config *types.OperatorConfig, p utils.Prompter) (types.O } config.Operator.Address = operatorAddress - // TODO(madhur): Disabling this for now as the feature doesn't work but - // we need to keep the code around for future // Prompt to gate stakers approval - //gateApproval, err := p.Confirm("Do you want to gate stakers approval?") - //if err != nil { - // return types.OperatorConfig{}, err - //} + gateApproval, err := p.Confirm("Do you want to gate stakers approval?") + if err != nil { + return types.OperatorConfig{}, err + } // Prompt for address if operator wants to gate approvals - //if gateApproval { - // delegationApprover, err := p.InputString("Enter your staker approver address:", "", "", - // func(s string) error { - // isValidAddress := eigenSdkUtils.IsValidEthereumAddress(s) - // - // if !isValidAddress { - // return errors.New("address is invalid") - // } - // - // return nil - // }, - // ) - // if err != nil { - // return types.OperatorConfig{}, err - // } - // config.Operator.DelegationApproverAddress = delegationApprover - //} else { - // config.Operator.DelegationApproverAddress = eigensdkTypes.ZeroAddress - //} - - // TODO(madhur): Remove this once we have the feature working and want to prompt users for this address - config.Operator.DelegationApproverAddress = eigensdkTypes.ZeroAddress + if gateApproval { + delegationApprover, err := p.InputString("Enter your staker approver address:", "", "", + func(s string) error { + isValidAddress := eigenSdkUtils.IsValidEthereumAddress(s) + + if !isValidAddress { + return errors.New("address is invalid") + } + + return nil + }, + ) + if err != nil { + return types.OperatorConfig{}, err + } + config.Operator.DelegationApproverAddress = delegationApprover + } else { + config.Operator.DelegationApproverAddress = eigensdkTypes.ZeroAddress + } // Prompt for eth node rpcUrl, err := p.InputString("Enter your ETH rpc url:", "http://localhost:8545", "", @@ -158,6 +154,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 blocks, default is 1200):", + "1200", + "", + 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, + ) + " blocks?", + ) + 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/config/operator-config-example.yaml b/pkg/operator/config/operator-config-example.yaml index 0fe85a8b..78e036be 100644 --- a/pkg/operator/config/operator-config-example.yaml +++ b/pkg/operator/config/operator-config-example.yaml @@ -12,9 +12,10 @@ operator: # For now, you can leave it with the default value for un-gated delegation requests # Once we enable gated delegation requests, you can update this field with the address of the approver delegation_approver_address: 0x0000000000000000000000000000000000000000 - # Please refer to this link for more details on this field https://github.com/Layr-Labs/eigenlayer-contracts/blob/92ccacc868785350973afc15e90a18dcd39fbc0b/src/contracts/interfaces/IDelegationManager.sol#L33: - # Please keep this field to 0, and it can be updated later using EigenLayer CLI - staker_opt_out_window_blocks: 0 + # This is the delay in blocks after which your allocations will take into effect. This doesn't applies + # to deallocation which is set by the protocol. + allocation_delay: 1200 + # This is the URL where the metadata of the operator is hosted. metadata_url: https://raw.githubusercontent.com/Layr-Labs/eigenlayer-cli/master/pkg/operator/config/metadata-example.json # EigenLayer Delegation manager contract address diff --git a/pkg/operator/deregister_operator_sets.go b/pkg/operator/deregister_operator_sets.go new file mode 100644 index 00000000..0cdbea86 --- /dev/null +++ b/pkg/operator/deregister_operator_sets.go @@ -0,0 +1,244 @@ +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.callerAddress, + 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.callerAddress) + _, _, 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.callerAddress, 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 := cCtx.String(flags.OperatorAddressFlag.Name) + callerAddress := cCtx.String(flags.CallerAddressFlag.Name) + if common.IsEmptyString(callerAddress) { + logger.Infof("Caller address not provided. Using operator address (%s) as caller address", operatorAddress) + callerAddress = operatorAddress + } + 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: gethcommon.HexToAddress(operatorAddress), + callerAddress: gethcommon.HexToAddress(callerAddress), + 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, + &flags.CallerAddressFlag, + } + + 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..3589b697 --- /dev/null +++ b/pkg/operator/register_operator_sets.go @@ -0,0 +1,231 @@ +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.callerAddress, + 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{ + OperatorAddress: config.operatorAddress, + AVSAddress: config.avsAddress, + OperatorSetIds: config.operatorSetIds, + WaitForReceipt: true, + }) + if err != nil { + return eigenSdkUtils.WrapError("failed to register for operator sets", err) + } + common.PrintTransactionInfo(receipt.TxHash.String(), config.chainID) + } else { + noSendTxOpts := common.GetNoSendTxOpts(config.callerAddress) + _, _, 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.callerAddress, 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 := cCtx.String(flags.OperatorAddressFlag.Name) + callerAddress := cCtx.String(flags.CallerAddressFlag.Name) + if common.IsEmptyString(callerAddress) { + logger.Infof("Caller address not provided. Using operator address (%s) as caller address", operatorAddress) + callerAddress = operatorAddress + } + 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: gethcommon.HexToAddress(operatorAddress), + callerAddress: gethcommon.HexToAddress(callerAddress), + 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, + &flags.CallerAddressFlag, + } + + allFlags := append(baseFlags, flags.GetSignerFlags()...) + sort.Sort(cli.FlagsByName(allFlags)) + return allFlags +} diff --git a/pkg/operator/status.go b/pkg/operator/status.go index 16a9fffc..e6a1ab00 100644 --- a/pkg/operator/status.go +++ b/pkg/operator/status.go @@ -129,7 +129,7 @@ func printOperatorDetails(operator eigensdkTypes.Operator) { fmt.Println("--------------------------- Operator Details ---------------------------") fmt.Printf("Address: %s\n", operator.Address) fmt.Printf("Delegation Approver Address: %s\n", operator.DelegationApproverAddress) - fmt.Printf("Staker Opt Out Window Blocks: %d\n", operator.StakerOptOutWindowBlocks) + fmt.Printf("Allocation Delay: %d\n", operator.AllocationDelay) fmt.Println("------------------------------------------------------------------------") fmt.Println() } diff --git a/pkg/operator/types.go b/pkg/operator/types.go new file mode 100644 index 00000000..87f5b44c --- /dev/null +++ b/pkg/operator/types.go @@ -0,0 +1,42 @@ +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 + callerAddress 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 + callerAddress 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 c50a5127..570c9a0a 100644 --- a/pkg/rewards/claim.go +++ b/pkg/rewards/claim.go @@ -44,7 +44,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) } @@ -114,7 +114,7 @@ func batchClaim( return eigenSdkUtils.WrapError("failed to parse YAML config", err) } - var elClaims []rewardscoordinator.IRewardsCoordinatorRewardsMerkleClaim + var elClaims []rewardscoordinator.IRewardsCoordinatorTypesRewardsMerkleClaim var claims []contractrewardscoordinator.IRewardsCoordinatorRewardsMerkleClaim var accounts []merkletree.MerkleTree @@ -163,7 +163,7 @@ func generateClaimPayload( logger logging.Logger, earnerAddress gethcommon.Address, tokenAddresses []gethcommon.Address, -) (*rewardscoordinator.IRewardsCoordinatorRewardsMerkleClaim, *contractrewardscoordinator.IRewardsCoordinatorRewardsMerkleClaim, *merkletree.MerkleTree, error) { +) (*rewardscoordinator.IRewardsCoordinatorTypesRewardsMerkleClaim, *contractrewardscoordinator.IRewardsCoordinatorRewardsMerkleClaim, *merkletree.MerkleTree, error) { claimableTokensOrderMap, present := proofData.Distribution.GetTokensForEarner(earnerAddress) if !present { @@ -187,11 +187,11 @@ func generateClaimPayload( return nil, nil, nil, 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, }, @@ -279,7 +279,7 @@ func Claim(cCtx *cli.Context, p utils.Prompter) error { return err } - elClaims := []rewardscoordinator.IRewardsCoordinatorRewardsMerkleClaim{*elClaim} + elClaims := []rewardscoordinator.IRewardsCoordinatorTypesRewardsMerkleClaim{*elClaim} claims := []contractrewardscoordinator.IRewardsCoordinatorRewardsMerkleClaim{*claim} accounts := []merkletree.MerkleTree{*account} err = broadcastClaims(config, ethClient, logger, p, ctx, elClaims, claims, accounts) @@ -293,7 +293,7 @@ func broadcastClaims( logger logging.Logger, p utils.Prompter, ctx context.Context, - elClaims []rewardscoordinator.IRewardsCoordinatorRewardsMerkleClaim, + elClaims []rewardscoordinator.IRewardsCoordinatorTypesRewardsMerkleClaim, claims []contractrewardscoordinator.IRewardsCoordinatorRewardsMerkleClaim, accounts []merkletree.MerkleTree, ) error { @@ -346,11 +346,7 @@ func broadcastClaims( // 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 } @@ -528,10 +524,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..742ef8c5 100644 --- a/pkg/types/chain_metadata.go +++ b/pkg/types/chain_metadata.go @@ -1,10 +1,11 @@ package types type ChainMetadata struct { - BlockExplorerUrl string - ELDelegationManagerAddress string - ELAVSDirectoryAddress string - ELRewardsCoordinatorAddress string - WebAppUrl string - ProofStoreBaseURL string + BlockExplorerUrl string + ELDelegationManagerAddress string + ELAVSDirectoryAddress string + ELRewardsCoordinatorAddress string + ELPermissionControllerAddress 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..3b75833c --- /dev/null +++ b/pkg/user/admin/accept.go @@ -0,0 +1,235 @@ +package admin + +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/user" + "github.com/Layr-Labs/eigenlayer-cli/pkg/utils" + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + eigenSdkUtils "github.com/Layr-Labs/eigensdk-go/utils" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/urfave/cli/v2" +) + +type AcceptAdminWriter interface { + AcceptAdmin( + ctx context.Context, + request elcontracts.AcceptAdminRequest, + ) (*gethtypes.Receipt, error) + NewAcceptAdminTx( + txOpts *bind.TransactOpts, + request elcontracts.AcceptAdminRequest, + ) (*gethtypes.Transaction, error) +} + +func AcceptCmd(generator func(logging.Logger, *acceptAdminConfig) (AcceptAdminWriter, error)) *cli.Command { + acceptCmd := &cli.Command{ + Name: "accept-admin", + Usage: "Accepts a user to become admin who is currently pending admin acceptance.", + Action: func(c *cli.Context) error { + return acceptAdmin(c, generator) + }, + After: telemetry.AfterRunAction(), + Flags: acceptFlags(), + } + + return acceptCmd +} + +func acceptAdmin( + cliCtx *cli.Context, + generator func(logging.Logger, *acceptAdminConfig) (AcceptAdminWriter, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateAcceptAdminConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user admin accept config", err) + } + cliCtx.App.Metadata["network"] = config.ChainID.String() + elWriter, err := generator(logger, config) + if err != nil { + return err + } + + acceptRequest := elcontracts.AcceptAdminRequest{ + AccountAddress: config.AccountAddress, + WaitForReceipt: true, + } + + if config.Broadcast { + return broadcastAcceptAdminTx(ctx, elWriter, config, acceptRequest) + } + + return printAcceptAdminTx(logger, elWriter, config, acceptRequest) +} + +func broadcastAcceptAdminTx( + ctx context.Context, + elWriter AcceptAdminWriter, + config *acceptAdminConfig, + request elcontracts.AcceptAdminRequest, +) error { + receipt, err := elWriter.AcceptAdmin(ctx, request) + if err != nil { + return eigenSdkUtils.WrapError("failed to broadcast AcceptAdmin transaction", err) + } + common.PrintTransactionInfo(receipt.TxHash.String(), config.ChainID) + return nil +} + +func printAcceptAdminTx( + logger logging.Logger, + elWriter AcceptAdminWriter, + config *acceptAdminConfig, + request elcontracts.AcceptAdminRequest, +) error { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return err + } + + noSendTxOpts := common.GetNoSendTxOpts(config.CallerAddress) + if common.IsSmartContractAddress(config.CallerAddress, ethClient) { + noSendTxOpts.GasLimit = 150_000 + } + + // Generate unsigned transaction + unsignedTx, err := elWriter.NewAcceptAdminTx(noSendTxOpts, request) + 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.OutputFile) { + err = common.WriteToFile([]byte(calldataHex), config.OutputFile) + if err != nil { + return err + } + logger.Infof("Call data written to file: %s", config.OutputFile) + } else { + fmt.Println(calldataHex) + } + } else { + if !common.IsEmptyString(config.OutputType) { + fmt.Println("output file not supported for pretty output type") + fmt.Println() + } + fmt.Printf( + "Pending admin at address %s will accept admin role\n", + config.CallerAddress, + ) + } + + txFeeDetails := common.GetTxFeeDetails(unsignedTx) + fmt.Println() + txFeeDetails.Print() + fmt.Println("To broadcast the transaction, use the --broadcast flag") + return nil +} + +func readAndValidateAcceptAdminConfig( + cliContext *cli.Context, + logger logging.Logger, +) (*acceptAdminConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + callerAddress := user.PopulateCallerAddress(cliContext, logger, accountAddress) + ethRpcUrl := cliContext.String(flags.ETHRpcUrlFlag.Name) + network := cliContext.String(flags.NetworkFlag.Name) + environment := cliContext.String(flags.EnvironmentFlag.Name) + outputType := cliContext.String(flags.OutputTypeFlag.Name) + outputFile := cliContext.String(flags.OutputFileFlag.Name) + broadcast := cliContext.Bool(flags.BroadcastFlag.Name) + if environment == "" { + environment = common.GetEnvFromNetwork(network) + } + signerConfig, err := common.GetSignerConfig(cliContext, 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) + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &acceptAdminConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + CallerAddress: callerAddress, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + SignerConfig: *signerConfig, + ChainID: chainID, + Environment: environment, + OutputFile: outputFile, + OutputType: outputType, + Broadcast: broadcast, + }, nil +} + +func acceptFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &user.CallerAddressFlag, + &PermissionControllerAddressFlag, + &flags.OutputTypeFlag, + &flags.OutputFileFlag, + &flags.BroadcastFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + } + cmdFlags = append(cmdFlags, flags.GetSignerFlags()...) + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} + +func generateAcceptAdminWriter( + prompter utils.Prompter, +) func(logger logging.Logger, config *acceptAdminConfig) (AcceptAdminWriter, error) { + return func(logger logging.Logger, config *acceptAdminConfig) (AcceptAdminWriter, error) { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to create new eth client", err) + } + return common.GetELWriter( + config.CallerAddress, + &config.SignerConfig, + ethClient, + elcontracts.Config{ + PermissionsControllerAddress: config.PermissionControllerAddress, + }, + prompter, + config.ChainID, + logger, + ) + } +} diff --git a/pkg/user/admin/accept_test.go b/pkg/user/admin/accept_test.go new file mode 100644 index 00000000..1347d8c8 --- /dev/null +++ b/pkg/user/admin/accept_test.go @@ -0,0 +1,126 @@ +package admin + +import ( + "context" + "errors" + "testing" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + + "github.com/urfave/cli/v2" +) + +type mockAcceptAdminWriter struct { + acceptAdminFunc func(ctx context.Context, request elcontracts.AcceptAdminRequest) (*gethtypes.Receipt, error) + newAcceptAdminTxFunc func(txOpts *bind.TransactOpts, request elcontracts.AcceptAdminRequest) (*gethtypes.Transaction, error) +} + +func (m *mockAcceptAdminWriter) AcceptAdmin( + ctx context.Context, + request elcontracts.AcceptAdminRequest, +) (*gethtypes.Receipt, error) { + return m.acceptAdminFunc(ctx, request) +} +func (m *mockAcceptAdminWriter) NewAcceptAdminTx( + txOpts *bind.TransactOpts, + request elcontracts.AcceptAdminRequest, +) (*gethtypes.Transaction, error) { + if m.newAcceptAdminTxFunc == nil { + return nil, errors.New("newAcceptAdminTxFunc not implemented") + } + return m.newAcceptAdminTxFunc(txOpts, request) +} + +func generateMockAcceptAdminWriter( + receipt *gethtypes.Receipt, + tx *gethtypes.Transaction, + err error, +) func(logging.Logger, *acceptAdminConfig) (AcceptAdminWriter, error) { + return func(logger logging.Logger, config *acceptAdminConfig) (AcceptAdminWriter, error) { + return &mockAcceptAdminWriter{ + acceptAdminFunc: func(ctx context.Context, request elcontracts.AcceptAdminRequest) (*gethtypes.Receipt, error) { + return receipt, err + }, + newAcceptAdminTxFunc: func(txOpts *bind.TransactOpts, request elcontracts.AcceptAdminRequest) (*gethtypes.Transaction, error) { + return tx, err + }, + }, nil + } +} + +func TestAcceptCmd_Success(t *testing.T) { + mockReceipt := &gethtypes.Receipt{ + TxHash: gethcommon.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"), + } + mockTx := &gethtypes.Transaction{} + + app := cli.NewApp() + app.Commands = []*cli.Command{ + AcceptCmd(generateMockAcceptAdminWriter(mockReceipt, mockTx, nil)), + } + + args := []string{ + "TestAcceptCmd_Success", + "accept-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestAcceptCmd_GeneratorError(t *testing.T) { + expectedError := "failed to create admin writer" + app := cli.NewApp() + app.Commands = []*cli.Command{ + AcceptCmd(func(logger logging.Logger, config *acceptAdminConfig) (AcceptAdminWriter, error) { + return nil, errors.New(expectedError) + }), + } + + args := []string{ + "TestAcceptCmd_GeneratorError", + "accept-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} + +func TestAcceptCmd_AcceptAdminError(t *testing.T) { + expectedError := "error accepting admin" + mockTx := &gethtypes.Transaction{} + + app := cli.NewApp() + app.Commands = []*cli.Command{ + AcceptCmd(generateMockAcceptAdminWriter(nil, mockTx, errors.New(expectedError))), + } + + args := []string{ + "TestAcceptCmd_AcceptAdminError", + "accept-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} diff --git a/pkg/user/admin/add_pending.go b/pkg/user/admin/add_pending.go new file mode 100644 index 00000000..7a596987 --- /dev/null +++ b/pkg/user/admin/add_pending.go @@ -0,0 +1,241 @@ +package admin + +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/user" + "github.com/Layr-Labs/eigenlayer-cli/pkg/utils" + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + eigenSdkUtils "github.com/Layr-Labs/eigensdk-go/utils" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/urfave/cli/v2" +) + +type AddPendingAdminWriter interface { + AddPendingAdmin( + ctx context.Context, + request elcontracts.AddPendingAdminRequest, + ) (*gethtypes.Receipt, error) + NewAddPendingAdminTx( + txOpts *bind.TransactOpts, + request elcontracts.AddPendingAdminRequest, + ) (*gethtypes.Transaction, error) +} + +func AddPendingCmd(generator func(logging.Logger, *addPendingAdminConfig) (AddPendingAdminWriter, error)) *cli.Command { + addPendingCmd := &cli.Command{ + Name: "add-pending-admin", + Usage: "Add an admin to be pending until accepted.", + Action: func(context *cli.Context) error { + return addPendingAdmin(context, generator) + }, + After: telemetry.AfterRunAction(), + Flags: addPendingFlags(), + } + + return addPendingCmd +} + +func addPendingAdmin( + cliCtx *cli.Context, + generator func(logging.Logger, *addPendingAdminConfig) (AddPendingAdminWriter, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateAddPendingAdminConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user admin add pending config", err) + } + cliCtx.App.Metadata["network"] = config.ChainID.String() + elWriter, err := generator(logger, config) + if err != nil { + return err + } + + addPendingRequest := elcontracts.AddPendingAdminRequest{ + AccountAddress: config.AccountAddress, + AdminAddress: config.AdminAddress, + WaitForReceipt: true, + } + + if config.Broadcast { + return broadcastAddPendingAdminTx(ctx, elWriter, config, addPendingRequest) + } + return printAddPendingAdminTx(logger, elWriter, config, addPendingRequest) +} + +func printAddPendingAdminTx( + logger logging.Logger, + elWriter AddPendingAdminWriter, + config *addPendingAdminConfig, + request elcontracts.AddPendingAdminRequest, +) error { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return err + } + noSendTxOpts := common.GetNoSendTxOpts(config.CallerAddress) + if common.IsSmartContractAddress(config.CallerAddress, ethClient) { + // address is a smart contract + noSendTxOpts.GasLimit = 150_000 + } + + unsignedTx, err := elWriter.NewAddPendingAdminTx(noSendTxOpts, request) + 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.OutputFile) { + err = common.WriteToFile([]byte(calldataHex), config.OutputFile) + if err != nil { + return err + } + logger.Infof("Call data written to file: %s", config.OutputFile) + } else { + fmt.Println(calldataHex) + } + } else { + if !common.IsEmptyString(config.OutputType) { + fmt.Println("output file not supported for pretty output type") + fmt.Println() + } + fmt.Printf( + "Admin %s will be added as pending for account %s\n", + config.AdminAddress, + config.AccountAddress, + ) + } + txFeeDetails := common.GetTxFeeDetails(unsignedTx) + fmt.Println() + txFeeDetails.Print() + fmt.Println("To broadcast the transaction, use the --broadcast flag") + return nil +} + +func broadcastAddPendingAdminTx( + ctx context.Context, + elWriter AddPendingAdminWriter, + config *addPendingAdminConfig, + request elcontracts.AddPendingAdminRequest, +) error { + receipt, err := elWriter.AddPendingAdmin( + ctx, + request, + ) + if err != nil { + return err + } + common.PrintTransactionInfo(receipt.TxHash.String(), config.ChainID) + return nil +} + +func readAndValidateAddPendingAdminConfig( + cliContext *cli.Context, + logger logging.Logger, +) (*addPendingAdminConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + adminAddress := gethcommon.HexToAddress(cliContext.String(AdminAddressFlag.Name)) + callerAddress := user.PopulateCallerAddress(cliContext, logger, accountAddress) + ethRpcUrl := cliContext.String(flags.ETHRpcUrlFlag.Name) + network := cliContext.String(flags.NetworkFlag.Name) + environment := cliContext.String(flags.EnvironmentFlag.Name) + outputType := cliContext.String(flags.OutputTypeFlag.Name) + outputFile := cliContext.String(flags.OutputFileFlag.Name) + broadcast := cliContext.Bool(flags.BroadcastFlag.Name) + if environment == "" { + environment = common.GetEnvFromNetwork(network) + } + signerConfig, err := common.GetSignerConfig(cliContext, 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) + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &addPendingAdminConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + AdminAddress: adminAddress, + CallerAddress: callerAddress, + SignerConfig: *signerConfig, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + ChainID: chainID, + Environment: environment, + OutputFile: outputFile, + OutputType: outputType, + Broadcast: broadcast, + }, nil +} + +func generateAddPendingAdminWriter( + prompter utils.Prompter, +) func(logger logging.Logger, config *addPendingAdminConfig) (AddPendingAdminWriter, error) { + return func(logger logging.Logger, config *addPendingAdminConfig) (AddPendingAdminWriter, error) { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to create new eth client", err) + } + return common.GetELWriter( + config.CallerAddress, + &config.SignerConfig, + ethClient, + elcontracts.Config{ + PermissionsControllerAddress: config.PermissionControllerAddress, + }, + prompter, + config.ChainID, + logger, + ) + } +} + +func addPendingFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AdminAddressFlag, + &user.CallerAddressFlag, + &PermissionControllerAddressFlag, + &flags.OutputTypeFlag, + &flags.OutputFileFlag, + &flags.BroadcastFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + } + cmdFlags = append(cmdFlags, flags.GetSignerFlags()...) + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} diff --git a/pkg/user/admin/add_pending_test.go b/pkg/user/admin/add_pending_test.go new file mode 100644 index 00000000..38099083 --- /dev/null +++ b/pkg/user/admin/add_pending_test.go @@ -0,0 +1,127 @@ +package admin + +import ( + "context" + "errors" + "testing" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +type mockAddPendingAdminWriter struct { + addPendingAdminFunc func(ctx context.Context, request elcontracts.AddPendingAdminRequest) (*gethtypes.Receipt, error) + newAddPendingAdminTxFunc func(txOpts *bind.TransactOpts, request elcontracts.AddPendingAdminRequest) (*gethtypes.Transaction, error) +} + +func (m *mockAddPendingAdminWriter) AddPendingAdmin( + ctx context.Context, + request elcontracts.AddPendingAdminRequest, +) (*gethtypes.Receipt, error) { + return m.addPendingAdminFunc(ctx, request) +} + +func (m *mockAddPendingAdminWriter) NewAddPendingAdminTx( + txOpts *bind.TransactOpts, + request elcontracts.AddPendingAdminRequest, +) (*gethtypes.Transaction, error) { + return m.newAddPendingAdminTxFunc(txOpts, request) +} + +func generateMockAddPendingAdminWriter( + receipt *gethtypes.Receipt, + tx *gethtypes.Transaction, + err error, +) func(logging.Logger, *addPendingAdminConfig) (AddPendingAdminWriter, error) { + return func(logger logging.Logger, config *addPendingAdminConfig) (AddPendingAdminWriter, error) { + return &mockAddPendingAdminWriter{ + addPendingAdminFunc: func(ctx context.Context, request elcontracts.AddPendingAdminRequest) (*gethtypes.Receipt, error) { + return receipt, err + }, + newAddPendingAdminTxFunc: func(txOpts *bind.TransactOpts, request elcontracts.AddPendingAdminRequest) (*gethtypes.Transaction, error) { + return tx, err + }, + }, nil + } +} + +func TestAddPendingCmd_Success(t *testing.T) { + mockReceipt := &gethtypes.Receipt{ + TxHash: gethcommon.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"), + } + mockTx := &gethtypes.Transaction{} + + app := cli.NewApp() + app.Commands = []*cli.Command{ + AddPendingCmd(generateMockAddPendingAdminWriter(mockReceipt, mockTx, nil)), + } + + args := []string{ + "TestAddPendingCmd_Success", + "add-pending-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestAddPendingCmd_GeneratorError(t *testing.T) { + expectedError := "failed to create admin writer" + app := cli.NewApp() + app.Commands = []*cli.Command{ + AddPendingCmd(func(logger logging.Logger, config *addPendingAdminConfig) (AddPendingAdminWriter, error) { + return nil, errors.New(expectedError) + }), + } + + args := []string{ + "TestAddPendingCmd_GeneratorError", + "add-pending-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} + +func TestAddPendingCmd_AddPendingError(t *testing.T) { + expectedError := "error adding pending admin" + mockTx := &gethtypes.Transaction{} + + app := cli.NewApp() + app.Commands = []*cli.Command{ + AddPendingCmd(generateMockAddPendingAdminWriter(nil, mockTx, errors.New(expectedError))), + } + + args := []string{ + "TestAddPendingCmd_AddPendingError", + "add-pending-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} diff --git a/pkg/user/admin/admin.go b/pkg/user/admin/admin.go new file mode 100644 index 00000000..1faa5e86 --- /dev/null +++ b/pkg/user/admin/admin.go @@ -0,0 +1,31 @@ +package admin + +import ( + "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/urfave/cli/v2" +) + +func AdminCmd(prompter utils.Prompter) *cli.Command { + adminCmd := &cli.Command{ + Name: "admin", + Usage: "Manage admin users.", + After: telemetry.AfterRunAction(), + Flags: []cli.Flag{ + &flags.VerboseFlag, + }, + Subcommands: []*cli.Command{ + AcceptCmd(generateAcceptAdminWriter(prompter)), + AddPendingCmd(generateAddPendingAdminWriter(prompter)), + IsAdminCmd(generateIsAdminReader), + IsPendingCmd(generateIsPendingAdminReader), + ListCmd(generateListAdminsReader), + ListPendingCmd(generateListPendingAdminsReader), + RemoveCmd(generateRemoveAdminWriter(prompter)), + RemovePendingCmd(generateRemovePendingAdminWriter(prompter)), + }, + } + + return adminCmd +} diff --git a/pkg/user/admin/flags.go b/pkg/user/admin/flags.go new file mode 100644 index 00000000..d7fb6a15 --- /dev/null +++ b/pkg/user/admin/flags.go @@ -0,0 +1,30 @@ +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{"ada"}, + Usage: "user admin ... --admin-address \"0x...\"", + EnvVars: []string{"ADMIN_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..6739fba7 --- /dev/null +++ b/pkg/user/admin/is_admin.go @@ -0,0 +1,140 @@ +package admin + +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 IsAdminReader interface { + IsAdmin( + ctx context.Context, + accountAddress gethcommon.Address, + pendingAdminAddress gethcommon.Address, + ) (bool, error) +} + +func IsAdminCmd(readerGenerator func(logging.Logger, *isAdminConfig) (IsAdminReader, error)) *cli.Command { + cmd := &cli.Command{ + Name: "is-admin", + Usage: "Checks if a user is an admin.", + Action: func(c *cli.Context) error { + return isAdmin(c, readerGenerator) + }, + After: telemetry.AfterRunAction(), + Flags: IsAdminFlags(), + } + + return cmd +} + +func isAdmin(cliCtx *cli.Context, generator func(logging.Logger, *isAdminConfig) (IsAdminReader, error)) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateIsAdminConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user admin is admin config", err) + } + cliCtx.App.Metadata["network"] = config.ChainID.String() + elReader, err := generator(logger, config) + if err != nil { + return err + } + + result, err := elReader.IsAdmin(ctx, config.AccountAddress, config.AdminAddress) + if err != nil { + return err + } + printIsAdminResult(result) + return nil +} + +func readAndValidateIsAdminConfig(cliContext *cli.Context, logger logging.Logger) (*isAdminConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + adminAddress := gethcommon.HexToAddress(cliContext.String(AdminAddressFlag.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) + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + var err error + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &isAdminConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + AdminAddress: adminAddress, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + ChainID: chainID, + Environment: environment, + }, nil +} + +func printIsAdminResult(result bool) { + if result { + fmt.Printf("Address provided is an admin.") + } else { + fmt.Printf("Address provided is not an admin.") + } +} + +func generateIsAdminReader(logger logging.Logger, config *isAdminConfig) (IsAdminReader, 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.PermissionControllerAddress, + }, + ethClient, + logger, + ) + return elReader, err +} + +func IsAdminFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AdminAddressFlag, + &PermissionControllerAddressFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} diff --git a/pkg/user/admin/is_admin_test.go b/pkg/user/admin/is_admin_test.go new file mode 100644 index 00000000..ba93d5fa --- /dev/null +++ b/pkg/user/admin/is_admin_test.go @@ -0,0 +1,117 @@ +package admin + +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 mockIsAdminReader struct { + isAdminFunc func(ctx context.Context, accountAddress gethcommon.Address, adminAddress gethcommon.Address) (bool, error) +} + +func (m *mockIsAdminReader) IsAdmin( + ctx context.Context, + accountAddress gethcommon.Address, + adminAddress gethcommon.Address, +) (bool, error) { + return m.isAdminFunc(ctx, accountAddress, adminAddress) +} + +func generateMockIsAdminReader(result bool, err error) func(logging.Logger, *isAdminConfig) (IsAdminReader, error) { + return func(logger logging.Logger, config *isAdminConfig) (IsAdminReader, error) { + return &mockIsAdminReader{ + isAdminFunc: func(ctx context.Context, accountAddress gethcommon.Address, adminAddress gethcommon.Address) (bool, error) { + return result, err + }, + }, nil + } +} + +func TestIsAdminCmd_Success(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{ + IsAdminCmd(generateMockIsAdminReader(true, nil)), + } + + args := []string{ + "TestIsAdminCmd_Success", + "is-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestIsAdminCmd_NotAdmin(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{ + IsAdminCmd(generateMockIsAdminReader(false, nil)), + } + + args := []string{ + "TestIsAdminCmd_NotAdmin", + "is-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestIsAdminCmd_GeneratorError(t *testing.T) { + expectedError := "failed to create admin reader" + app := cli.NewApp() + app.Commands = []*cli.Command{ + IsAdminCmd(func(logger logging.Logger, config *isAdminConfig) (IsAdminReader, error) { + return nil, errors.New(expectedError) + }), + } + + args := []string{ + "TestIsAdminCmd_GeneratorError", + "is-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} + +func TestIsAdminCmd_IsAdminError(t *testing.T) { + expectedError := "error checking admin status" + app := cli.NewApp() + app.Commands = []*cli.Command{ + IsAdminCmd(generateMockIsAdminReader(false, errors.New(expectedError))), + } + + args := []string{ + "TestIsAdminCmd_IsAdminError", + "is-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} diff --git a/pkg/user/admin/is_pending.go b/pkg/user/admin/is_pending.go new file mode 100644 index 00000000..1c7bda9a --- /dev/null +++ b/pkg/user/admin/is_pending.go @@ -0,0 +1,148 @@ +package admin + +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 IsPendingAdminReader interface { + IsPendingAdmin( + ctx context.Context, + accountAddress gethcommon.Address, + pendingAdminAddress gethcommon.Address, + ) (bool, error) +} + +func IsPendingCmd( + readerGenerator func(logging.Logger, *isPendingAdminConfig) (IsPendingAdminReader, error), +) *cli.Command { + isPendingCmd := &cli.Command{ + Name: "is-pending-admin", + Usage: "Checks if a user is pending acceptance to admin.", + Action: func(c *cli.Context) error { + return isPendingAdmin(c, readerGenerator) + }, + After: telemetry.AfterRunAction(), + Flags: isPendingAdminFlags(), + } + + return isPendingCmd +} + +func isPendingAdmin( + cliCtx *cli.Context, + generator func(logging.Logger, *isPendingAdminConfig) (IsPendingAdminReader, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateIsPendingAdminConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user admin is pending config", err) + } + cliCtx.App.Metadata["network"] = config.ChainID.String() + elReader, err := generator(logger, config) + if err != nil { + return err + } + + result, err := elReader.IsPendingAdmin(ctx, config.AccountAddress, config.PendingAdminAddress) + if err != nil { + return err + } + printIsPendingAdminResult(result) + return nil +} + +func readAndValidateIsPendingAdminConfig( + cliContext *cli.Context, + logger logging.Logger, +) (*isPendingAdminConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + pendingAdminAddress := gethcommon.HexToAddress(cliContext.String(PendingAdminAddressFlag.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) + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + var err error + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &isPendingAdminConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + PendingAdminAddress: pendingAdminAddress, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + ChainID: chainID, + Environment: environment, + }, nil +} + +func printIsPendingAdminResult(result bool) { + if result { + fmt.Printf("Address provided is a pending admin.") + } else { + fmt.Printf("Address provided is not a pending admin.") + } +} + +func generateIsPendingAdminReader(logger logging.Logger, config *isPendingAdminConfig) (IsPendingAdminReader, 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.PermissionControllerAddress, + }, + ethClient, + logger, + ) + return elReader, err +} + +func isPendingAdminFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &PendingAdminAddressFlag, + &PermissionControllerAddressFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} diff --git a/pkg/user/admin/is_pending_test.go b/pkg/user/admin/is_pending_test.go new file mode 100644 index 00000000..9116ef63 --- /dev/null +++ b/pkg/user/admin/is_pending_test.go @@ -0,0 +1,120 @@ +package admin + +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 mockIsPendingAdminReader struct { + isPendingAdminFunc func(ctx context.Context, accountAddress gethcommon.Address, pendingAdminAddress gethcommon.Address) (bool, error) +} + +func (m *mockIsPendingAdminReader) IsPendingAdmin( + ctx context.Context, + accountAddress gethcommon.Address, + pendingAdminAddress gethcommon.Address, +) (bool, error) { + return m.isPendingAdminFunc(ctx, accountAddress, pendingAdminAddress) +} + +func generateMockIsPendingAdminReader( + result bool, + err error, +) func(logging.Logger, *isPendingAdminConfig) (IsPendingAdminReader, error) { + return func(logger logging.Logger, config *isPendingAdminConfig) (IsPendingAdminReader, error) { + return &mockIsPendingAdminReader{ + isPendingAdminFunc: func(ctx context.Context, accountAddress gethcommon.Address, pendingAdminAddress gethcommon.Address) (bool, error) { + return result, err + }, + }, nil + } +} + +func TestIsPendingCmd_Success(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{ + IsPendingCmd(generateMockIsPendingAdminReader(true, nil)), + } + + args := []string{ + "TestIsPendingCmd_Success", + "is-pending-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--pending-admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestIsPendingCmd_NotPending(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{ + IsPendingCmd(generateMockIsPendingAdminReader(false, nil)), + } + + args := []string{ + "TestIsPendingCmd_NotPending", + "is-pending-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--pending-admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestIsPendingCmd_GeneratorError(t *testing.T) { + expectedError := "failed to create pending admin reader" + app := cli.NewApp() + app.Commands = []*cli.Command{ + IsPendingCmd(func(logger logging.Logger, config *isPendingAdminConfig) (IsPendingAdminReader, error) { + return nil, errors.New(expectedError) + }), + } + + args := []string{ + "TestIsPendingCmd_GeneratorError", + "is-pending-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--pending-admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} + +func TestIsPendingCmd_IsPendingAdminError(t *testing.T) { + expectedError := "error checking pending admin status" + app := cli.NewApp() + app.Commands = []*cli.Command{ + IsPendingCmd(generateMockIsPendingAdminReader(false, errors.New(expectedError))), + } + + args := []string{ + "TestIsPendingCmd_IsPendingAdminError", + "is-pending-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--pending-admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} diff --git a/pkg/user/admin/list.go b/pkg/user/admin/list.go new file mode 100644 index 00000000..e04a7549 --- /dev/null +++ b/pkg/user/admin/list.go @@ -0,0 +1,140 @@ +package admin + +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 ListAdminsReader interface { + ListAdmins( + ctx context.Context, + userAddress gethcommon.Address, + ) ([]gethcommon.Address, error) +} + +func ListCmd(readerGenerator func(logging.Logger, *listAdminsConfig) (ListAdminsReader, error)) *cli.Command { + listCmd := &cli.Command{ + Name: "list-admins", + Usage: "List all users who are admins.", + Action: func(c *cli.Context) error { + return listAdmins(c, readerGenerator) + }, + After: telemetry.AfterRunAction(), + Flags: listAdminFlags(), + } + + return listCmd +} + +func listAdmins( + cliCtx *cli.Context, + generator func(logging.Logger, *listAdminsConfig) (ListAdminsReader, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateListAdminsConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user admin list config", err) + } + cliCtx.App.Metadata["network"] = config.ChainID.String() + elReader, err := generator(logger, config) + if err != nil { + return err + } + + pendingAdmins, err := elReader.ListAdmins(ctx, config.AccountAddress) + if err != nil { + return err + } + printAdmins(config.AccountAddress, pendingAdmins) + return nil +} + +func printAdmins(account gethcommon.Address, admins []gethcommon.Address) { + fmt.Printf("Admins for AccountAddress: %s \n", account) + fmt.Println(strings.Repeat("=", 60)) + for _, admin := range admins { + fmt.Printf("%s \n", admin.String()) + } +} + +func readAndValidateListAdminsConfig(cliContext *cli.Context, logger logging.Logger) (*listAdminsConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.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) + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + var err error + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &listAdminsConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + ChainID: chainID, + Environment: environment, + }, nil +} + +func generateListAdminsReader(logger logging.Logger, config *listAdminsConfig) (ListAdminsReader, 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.PermissionControllerAddress, + }, + ethClient, + logger, + ) + return elReader, err +} + +func listAdminFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &PermissionControllerAddressFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} diff --git a/pkg/user/admin/list_pending.go b/pkg/user/admin/list_pending.go new file mode 100644 index 00000000..7d97cb4b --- /dev/null +++ b/pkg/user/admin/list_pending.go @@ -0,0 +1,148 @@ +package admin + +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 ListPendingAdminsReader interface { + ListPendingAdmins( + ctx context.Context, + userAddress gethcommon.Address, + ) ([]gethcommon.Address, error) +} + +func ListPendingCmd( + readerGenerator func(logging.Logger, *listPendingAdminsConfig) (ListPendingAdminsReader, error), +) *cli.Command { + listPendingCmd := &cli.Command{ + Name: "list-pending-admins", + Usage: "List all users who are pending admin acceptance.", + Action: func(c *cli.Context) error { + return listPendingAdmins(c, readerGenerator) + }, + After: telemetry.AfterRunAction(), + Flags: listPendingAdminsFlags(), + } + + return listPendingCmd +} + +func listPendingAdmins( + cliCtx *cli.Context, + generator func(logging.Logger, *listPendingAdminsConfig) (ListPendingAdminsReader, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateListPendingAdminsConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user admin list pending config", err) + } + cliCtx.App.Metadata["network"] = config.ChainID.String() + elReader, err := generator(logger, config) + if err != nil { + return err + } + + pendingAdmins, err := elReader.ListPendingAdmins(ctx, config.AccountAddress) + if err != nil { + return err + } + printPendingAdmins(config.AccountAddress, pendingAdmins) + return nil +} + +func printPendingAdmins(account gethcommon.Address, admins []gethcommon.Address) { + fmt.Printf("Pending Admins for AccountAddress: %s \n", account) + fmt.Println(strings.Repeat("=", 60)) + for _, admin := range admins { + fmt.Printf("%s \n", admin.String()) + } +} + +func readAndValidateListPendingAdminsConfig( + cliContext *cli.Context, + logger logging.Logger, +) (*listPendingAdminsConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.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) + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + var err error + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &listPendingAdminsConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + ChainID: chainID, + Environment: environment, + }, nil +} + +func generateListPendingAdminsReader( + logger logging.Logger, + config *listPendingAdminsConfig, +) (ListPendingAdminsReader, 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.PermissionControllerAddress, + }, + ethClient, + logger, + ) + return elReader, err +} + +func listPendingAdminsFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &PermissionControllerAddressFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + } + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} diff --git a/pkg/user/admin/list_pending_test.go b/pkg/user/admin/list_pending_test.go new file mode 100644 index 00000000..d6363fca --- /dev/null +++ b/pkg/user/admin/list_pending_test.go @@ -0,0 +1,105 @@ +package admin + +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 mockListPendingAdminsReader struct { + listPendingAdminsFunc func(ctx context.Context, userAddress gethcommon.Address) ([]gethcommon.Address, error) +} + +func (m *mockListPendingAdminsReader) ListPendingAdmins( + ctx context.Context, + userAddress gethcommon.Address, +) ([]gethcommon.Address, error) { + return m.listPendingAdminsFunc(ctx, userAddress) +} + +func generateMockListPendingAdminsReader( + admins []gethcommon.Address, + err error, +) func(logging.Logger, *listPendingAdminsConfig) (ListPendingAdminsReader, error) { + return func(logger logging.Logger, config *listPendingAdminsConfig) (ListPendingAdminsReader, error) { + return &mockListPendingAdminsReader{ + listPendingAdminsFunc: func(ctx context.Context, userAddress gethcommon.Address) ([]gethcommon.Address, error) { + return admins, err + }, + }, nil + } +} + +func TestListPendingCmd_Success(t *testing.T) { + expectedAdmins := []gethcommon.Address{ + gethcommon.HexToAddress("0x1234567890abcdef1234567890abcdef12345678"), + gethcommon.HexToAddress("0xabcdef1234567890abcdef1234567890abcdef12"), + } + + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListPendingCmd(generateMockListPendingAdminsReader(expectedAdmins, nil)), + } + + args := []string{ + "TestListPendingCmd_Success", + "list-pending-admins", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--permission-controller-address", "0xe4dB7125ef7a9D99F809B6b7788f75c8D84d8455", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestListPendingCmd_GeneratorError(t *testing.T) { + expectedError := "failed to create pending admins reader" + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListPendingCmd(func(logger logging.Logger, config *listPendingAdminsConfig) (ListPendingAdminsReader, error) { + return nil, errors.New(expectedError) + }), + } + + args := []string{ + "TestListPendingCmd_GeneratorError", + "list-pending-admins", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--permission-controller-address", "0xe4dB7125ef7a9D99F809B6b7788f75c8D84d8455", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} + +func TestListPendingCmd_ListPendingError(t *testing.T) { + expectedError := "failed to fetch pending admins" + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListPendingCmd(generateMockListPendingAdminsReader(nil, errors.New(expectedError))), + } + + args := []string{ + "TestListPendingCmd_ListPendingError", + "list-pending-admins", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--permission-controller-address", "0xe4dB7125ef7a9D99F809B6b7788f75c8D84d8455", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} diff --git a/pkg/user/admin/list_test.go b/pkg/user/admin/list_test.go new file mode 100644 index 00000000..d925d135 --- /dev/null +++ b/pkg/user/admin/list_test.go @@ -0,0 +1,120 @@ +package admin + +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 mockListAdminsReader struct { + listAdminsFunc func(ctx context.Context, userAddress gethcommon.Address) ([]gethcommon.Address, error) +} + +func (m *mockListAdminsReader) ListAdmins( + ctx context.Context, + userAddress gethcommon.Address, +) ([]gethcommon.Address, error) { + return m.listAdminsFunc(ctx, userAddress) +} + +func generateMockListAdminsReader( + admins []gethcommon.Address, + err error, +) func(logging.Logger, *listAdminsConfig) (ListAdminsReader, error) { + return func(logger logging.Logger, config *listAdminsConfig) (ListAdminsReader, error) { + return &mockListAdminsReader{ + listAdminsFunc: func(ctx context.Context, userAddress gethcommon.Address) ([]gethcommon.Address, error) { + return admins, err + }, + }, nil + } +} + +func TestListCmd_Success(t *testing.T) { + expectedAdmins := []gethcommon.Address{ + gethcommon.HexToAddress("0x1234567890abcdef1234567890abcdef12345678"), + gethcommon.HexToAddress("0xabcdef1234567890abcdef1234567890abcdef12"), + } + + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListCmd(generateMockListAdminsReader(expectedAdmins, nil)), + } + + args := []string{ + "TestListCmd_Success", + "list-admins", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestListCmd_NoAdmins(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListCmd(generateMockListAdminsReader([]gethcommon.Address{}, nil)), + } + + args := []string{ + "TestListCmd_NoAdmins", + "list-admins", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestListCmd_GeneratorError(t *testing.T) { + expectedError := "failed to create admin reader" + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListCmd(func(logger logging.Logger, config *listAdminsConfig) (ListAdminsReader, error) { + return nil, errors.New(expectedError) + }), + } + + args := []string{ + "TestListCmd_GeneratorError", + "list-admins", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} + +func TestListCmd_ListAdminsError(t *testing.T) { + expectedError := "failed to fetch admins" + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListCmd(generateMockListAdminsReader(nil, errors.New(expectedError))), + } + + args := []string{ + "TestListCmd_ListAdminsError", + "list-admins", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} diff --git a/pkg/user/admin/remove.go b/pkg/user/admin/remove.go new file mode 100644 index 00000000..6f38e159 --- /dev/null +++ b/pkg/user/admin/remove.go @@ -0,0 +1,239 @@ +package admin + +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/user" + "github.com/Layr-Labs/eigenlayer-cli/pkg/utils" + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + eigenSdkUtils "github.com/Layr-Labs/eigensdk-go/utils" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/urfave/cli/v2" +) + +type RemoveAdminWriter interface { + RemoveAdmin( + ctx context.Context, + request elcontracts.RemoveAdminRequest, + ) (*gethtypes.Receipt, error) + NewRemoveAdminTx( + txOpts *bind.TransactOpts, + request elcontracts.RemoveAdminRequest, + ) (*gethtypes.Transaction, error) +} + +func RemoveCmd(generator func(logging.Logger, *removeAdminConfig) (RemoveAdminWriter, error)) *cli.Command { + removeCmd := &cli.Command{ + Name: "remove-admin", + Usage: "The remove command allows you to remove an admin user.", + Action: func(context *cli.Context) error { + return removeAdmin(context, generator) + }, + After: telemetry.AfterRunAction(), + Flags: removeFlags(), + } + + return removeCmd +} + +func removeAdmin( + cliCtx *cli.Context, + generator func(logging.Logger, *removeAdminConfig) (RemoveAdminWriter, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateRemoveAdminConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user admin remove config", err) + } + cliCtx.App.Metadata["network"] = config.ChainID.String() + elWriter, err := generator(logger, config) + if err != nil { + return err + } + + removeRequest := elcontracts.RemoveAdminRequest{ + AccountAddress: config.AccountAddress, + AdminAddress: config.AdminAddress, + WaitForReceipt: true, + } + + if config.Broadcast { + return broadcastRemoveAdminTx(ctx, elWriter, config, removeRequest) + } + + return printRemoveAdminTx(logger, elWriter, config, removeRequest) +} + +func broadcastRemoveAdminTx( + ctx context.Context, + elWriter RemoveAdminWriter, + config *removeAdminConfig, + request elcontracts.RemoveAdminRequest, +) error { + receipt, err := elWriter.RemoveAdmin(ctx, request) + if err != nil { + return eigenSdkUtils.WrapError("failed to broadcast RemoveAdmin transaction", err) + } + common.PrintTransactionInfo(receipt.TxHash.String(), config.ChainID) + return nil +} + +func printRemoveAdminTx( + logger logging.Logger, + elWriter RemoveAdminWriter, + config *removeAdminConfig, + request elcontracts.RemoveAdminRequest, +) error { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return err + } + + noSendTxOpts := common.GetNoSendTxOpts(config.CallerAddress) + if common.IsSmartContractAddress(config.CallerAddress, ethClient) { + noSendTxOpts.GasLimit = 150_000 + } + unsignedTx, err := elWriter.NewRemoveAdminTx(noSendTxOpts, request) + 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.OutputFile) { + err = common.WriteToFile([]byte(calldataHex), config.OutputFile) + if err != nil { + return err + } + logger.Infof("Call data written to file: %s", config.OutputFile) + } else { + fmt.Println(calldataHex) + } + } else { + if !common.IsEmptyString(config.OutputType) { + fmt.Println("output file not supported for pretty output type") + fmt.Println() + } + fmt.Printf( + "Admin %s will be removed for account %s\n", + config.AdminAddress, + config.AccountAddress, + ) + } + + txFeeDetails := common.GetTxFeeDetails(unsignedTx) + fmt.Println() + txFeeDetails.Print() + fmt.Println("To broadcast the transaction, use the --broadcast flag") + return nil +} + +func readAndValidateRemoveAdminConfig( + cliContext *cli.Context, + logger logging.Logger, +) (*removeAdminConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + adminAddress := gethcommon.HexToAddress(cliContext.String(AdminAddressFlag.Name)) + callerAddress := user.PopulateCallerAddress(cliContext, logger, accountAddress) + ethRpcUrl := cliContext.String(flags.ETHRpcUrlFlag.Name) + network := cliContext.String(flags.NetworkFlag.Name) + environment := cliContext.String(flags.EnvironmentFlag.Name) + outputType := cliContext.String(flags.OutputTypeFlag.Name) + outputFile := cliContext.String(flags.OutputFileFlag.Name) + broadcast := cliContext.Bool(flags.BroadcastFlag.Name) + if environment == "" { + environment = common.GetEnvFromNetwork(network) + } + signerConfig, err := common.GetSignerConfig(cliContext, 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) + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &removeAdminConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + AdminAddress: adminAddress, + CallerAddress: callerAddress, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + SignerConfig: *signerConfig, + ChainID: chainID, + Environment: environment, + OutputFile: outputFile, + OutputType: outputType, + Broadcast: broadcast, + }, nil +} + +func generateRemoveAdminWriter( + prompter utils.Prompter, +) func(logger logging.Logger, config *removeAdminConfig) (RemoveAdminWriter, error) { + return func(logger logging.Logger, config *removeAdminConfig) (RemoveAdminWriter, error) { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to create new eth client", err) + } + return common.GetELWriter( + config.CallerAddress, + &config.SignerConfig, + ethClient, + elcontracts.Config{ + PermissionsControllerAddress: config.PermissionControllerAddress, + }, + prompter, + config.ChainID, + logger, + ) + } +} + +func removeFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AdminAddressFlag, + &user.CallerAddressFlag, + &PermissionControllerAddressFlag, + &flags.OutputTypeFlag, + &flags.OutputFileFlag, + &flags.BroadcastFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + } + cmdFlags = append(cmdFlags, flags.GetSignerFlags()...) + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} diff --git a/pkg/user/admin/remove_pending.go b/pkg/user/admin/remove_pending.go new file mode 100644 index 00000000..fb2d7ff9 --- /dev/null +++ b/pkg/user/admin/remove_pending.go @@ -0,0 +1,239 @@ +package admin + +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/user" + "github.com/Layr-Labs/eigenlayer-cli/pkg/utils" + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + eigenSdkUtils "github.com/Layr-Labs/eigensdk-go/utils" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/urfave/cli/v2" +) + +type RemovePendingAdminWriter interface { + RemovePendingAdmin( + ctx context.Context, + request elcontracts.RemovePendingAdminRequest, + ) (*gethtypes.Receipt, error) + NewRemovePendingAdminTx( + txOpts *bind.TransactOpts, + request elcontracts.RemovePendingAdminRequest, + ) (*gethtypes.Transaction, error) +} + +func RemovePendingCmd( + generator func(logging.Logger, *removePendingAdminConfig) (RemovePendingAdminWriter, error), +) *cli.Command { + removeCmd := &cli.Command{ + Name: "remove-pending-admin", + Usage: "Remove a user who is pending admin acceptance.", + Action: func(context *cli.Context) error { + return removePendingAdmin(context, generator) + }, + After: telemetry.AfterRunAction(), + Flags: removePendingAdminFlags(), + } + + return removeCmd +} + +func removePendingAdmin( + cliCtx *cli.Context, + generator func(logging.Logger, *removePendingAdminConfig) (RemovePendingAdminWriter, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateRemovePendingAdminConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user admin remove pending config", err) + } + cliCtx.App.Metadata["network"] = config.ChainID.String() + elWriter, err := generator(logger, config) + if err != nil { + return err + } + removeRequest := elcontracts.RemovePendingAdminRequest{ + AccountAddress: config.AccountAddress, + AdminAddress: config.AdminAddress, + WaitForReceipt: true, + } + + if config.Broadcast { + return broadcastRemovePendingAdminTx(ctx, elWriter, config, removeRequest) + } + return printRemovePendingAdminTx(logger, elWriter, config, removeRequest) +} + +func broadcastRemovePendingAdminTx( + ctx context.Context, + elWriter RemovePendingAdminWriter, + config *removePendingAdminConfig, + request elcontracts.RemovePendingAdminRequest, +) error { + receipt, err := elWriter.RemovePendingAdmin(ctx, request) + if err != nil { + return eigenSdkUtils.WrapError("failed to broadcast RemovePendingAdmin transaction", err) + } + common.PrintTransactionInfo(receipt.TxHash.String(), config.ChainID) + return nil +} + +func printRemovePendingAdminTx( + logger logging.Logger, + elWriter RemovePendingAdminWriter, + config *removePendingAdminConfig, + request elcontracts.RemovePendingAdminRequest, +) error { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return err + } + + noSendTxOpts := common.GetNoSendTxOpts(config.CallerAddress) + if common.IsSmartContractAddress(config.CallerAddress, ethClient) { + noSendTxOpts.GasLimit = 150_000 + } + unsignedTx, err := elWriter.NewRemovePendingAdminTx(noSendTxOpts, request) + 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.OutputFile) { + err = common.WriteToFile([]byte(calldataHex), config.OutputFile) + if err != nil { + return err + } + logger.Infof("Call data written to file: %s", config.OutputFile) + } else { + fmt.Println(calldataHex) + } + } else { + if !common.IsEmptyString(config.OutputType) { + fmt.Println("Output file not supported for pretty output type") + fmt.Println() + } + fmt.Printf( + "Pending admin %s will be removed for account %s\n", + config.AdminAddress, + config.AccountAddress, + ) + } + + txFeeDetails := common.GetTxFeeDetails(unsignedTx) + fmt.Println() + txFeeDetails.Print() + fmt.Println("To broadcast the transaction, use the --broadcast flag") + return nil +} + +func readAndValidateRemovePendingAdminConfig( + cliContext *cli.Context, + logger logging.Logger, +) (*removePendingAdminConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + adminAddress := gethcommon.HexToAddress(cliContext.String(AdminAddressFlag.Name)) + callerAddress := user.PopulateCallerAddress(cliContext, logger, accountAddress) + ethRpcUrl := cliContext.String(flags.ETHRpcUrlFlag.Name) + network := cliContext.String(flags.NetworkFlag.Name) + environment := cliContext.String(flags.EnvironmentFlag.Name) + outputType := cliContext.String(flags.OutputTypeFlag.Name) + outputFile := cliContext.String(flags.OutputFileFlag.Name) + broadcast := cliContext.Bool(flags.BroadcastFlag.Name) + if environment == "" { + environment = common.GetEnvFromNetwork(network) + } + signerConfig, err := common.GetSignerConfig(cliContext, 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) + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &removePendingAdminConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + AdminAddress: adminAddress, + CallerAddress: callerAddress, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + SignerConfig: *signerConfig, + ChainID: chainID, + Environment: environment, + OutputFile: outputFile, + OutputType: outputType, + Broadcast: broadcast, + }, nil +} + +func generateRemovePendingAdminWriter( + prompter utils.Prompter, +) func(logger logging.Logger, config *removePendingAdminConfig) (RemovePendingAdminWriter, error) { + return func(logger logging.Logger, config *removePendingAdminConfig) (RemovePendingAdminWriter, error) { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to create new eth client", err) + } + return common.GetELWriter( + config.CallerAddress, + &config.SignerConfig, + ethClient, + elcontracts.Config{ + PermissionsControllerAddress: config.PermissionControllerAddress, + }, + prompter, + config.ChainID, + logger, + ) + } +} + +func removePendingAdminFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AdminAddressFlag, + &user.CallerAddressFlag, + &PermissionControllerAddressFlag, + &flags.BroadcastFlag, + &flags.OutputTypeFlag, + &flags.OutputFileFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + } + cmdFlags = append(cmdFlags, flags.GetSignerFlags()...) + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} diff --git a/pkg/user/admin/remove_pending_test.go b/pkg/user/admin/remove_pending_test.go new file mode 100644 index 00000000..12ff0d62 --- /dev/null +++ b/pkg/user/admin/remove_pending_test.go @@ -0,0 +1,128 @@ +package admin + +import ( + "context" + "errors" + "testing" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +type mockRemovePendingAdminWriter struct { + removePendingAdminFunc func(ctx context.Context, request elcontracts.RemovePendingAdminRequest) (*gethtypes.Receipt, error) + newRemovePendingAdminTxFunc func(txOpts *bind.TransactOpts, request elcontracts.RemovePendingAdminRequest) (*gethtypes.Transaction, error) +} + +func (m *mockRemovePendingAdminWriter) RemovePendingAdmin( + ctx context.Context, + request elcontracts.RemovePendingAdminRequest, +) (*gethtypes.Receipt, error) { + return m.removePendingAdminFunc(ctx, request) +} + +func (m *mockRemovePendingAdminWriter) NewRemovePendingAdminTx( + txOpts *bind.TransactOpts, + request elcontracts.RemovePendingAdminRequest, +) (*gethtypes.Transaction, error) { + return m.newRemovePendingAdminTxFunc(txOpts, request) +} + +func generateMockRemovePendingAdminWriter( + receipt *gethtypes.Receipt, + tx *gethtypes.Transaction, + err error, +) func(logging.Logger, *removePendingAdminConfig) (RemovePendingAdminWriter, error) { + return func(logger logging.Logger, config *removePendingAdminConfig) (RemovePendingAdminWriter, error) { + return &mockRemovePendingAdminWriter{ + removePendingAdminFunc: func(ctx context.Context, request elcontracts.RemovePendingAdminRequest) (*gethtypes.Receipt, error) { + return receipt, err + }, + newRemovePendingAdminTxFunc: func(txOpts *bind.TransactOpts, request elcontracts.RemovePendingAdminRequest) (*gethtypes.Transaction, error) { + return tx, err + }, + }, nil + } +} + +func TestRemovePendingCmd_Success(t *testing.T) { + mockReceipt := &gethtypes.Receipt{ + TxHash: gethcommon.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"), + } + mockTx := &gethtypes.Transaction{} + + app := cli.NewApp() + app.Commands = []*cli.Command{ + RemovePendingCmd(generateMockRemovePendingAdminWriter(mockReceipt, mockTx, nil)), + } + + args := []string{ + "TestRemovePendingCmd_Success", + "remove-pending-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.NoError(t, err) +} +func TestRemovePendingCmd_GeneratorError(t *testing.T) { + expectedError := "failed to create admin writer" + app := cli.NewApp() + app.Commands = []*cli.Command{ + RemovePendingCmd( + func(logger logging.Logger, config *removePendingAdminConfig) (RemovePendingAdminWriter, error) { + return nil, errors.New(expectedError) + }, + ), + } + + args := []string{ + "TestRemovePendingCmd_GeneratorError", + "remove-pending-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} + +func TestRemovePendingCmd_RemovePendingError(t *testing.T) { + expectedError := "error removing pending admin" + mockTx := &gethtypes.Transaction{} + + app := cli.NewApp() + app.Commands = []*cli.Command{ + RemovePendingCmd(generateMockRemovePendingAdminWriter(nil, mockTx, errors.New(expectedError))), + } + + args := []string{ + "TestRemovePendingCmd_RemovePendingError", + "remove-pending-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} diff --git a/pkg/user/admin/remove_test.go b/pkg/user/admin/remove_test.go new file mode 100644 index 00000000..d4650643 --- /dev/null +++ b/pkg/user/admin/remove_test.go @@ -0,0 +1,127 @@ +package admin + +import ( + "context" + "errors" + "testing" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +type mockRemoveAdminWriter struct { + removeAdminFunc func(ctx context.Context, request elcontracts.RemoveAdminRequest) (*gethtypes.Receipt, error) + newRemoveAdminTxFunc func(txOpts *bind.TransactOpts, request elcontracts.RemoveAdminRequest) (*gethtypes.Transaction, error) +} + +func (m *mockRemoveAdminWriter) RemoveAdmin( + ctx context.Context, + request elcontracts.RemoveAdminRequest, +) (*gethtypes.Receipt, error) { + return m.removeAdminFunc(ctx, request) +} + +func (m *mockRemoveAdminWriter) NewRemoveAdminTx( + txOpts *bind.TransactOpts, + request elcontracts.RemoveAdminRequest, +) (*gethtypes.Transaction, error) { + return m.newRemoveAdminTxFunc(txOpts, request) +} + +func generateMockRemoveAdminWriter( + receipt *gethtypes.Receipt, + tx *gethtypes.Transaction, + err error, +) func(logging.Logger, *removeAdminConfig) (RemoveAdminWriter, error) { + return func(logger logging.Logger, config *removeAdminConfig) (RemoveAdminWriter, error) { + return &mockRemoveAdminWriter{ + removeAdminFunc: func(ctx context.Context, request elcontracts.RemoveAdminRequest) (*gethtypes.Receipt, error) { + return receipt, err + }, + newRemoveAdminTxFunc: func(txOpts *bind.TransactOpts, request elcontracts.RemoveAdminRequest) (*gethtypes.Transaction, error) { + return tx, err + }, + }, nil + } +} + +func TestRemoveCmd_Success(t *testing.T) { + mockReceipt := &gethtypes.Receipt{ + TxHash: gethcommon.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"), + } + mockTx := &gethtypes.Transaction{} + + app := cli.NewApp() + app.Commands = []*cli.Command{ + RemoveCmd(generateMockRemoveAdminWriter(mockReceipt, mockTx, nil)), + } + + args := []string{ + "TestRemoveCmd_Success", + "remove-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestRemoveCmd_GeneratorError(t *testing.T) { + expectedError := "failed to create admin writer" + app := cli.NewApp() + app.Commands = []*cli.Command{ + RemoveCmd(func(logger logging.Logger, config *removeAdminConfig) (RemoveAdminWriter, error) { + return nil, errors.New(expectedError) + }), + } + + args := []string{ + "TestRemoveCmd_GeneratorError", + "remove-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} + +func TestRemoveCmd_RemoveAdminError(t *testing.T) { + expectedError := "error removing admin" + mockTx := &gethtypes.Transaction{} + + app := cli.NewApp() + app.Commands = []*cli.Command{ + RemoveCmd(generateMockRemoveAdminWriter(nil, mockTx, errors.New(expectedError))), + } + + args := []string{ + "TestRemoveCmd_RemoveAdminError", + "remove-admin", + "--account-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--admin-address", "0x1234567890abcdef1234567890abcdef12345678", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--network", "holesky", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} diff --git a/pkg/user/admin/types.go b/pkg/user/admin/types.go new file mode 100644 index 00000000..a4635f76 --- /dev/null +++ b/pkg/user/admin/types.go @@ -0,0 +1,105 @@ +package admin + +import ( + "math/big" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/types" + gethcommon "github.com/ethereum/go-ethereum/common" +) + +type listPendingAdminsConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + PermissionControllerAddress gethcommon.Address + ChainID *big.Int + Environment string +} + +type listAdminsConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + PermissionControllerAddress gethcommon.Address + ChainID *big.Int + Environment string +} + +type isPendingAdminConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + PendingAdminAddress gethcommon.Address + PermissionControllerAddress gethcommon.Address + ChainID *big.Int + Environment string +} + +type isAdminConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + AdminAddress gethcommon.Address + PermissionControllerAddress gethcommon.Address + ChainID *big.Int + Environment string +} + +type acceptAdminConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + CallerAddress gethcommon.Address + PermissionControllerAddress gethcommon.Address + SignerConfig types.SignerConfig + ChainID *big.Int + Environment string + OutputFile string + OutputType string + Broadcast bool +} + +type addPendingAdminConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + AdminAddress gethcommon.Address + CallerAddress gethcommon.Address + PermissionControllerAddress gethcommon.Address + SignerConfig types.SignerConfig + ChainID *big.Int + Environment string + OutputFile string + OutputType string + Broadcast bool +} + +type removeAdminConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + AdminAddress gethcommon.Address + CallerAddress gethcommon.Address + PermissionControllerAddress gethcommon.Address + SignerConfig types.SignerConfig + ChainID *big.Int + Environment string + OutputFile string + OutputType string + Broadcast bool +} + +type removePendingAdminConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + AdminAddress gethcommon.Address + CallerAddress gethcommon.Address + PermissionControllerAddress gethcommon.Address + SignerConfig types.SignerConfig + ChainID *big.Int + Environment string + OutputFile string + OutputType string + Broadcast bool +} diff --git a/pkg/user/appointee/appointee.go b/pkg/user/appointee/appointee.go new file mode 100644 index 00000000..6882b72d --- /dev/null +++ b/pkg/user/appointee/appointee.go @@ -0,0 +1,28 @@ +package appointee + +import ( + "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/urfave/cli/v2" +) + +func AppointeeCmd(prompter utils.Prompter) *cli.Command { + appointeeCmd := &cli.Command{ + Name: "appointee", + Usage: "User permission management operations.", + After: telemetry.AfterRunAction(), + Flags: []cli.Flag{ + &flags.VerboseFlag, + }, + Subcommands: []*cli.Command{ + canCallCmd(generateCanCallReader), + ListCmd(generateListAppointeesReader), + ListPermissionsCmd(generateListAppointeePermissionsReader), + RemoveCmd(generateRemoveAppointeePermissionWriter(prompter)), + SetCmd(generateSetAppointeePermissionWriter(prompter)), + }, + } + + return appointeeCmd +} diff --git a/pkg/user/appointee/can_call.go b/pkg/user/appointee/can_call.go new file mode 100644 index 00000000..59677787 --- /dev/null +++ b/pkg/user/appointee/can_call.go @@ -0,0 +1,148 @@ +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 CanCallReader interface { + CanCall( + ctx context.Context, + accountAddress gethcommon.Address, + appointeeAddress gethcommon.Address, + target gethcommon.Address, + selector [4]byte, + ) (bool, error) +} + +func canCallCmd(readerGenerator func(logging.Logger, *canCallConfig) (CanCallReader, error)) *cli.Command { + cmd := &cli.Command{ + Name: "can-call", + Usage: "Checks if an appointee 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) (CanCallReader, error)) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateCanCallConfig(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.CanCall(ctx, config.AccountAddress, config.AppointeeAddress, config.Target, config.Selector) + fmt.Printf("CanCall Result: %v\n", result) + fmt.Printf( + "Target, Selector and Appointee: %s, %x, %s\n", + config.Target, + string(config.Selector[:]), + config.AppointeeAddress, + ) + return err +} + +func readAndValidateCanCallConfig(cliContext *cli.Context, logger logging.Logger) (*canCallConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + appointeeAddress := gethcommon.HexToAddress(cliContext.String(AppointeeAddressFlag.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) + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &canCallConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + AppointeeAddress: appointeeAddress, + Target: target, + Selector: selectorBytes, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + ChainID: chainID, + Environment: environment, + }, nil +} + +func generateCanCallReader( + logger logging.Logger, + config *canCallConfig, +) (CanCallReader, 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.PermissionControllerAddress, + }, + ethClient, + logger, + ) + return elReader, err +} + +func canCallFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &AccountAddressFlag, + &AppointeeAddressFlag, + &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..f32a893b --- /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, + accountAddress gethcommon.Address, + appointeeAddress gethcommon.Address, + target gethcommon.Address, + selector [4]byte, + ) (bool, error) +} + +func newMockElChainReader() mockElChainReader { + return mockElChainReader{ + canCallFunc: func(ctx context.Context, accountAddress, appointeeAddress, target gethcommon.Address, selector [4]byte) (bool, error) { + return true, nil + }, + } +} + +func newErrorMockElChainReader(expectedError string) mockElChainReader { + return mockElChainReader{ + canCallFunc: func(ctx context.Context, accountAddress, appointeeAddress, target gethcommon.Address, selector [4]byte) (bool, error) { + return false, errors.New(expectedError) + }, + } +} + +func (m *mockElChainReader) CanCall( + ctx context.Context, + accountAddress, appointeeAddress, + target gethcommon.Address, + selector [4]byte, +) (bool, error) { + return m.canCallFunc(ctx, accountAddress, appointeeAddress, 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", + "--appointee-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_CanCallError(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) (CanCallReader, error) { + return CanCallReader(&mockReader), nil + }), + } + + args := []string{ + "TestCanCallCmd_CanCallError", + "can-call", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--appointee-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", + "--appointee-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", + "--appointee-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) (CanCallReader, error) { + return func(logger logging.Logger, config *canCallConfig) (CanCallReader, error) { + mockReader := newMockElChainReader() + return CanCallReader(&mockReader), nil + } +} diff --git a/pkg/user/appointee/flags.go b/pkg/user/appointee/flags.go new file mode 100644 index 00000000..44bacc3b --- /dev/null +++ b/pkg/user/appointee/flags.go @@ -0,0 +1,36 @@ +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"}, + } + 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"}, + } + 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..adb10e3c --- /dev/null +++ b/pkg/user/appointee/list.go @@ -0,0 +1,162 @@ +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 ListAppointeesReader interface { + ListAppointees( + ctx context.Context, + accountAddress gethcommon.Address, + target gethcommon.Address, + selector [4]byte, + ) ([]gethcommon.Address, error) +} + +func ListCmd(readerGenerator func(logging.Logger, *listAppointeesConfig) (ListAppointeesReader, error)) *cli.Command { + listCmd := &cli.Command{ + Name: "list", + Usage: "Lists all appointed addresses 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, *listAppointeesConfig) (ListAppointeesReader, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateListAppointeesConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user appointee list config", err) + } + + elReader, err := generator(logger, config) + if err != nil { + return err + } + + appointees, err := elReader.ListAppointees(ctx, config.AccountAddress, config.Target, config.Selector) + if err != nil { + return err + } + printResults(config, appointees) + return nil +} + +func printResults(config *listAppointeesConfig, appointees []gethcommon.Address) { + fmt.Printf( + "Target, Selector and Appointer: %s, %x, %s", + config.Target, + string(config.Selector[:]), + config.AccountAddress, + ) + fmt.Println() + fmt.Println(strings.Repeat("=", 60)) + + for _, appointee := range appointees { + fmt.Printf("%s\n", appointee) + } +} + +func readAndValidateListAppointeesConfig( + cliContext *cli.Context, + logger logging.Logger, +) (*listAppointeesConfig, error) { + accountAddress := 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() + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &listAppointeesConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + Target: target, + Selector: selectorBytes, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + ChainID: chainID, + Environment: environment, + }, nil +} + +func generateListAppointeesReader(logger logging.Logger, config *listAppointeesConfig) (ListAppointeesReader, 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.PermissionControllerAddress, + }, + ethClient, + logger, + ) + return elReader, err +} + +func listFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &TargetAddressFlag, + &SelectorFlag, + &PermissionControllerAddressFlag, + &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..3d8403cc --- /dev/null +++ b/pkg/user/appointee/list_permissions.go @@ -0,0 +1,155 @@ +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 PermissionsReader interface { + ListAppointeePermissions( + ctx context.Context, + accountAddress gethcommon.Address, + appointeeAddress gethcommon.Address, + ) ([]gethcommon.Address, [][4]byte, error) +} + +func ListPermissionsCmd( + readerGenerator func(logging.Logger, *listAppointeePermissionsConfig) (PermissionsReader, error), +) *cli.Command { + cmd := &cli.Command{ + Name: "list-permissions", + Usage: "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, *listAppointeePermissionsConfig) (PermissionsReader, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateListAppointeePermissionsConfig(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 + } + + appointees, permissions, err := reader.ListAppointeePermissions(ctx, config.AccountAddress, config.AppointeeAddress) + if err != nil { + return err + } + printPermissions(config, appointees, permissions) + return nil +} + +func readAndValidateListAppointeePermissionsConfig( + cliContext *cli.Context, + logger logging.Logger, +) (*listAppointeePermissionsConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + appointeeAddress := 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) + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + var err error + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &listAppointeePermissionsConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + AppointeeAddress: appointeeAddress, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + ChainID: chainID, + Environment: environment, + }, nil +} + +func printPermissions(config *listAppointeePermissionsConfig, targets []gethcommon.Address, selectors [][4]byte) { + fmt.Printf("Appointee address: %s\n", config.AppointeeAddress) + fmt.Printf("Appointed by: %s\n", config.AccountAddress) + fmt.Println(strings.Repeat("=", 60)) + + for index := range targets { + fmt.Printf("Target: %s, Selector: %x\n", targets[index], selectors[index]) + } +} + +func generateListAppointeePermissionsReader( + logger logging.Logger, + config *listAppointeePermissionsConfig, +) (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.PermissionControllerAddress, + }, + ethClient, + logger, + ) + return elReader, err +} + +func listPermissionFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AppointeeAddressFlag, + &PermissionControllerAddressFlag, + &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..9024bfde --- /dev/null +++ b/pkg/user/appointee/list_permissions_test.go @@ -0,0 +1,117 @@ +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, + accountAddress gethcommon.Address, + appointeeAddress gethcommon.Address, + ) ([]gethcommon.Address, [][4]byte, error) +} + +func newMockListPermissionsReader( + appointeeAddresses []gethcommon.Address, + permissions [][4]byte, + err error, +) *mockListPermissionsReader { + return &mockListPermissionsReader{ + listPermissionsFunc: func(ctx context.Context, accountAddress, appointeeAddress gethcommon.Address) ([]gethcommon.Address, [][4]byte, error) { + return appointeeAddresses, permissions, err + }, + } +} + +func (m *mockListPermissionsReader) ListAppointeePermissions( + ctx context.Context, + accountAddress gethcommon.Address, + appointeeAddress gethcommon.Address, +) ([]gethcommon.Address, [][4]byte, error) { + return m.listPermissionsFunc(ctx, accountAddress, appointeeAddress) +} + +func generateMockListPermissionsReader( + appointeeAddresses []gethcommon.Address, + permissions [][4]byte, + err error, +) func(logging.Logger, *listAppointeePermissionsConfig) (PermissionsReader, error) { + return func(logger logging.Logger, config *listAppointeePermissionsConfig) (PermissionsReader, error) { + return newMockListPermissionsReader(appointeeAddresses, permissions, err), nil + } +} + +func TestListPermissions_Success(t *testing.T) { + expectedAppointees := []gethcommon.Address{ + gethcommon.HexToAddress("0x1234567890abcdef1234567890abcdef12345678"), + } + expectedPermissions := [][4]byte{ + {0x1A, 0x2B, 0x3C, 0x4D}, + } + + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListPermissionsCmd(generateMockListPermissionsReader(expectedAppointees, 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..44321c2a --- /dev/null +++ b/pkg/user/appointee/list_test.go @@ -0,0 +1,136 @@ +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 mockListAppointeesReader struct { + listAppointeesFunc func( + ctx context.Context, + accountAddress gethcommon.Address, + target gethcommon.Address, + selector [4]byte, + ) ([]gethcommon.Address, error) +} + +func newMockListAppointeesReader(appointeeAddresses []gethcommon.Address, err error) *mockListAppointeesReader { + return &mockListAppointeesReader{ + listAppointeesFunc: func(ctx context.Context, accountAddress, target gethcommon.Address, selector [4]byte) ([]gethcommon.Address, error) { + return appointeeAddresses, err + }, + } +} + +func (m *mockListAppointeesReader) ListAppointees( + ctx context.Context, + accountAddress, + target gethcommon.Address, + selector [4]byte, +) ([]gethcommon.Address, error) { + return m.listAppointeesFunc(ctx, accountAddress, target, selector) +} + +func generateMockListReader( + appointeeAddresses []gethcommon.Address, + err error, +) func(logging.Logger, *listAppointeesConfig) (ListAppointeesReader, error) { + return func(logger logging.Logger, config *listAppointeesConfig) (ListAppointeesReader, error) { + return newMockListAppointeesReader(appointeeAddresses, err), nil + } +} + +func TestListAppointees_Success(t *testing.T) { + expectedAppointees := []gethcommon.Address{ + gethcommon.HexToAddress("0x1234567890abcdef1234567890abcdef12345678"), + gethcommon.HexToAddress("0x9876543210fedcba9876543210fedcba98765432"), + } + + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListCmd(generateMockListReader(expectedAppointees, 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_NoAppointees(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{ + ListCmd(generateMockListReader([]gethcommon.Address{}, nil)), + } + + args := []string{ + "TestListAppointees_NoAppointees", + "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..02290af4 --- /dev/null +++ b/pkg/user/appointee/remove.go @@ -0,0 +1,253 @@ +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/user" + "github.com/Layr-Labs/eigenlayer-cli/pkg/utils" + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + eigenSdkUtils "github.com/Layr-Labs/eigensdk-go/utils" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/urfave/cli/v2" +) + +type RemoveAppointeePermissionWriter interface { + RemovePermission( + ctx context.Context, + request elcontracts.RemovePermissionRequest, + ) (*gethtypes.Receipt, error) + NewRemovePermissionTx( + txOpts *bind.TransactOpts, + request elcontracts.RemovePermissionRequest, + ) (*gethtypes.Transaction, error) +} + +func RemoveCmd(generator func(logging.Logger, *removeConfig) (RemoveAppointeePermissionWriter, error)) *cli.Command { + removeCmd := &cli.Command{ + Name: "remove", + Usage: "Remove an appointee's permission", + After: telemetry.AfterRunAction(), + Action: func(c *cli.Context) error { + return removeAppointeePermission(c, generator) + }, + Flags: removeCommandFlags(), + } + + return removeCmd +} + +func removeAppointeePermission( + cliCtx *cli.Context, + generator func(logging.Logger, *removeConfig) (RemoveAppointeePermissionWriter, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateRemoveConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user appointee remove config", err) + } + cliCtx.App.Metadata["network"] = config.ChainID.String() + permissionWriter, err := generator(logger, config) + if err != nil { + return err + } + removePermissionRequest := elcontracts.RemovePermissionRequest{ + AccountAddress: config.AccountAddress, + AppointeeAddress: config.AppointeeAddress, + Target: config.Target, + Selector: config.Selector, + WaitForReceipt: true, + } + if config.Broadcast { + return broadcastRemoveAppointeeTx(ctx, permissionWriter, config, removePermissionRequest) + } + return printRemoveAppointeeTx(logger, permissionWriter, config, removePermissionRequest) +} + +func broadcastRemoveAppointeeTx( + ctx context.Context, + permissionWriter RemoveAppointeePermissionWriter, + config *removeConfig, + request elcontracts.RemovePermissionRequest, +) error { + receipt, err := permissionWriter.RemovePermission( + ctx, + request, + ) + if err != nil { + return err + } + common.PrintTransactionInfo(receipt.TxHash.String(), config.ChainID) + return nil +} + +func printRemoveAppointeeTx( + logger logging.Logger, + permissionWriter RemoveAppointeePermissionWriter, + config *removeConfig, + request elcontracts.RemovePermissionRequest, +) error { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return err + } + noSendTxOpts := common.GetNoSendTxOpts(config.CallerAddress) + if common.IsSmartContractAddress(config.CallerAddress, ethClient) { + noSendTxOpts.GasLimit = 150_000 + } + unsignedTx, err := permissionWriter.NewRemovePermissionTx(noSendTxOpts, request) + 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.OutputFile) { + err = common.WriteToFile([]byte(calldataHex), config.OutputFile) + if err != nil { + return err + } + logger.Infof("Call data written to file: %s", config.OutputFile) + } else { + fmt.Println(calldataHex) + } + } else { + if !common.IsEmptyString(config.OutputType) { + fmt.Println("output file not supported for pretty output type") + fmt.Println() + } + fmt.Printf( + "Appointee %s will lose permission to target %s selector %s for account %s\n", + config.AppointeeAddress, + config.Target, + config.Selector, + config.AccountAddress, + ) + } + txFeeDetails := common.GetTxFeeDetails(unsignedTx) + fmt.Println() + txFeeDetails.Print() + fmt.Println("To broadcast the transaction, use the --broadcast flag") + return nil +} + +func generateRemoveAppointeePermissionWriter( + prompter utils.Prompter, +) func( + logger logging.Logger, + config *removeConfig, +) (RemoveAppointeePermissionWriter, error) { + return func(logger logging.Logger, config *removeConfig) (RemoveAppointeePermissionWriter, error) { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to create new eth client", err) + } + elWriter, err := common.GetELWriter( + config.CallerAddress, + &config.SignerConfig, + ethClient, + elcontracts.Config{ + PermissionsControllerAddress: config.PermissionControllerAddress, + }, + prompter, + config.ChainID, + logger, + ) + return elWriter, err + } +} + +func readAndValidateRemoveConfig(cliContext *cli.Context, logger logging.Logger) (*removeConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + appointeeAddress := gethcommon.HexToAddress(cliContext.String(AppointeeAddressFlag.Name)) + callerAddress := user.PopulateCallerAddress(cliContext, logger, accountAddress) + ethRpcUrl := cliContext.String(flags.ETHRpcUrlFlag.Name) + network := cliContext.String(flags.NetworkFlag.Name) + environment := cliContext.String(flags.EnvironmentFlag.Name) + outputFile := cliContext.String(flags.OutputFileFlag.Name) + outputType := cliContext.String(flags.OutputTypeFlag.Name) + broadcast := cliContext.Bool(flags.BroadcastFlag.Name) + target := gethcommon.HexToAddress(cliContext.String(TargetAddressFlag.Name)) + selector := cliContext.String(SelectorFlag.Name) + selectorBytes, err := common.ValidateAndConvertSelectorString(selector) + if err != nil { + return nil, err + } + signerConfig, err := common.GetSignerConfig(cliContext, 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) + } + + if environment == "" { + environment = common.GetEnvFromNetwork(network) + } + + chainID := utils.NetworkNameToChainId(network) + cliContext.App.Metadata["network"] = chainID.String() + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &removeConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + AppointeeAddress: appointeeAddress, + CallerAddress: callerAddress, + Target: target, + Selector: selectorBytes, + SignerConfig: *signerConfig, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + ChainID: chainID, + Environment: environment, + Broadcast: broadcast, + OutputType: outputType, + OutputFile: outputFile, + }, nil +} + +func removeCommandFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AppointeeAddressFlag, + &user.CallerAddressFlag, + &TargetAddressFlag, + &SelectorFlag, + &PermissionControllerAddressFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + &flags.BroadcastFlag, + &flags.OutputFileFlag, + &flags.OutputTypeFlag, + } + cmdFlags = append(cmdFlags, flags.GetSignerFlags()...) + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} diff --git a/pkg/user/appointee/remove_test.go b/pkg/user/appointee/remove_test.go new file mode 100644 index 00000000..61fee75e --- /dev/null +++ b/pkg/user/appointee/remove_test.go @@ -0,0 +1,123 @@ +package appointee + +import ( + "context" + "errors" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethtypes "github.com/ethereum/go-ethereum/core/types" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/stretchr/testify/assert" + + "github.com/urfave/cli/v2" +) + +type mockRemoveAppointeePermissionWriter struct { + removePermissionFunc func(ctx context.Context, request elcontracts.RemovePermissionRequest) (*gethtypes.Receipt, error) + newRemovePermissionTxFunc func(txOpts *bind.TransactOpts, request elcontracts.RemovePermissionRequest) (*gethtypes.Transaction, error) +} + +func (m *mockRemoveAppointeePermissionWriter) RemovePermission( + ctx context.Context, + request elcontracts.RemovePermissionRequest, +) (*gethtypes.Receipt, error) { + return m.removePermissionFunc(ctx, request) +} + +func (m *mockRemoveAppointeePermissionWriter) NewRemovePermissionTx( + txOpts *bind.TransactOpts, + request elcontracts.RemovePermissionRequest, +) (*gethtypes.Transaction, error) { + return m.newRemovePermissionTxFunc(txOpts, request) +} + +func generateMockRemoveWriter(err error) func(logging.Logger, *removeConfig) (RemoveAppointeePermissionWriter, error) { + return func(logger logging.Logger, config *removeConfig) (RemoveAppointeePermissionWriter, error) { + return &mockRemoveAppointeePermissionWriter{ + removePermissionFunc: func(ctx context.Context, request elcontracts.RemovePermissionRequest) (*gethtypes.Receipt, error) { + return &gethtypes.Receipt{}, err + }, + newRemovePermissionTxFunc: func(txOpts *bind.TransactOpts, request elcontracts.RemovePermissionRequest) (*gethtypes.Transaction, error) { + return &gethtypes.Transaction{}, err + }, + }, nil + } +} + +func TestRemoveCmd_Success(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{ + RemoveCmd(generateMockRemoveWriter(nil)), + } + + args := []string{ + "TestRemoveCmd_Success", + "remove", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--appointee-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--target-address", "0x9876543210fedcba9876543210fedcba98765432", + "--selector", "0x1A2B3C4D", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestRemoveCmd_GeneratorError(t *testing.T) { + expectedError := "failed to create permission writer" + app := cli.NewApp() + app.Commands = []*cli.Command{ + RemoveCmd(func(logger logging.Logger, config *removeConfig) (RemoveAppointeePermissionWriter, error) { + return nil, errors.New(expectedError) + }), + } + + args := []string{ + "TestRemoveCmd_GeneratorError", + "remove", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--appointee-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--target-address", "0x9876543210fedcba9876543210fedcba98765432", + "--selector", "0x1A2B3C4D", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} + +func TestRemoveCmd_RemovePermissionError(t *testing.T) { + expectedError := "error removing appointee permission" + app := cli.NewApp() + app.Commands = []*cli.Command{ + RemoveCmd(generateMockRemoveWriter(errors.New(expectedError))), + } + + args := []string{ + "TestRemoveCmd_RemovePermissionError", + "remove", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--appointee-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--target-address", "0x9876543210fedcba9876543210fedcba98765432", + "--selector", "0x1A2B3C4D", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} diff --git a/pkg/user/appointee/set.go b/pkg/user/appointee/set.go new file mode 100644 index 00000000..0c80dd83 --- /dev/null +++ b/pkg/user/appointee/set.go @@ -0,0 +1,257 @@ +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/user" + "github.com/Layr-Labs/eigenlayer-cli/pkg/utils" + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + eigenSdkUtils "github.com/Layr-Labs/eigensdk-go/utils" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethcommon "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/urfave/cli/v2" +) + +type SetAppointeePermissionWriter interface { + SetPermission( + ctx context.Context, + request elcontracts.SetPermissionRequest, + ) (*gethtypes.Receipt, error) + NewSetPermissionTx( + txOpts *bind.TransactOpts, + request elcontracts.SetPermissionRequest, + ) (*gethtypes.Transaction, error) +} + +func SetCmd(generator func(logging.Logger, *setConfig) (SetAppointeePermissionWriter, error)) *cli.Command { + setCmd := &cli.Command{ + Name: "set", + Usage: "Grant an appointee a permission.", + Action: func(c *cli.Context) error { + return setAppointeePermission(c, generator) + }, + After: telemetry.AfterRunAction(), + Flags: setCommandFlags(), + } + + return setCmd +} + +func setAppointeePermission( + cliCtx *cli.Context, + generator func(logging.Logger, *setConfig) (SetAppointeePermissionWriter, error), +) error { + ctx := cliCtx.Context + logger := common.GetLogger(cliCtx) + + config, err := readAndValidateSetConfig(cliCtx, logger) + if err != nil { + return eigenSdkUtils.WrapError("failed to read and validate user appointee set config", err) + } + cliCtx.App.Metadata["network"] = config.ChainID.String() + permissionWriter, err := generator(logger, config) + if err != nil { + return err + } + return broadcastOrPrint(ctx, logger, permissionWriter, config) +} + +func broadcastOrPrint( + ctx context.Context, + logger logging.Logger, + permissionWriter SetAppointeePermissionWriter, + config *setConfig, +) error { + permissionRequest := elcontracts.SetPermissionRequest{ + AccountAddress: config.AccountAddress, + AppointeeAddress: config.AppointeeAddress, + Target: config.Target, + Selector: config.Selector, + WaitForReceipt: true, + } + if config.Broadcast { + return broadcastSetAppointeeTx(ctx, permissionWriter, config, permissionRequest) + } + return printSetAppointeeResults(logger, permissionWriter, config, permissionRequest) +} + +func broadcastSetAppointeeTx( + ctx context.Context, + permissionWriter SetAppointeePermissionWriter, + config *setConfig, + request elcontracts.SetPermissionRequest, +) error { + receipt, err := permissionWriter.SetPermission(ctx, request) + if err != nil { + return err + } + common.PrintTransactionInfo(receipt.TxHash.String(), config.ChainID) + return nil +} + +func printSetAppointeeResults( + logger logging.Logger, + permissionWriter SetAppointeePermissionWriter, + config *setConfig, + request elcontracts.SetPermissionRequest, +) error { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return err + } + noSendTxOpts := common.GetNoSendTxOpts(config.CallerAddress) + if common.IsSmartContractAddress(config.CallerAddress, ethClient) { + noSendTxOpts.GasLimit = 150_000 + } + + tx, err := permissionWriter.NewSetPermissionTx(noSendTxOpts, request) + if err != nil { + return eigenSdkUtils.WrapError("failed to create unsigned tx", err) + } + if config.OutputType == string(common.OutputType_Calldata) { + calldataHex := gethcommon.Bytes2Hex(tx.Data()) + if !common.IsEmptyString(config.OutputFile) { + err = common.WriteToFile([]byte(calldataHex), config.OutputFile) + if err != nil { + return err + } + logger.Infof("Call data written to file: %s", config.OutputFile) + } else { + fmt.Println(calldataHex) + } + } else { + if !common.IsEmptyString(config.OutputType) { + fmt.Println("output file not supported for pretty output type") + fmt.Println() + } + fmt.Printf( + "Appointee %s will be given permission to target %s selector %s by account %s\n", + config.AppointeeAddress, + config.Target, + config.Selector, + config.AccountAddress, + ) + } + txFeeDetails := common.GetTxFeeDetails(tx) + fmt.Println() + txFeeDetails.Print() + fmt.Println("To broadcast the transaction, use the --broadcast flag") + return nil +} + +func generateSetAppointeePermissionWriter( + prompter utils.Prompter, +) func(logger logging.Logger, config *setConfig) (SetAppointeePermissionWriter, error) { + return func(logger logging.Logger, config *setConfig) (SetAppointeePermissionWriter, error) { + ethClient, err := ethclient.Dial(config.RPCUrl) + if err != nil { + return nil, eigenSdkUtils.WrapError("failed to create new eth client", err) + } + elWriter, err := common.GetELWriter( + config.CallerAddress, + &config.SignerConfig, + ethClient, + elcontracts.Config{ + PermissionsControllerAddress: config.PermissionControllerAddress, + }, + prompter, + config.ChainID, + logger, + ) + return elWriter, err + } +} + +func readAndValidateSetConfig(cliContext *cli.Context, logger logging.Logger) (*setConfig, error) { + accountAddress := gethcommon.HexToAddress(cliContext.String(AccountAddressFlag.Name)) + appointeeAddress := gethcommon.HexToAddress(cliContext.String(AppointeeAddressFlag.Name)) + callerAddress := user.PopulateCallerAddress(cliContext, logger, accountAddress) + ethRpcUrl := cliContext.String(flags.ETHRpcUrlFlag.Name) + network := cliContext.String(flags.NetworkFlag.Name) + environment := cliContext.String(flags.EnvironmentFlag.Name) + outputType := cliContext.String(flags.OutputTypeFlag.Name) + outputFile := cliContext.String(flags.OutputFileFlag.Name) + broadcast := cliContext.Bool(flags.BroadcastFlag.Name) + target := gethcommon.HexToAddress(cliContext.String(TargetAddressFlag.Name)) + selector := cliContext.String(SelectorFlag.Name) + selectorBytes, err := common.ValidateAndConvertSelectorString(selector) + if err != nil { + return nil, err + } + signerConfig, err := common.GetSignerConfig(cliContext, 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) + } + + if environment == "" { + environment = common.GetEnvFromNetwork(network) + } + + chainID := utils.NetworkNameToChainId(network) + cliContext.App.Metadata["network"] = chainID.String() + permissionControllerAddress := cliContext.String(PermissionControllerAddressFlag.Name) + + if common.IsEmptyString(permissionControllerAddress) { + permissionControllerAddress, err = common.GetPermissionControllerAddress(utils.NetworkNameToChainId(network)) + if err != nil { + return nil, err + } + } + + logger.Debugf( + "Env: %s, network: %s, chain ID: %s, PermissionController address: %s", + environment, + network, + chainID, + permissionControllerAddress, + ) + + return &setConfig{ + Network: network, + RPCUrl: ethRpcUrl, + AccountAddress: accountAddress, + AppointeeAddress: appointeeAddress, + CallerAddress: callerAddress, + Target: target, + Selector: selectorBytes, + SignerConfig: *signerConfig, + PermissionControllerAddress: gethcommon.HexToAddress(permissionControllerAddress), + ChainID: chainID, + Environment: environment, + OutputFile: outputFile, + OutputType: outputType, + Broadcast: broadcast, + }, nil +} + +func setCommandFlags() []cli.Flag { + cmdFlags := []cli.Flag{ + &flags.VerboseFlag, + &AccountAddressFlag, + &AppointeeAddressFlag, + &user.CallerAddressFlag, + &TargetAddressFlag, + &SelectorFlag, + &PermissionControllerAddressFlag, + &flags.NetworkFlag, + &flags.EnvironmentFlag, + &flags.ETHRpcUrlFlag, + &flags.BroadcastFlag, + &flags.OutputTypeFlag, + &flags.OutputFileFlag, + } + cmdFlags = append(cmdFlags, flags.GetSignerFlags()...) + sort.Sort(cli.FlagsByName(cmdFlags)) + return cmdFlags +} diff --git a/pkg/user/appointee/set_test.go b/pkg/user/appointee/set_test.go new file mode 100644 index 00000000..5c5e39d1 --- /dev/null +++ b/pkg/user/appointee/set_test.go @@ -0,0 +1,122 @@ +package appointee + +import ( + "context" + "errors" + "testing" + + "github.com/Layr-Labs/eigensdk-go/chainio/clients/elcontracts" + "github.com/Layr-Labs/eigensdk-go/logging" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethtypes "github.com/ethereum/go-ethereum/core/types" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +type mockSetAppointeePermissionWriter struct { + setPermissionFunc func(ctx context.Context, request elcontracts.SetPermissionRequest) (*gethtypes.Receipt, error) + newSetPermissionTxFunc func(txOpts *bind.TransactOpts, request elcontracts.SetPermissionRequest) (*gethtypes.Transaction, error) +} + +func (m *mockSetAppointeePermissionWriter) SetPermission( + ctx context.Context, + request elcontracts.SetPermissionRequest, +) (*gethtypes.Receipt, error) { + return m.setPermissionFunc(ctx, request) +} + +func (m *mockSetAppointeePermissionWriter) NewSetPermissionTx( + txOpts *bind.TransactOpts, + request elcontracts.SetPermissionRequest, +) (*gethtypes.Transaction, error) { + return m.newSetPermissionTxFunc(txOpts, request) +} + +func generateMockSetWriter(err error) func(logging.Logger, *setConfig) (SetAppointeePermissionWriter, error) { + return func(logger logging.Logger, config *setConfig) (SetAppointeePermissionWriter, error) { + return &mockSetAppointeePermissionWriter{ + setPermissionFunc: func(ctx context.Context, request elcontracts.SetPermissionRequest) (*gethtypes.Receipt, error) { + return &gethtypes.Receipt{}, err + }, + newSetPermissionTxFunc: func(txOpts *bind.TransactOpts, request elcontracts.SetPermissionRequest) (*gethtypes.Transaction, error) { + return &gethtypes.Transaction{}, err + }, + }, nil + } +} + +func TestSetCmd_Success(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{ + SetCmd(generateMockSetWriter(nil)), + } + + args := []string{ + "TestSetCmd_Success", + "set", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--appointee-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--target-address", "0x9876543210fedcba9876543210fedcba98765432", + "--selector", "0x1A2B3C4D", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.NoError(t, err) +} + +func TestSetCmd_GeneratorError(t *testing.T) { + expectedError := "failed to create permission writer" + app := cli.NewApp() + app.Commands = []*cli.Command{ + SetCmd(func(logger logging.Logger, config *setConfig) (SetAppointeePermissionWriter, error) { + return nil, errors.New(expectedError) + }), + } + + args := []string{ + "TestSetCmd_GeneratorError", + "set", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--appointee-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--target-address", "0x9876543210fedcba9876543210fedcba98765432", + "--selector", "0x1A2B3C4D", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} + +func TestSetCmd_SetPermissionError(t *testing.T) { + expectedError := "error setting permission" + app := cli.NewApp() + app.Commands = []*cli.Command{ + SetCmd(generateMockSetWriter(errors.New(expectedError))), + } + + args := []string{ + "TestSetCmd_SetPermissionError", + "set", + "--account-address", "0x1234567890abcdef1234567890abcdef12345678", + "--appointee-address", "0xabcdef1234567890abcdef1234567890abcdef12", + "--target-address", "0x9876543210fedcba9876543210fedcba98765432", + "--selector", "0x1A2B3C4D", + "--network", "holesky", + "--eth-rpc-url", "https://ethereum-holesky.publicnode.com/", + "--ecdsa-private-key", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "--broadcast", + } + + err := app.Run(args) + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedError) +} diff --git a/pkg/user/appointee/types.go b/pkg/user/appointee/types.go new file mode 100644 index 00000000..7d6ff1ac --- /dev/null +++ b/pkg/user/appointee/types.go @@ -0,0 +1,75 @@ +package appointee + +import ( + "math/big" + + "github.com/Layr-Labs/eigenlayer-cli/pkg/types" + gethcommon "github.com/ethereum/go-ethereum/common" +) + +type canCallConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + AppointeeAddress gethcommon.Address + Target gethcommon.Address + Selector [4]byte + PermissionControllerAddress gethcommon.Address + ChainID *big.Int + Environment string +} + +type listAppointeesConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + Target gethcommon.Address + Selector [4]byte + PermissionControllerAddress gethcommon.Address + ChainID *big.Int + Environment string +} + +type listAppointeePermissionsConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + AppointeeAddress gethcommon.Address + PermissionControllerAddress gethcommon.Address + ChainID *big.Int + Environment string +} + +type removeConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + AppointeeAddress gethcommon.Address + CallerAddress gethcommon.Address + Target gethcommon.Address + SignerConfig types.SignerConfig + Selector [4]byte + PermissionControllerAddress gethcommon.Address + ChainID *big.Int + Environment string + OutputFile string + OutputType string + Broadcast bool +} + +type setConfig struct { + Network string + RPCUrl string + AccountAddress gethcommon.Address + AppointeeAddress gethcommon.Address + CallerAddress gethcommon.Address + Target gethcommon.Address + SignerConfig types.SignerConfig + Selector [4]byte + PermissionControllerAddress gethcommon.Address + ChainID *big.Int + Environment string + OutputFile string + OutputType string + Broadcast bool +} diff --git a/pkg/user/flags.go b/pkg/user/flags.go new file mode 100644 index 00000000..a59875bb --- /dev/null +++ b/pkg/user/flags.go @@ -0,0 +1,13 @@ +package user + +import "github.com/urfave/cli/v2" + +var ( + CallerAddressFlag = cli.StringFlag{ + Name: "caller-address", + Aliases: []string{"ca"}, + Usage: "This is the address of the caller who is calling the contract function. \n" + + "If it is not provided, the account address will be used as the caller address", + EnvVars: []string{"CALLER_ADDRESS"}, + } +) diff --git a/pkg/user/helper.go b/pkg/user/helper.go new file mode 100644 index 00000000..7d24bd6d --- /dev/null +++ b/pkg/user/helper.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/Layr-Labs/eigenlayer-cli/pkg/internal/common" + "github.com/Layr-Labs/eigensdk-go/logging" + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v2" +) + +func PopulateCallerAddress( + cliContext *cli.Context, + logger logging.Logger, + accountAddress gethcommon.Address, +) gethcommon.Address { + // TODO: these are copied across both callers of this method. Will clean this up in the CLI refactor of flags. + callerAddress := cliContext.String(CallerAddressFlag.Name) + if common.IsEmptyString(callerAddress) { + logger.Infof( + "Caller address not provided. Using account address (%s) as caller address", + accountAddress, + ) + + return accountAddress + } + return gethcommon.HexToAddress(callerAddress) +} diff --git a/pkg/users.go b/pkg/users.go new file mode 100644 index 00000000..af88a528 --- /dev/null +++ b/pkg/users.go @@ -0,0 +1,21 @@ +package pkg + +import ( + "github.com/Layr-Labs/eigenlayer-cli/pkg/user/admin" + "github.com/Layr-Labs/eigenlayer-cli/pkg/user/appointee" + "github.com/Layr-Labs/eigenlayer-cli/pkg/utils" + "github.com/urfave/cli/v2" +) + +func UserCmd(prompter utils.Prompter) *cli.Command { + var userCmd = &cli.Command{ + Name: "user", + Usage: "Manage user permissions", + Subcommands: []*cli.Command{ + admin.AdminCmd(prompter), + appointee.AppointeeCmd(prompter), + }, + } + + return userCmd +} diff --git a/tests/keystore/operator-ci.yaml b/tests/keystore/operator-ci.yaml index ec684748..f718a863 100644 --- a/tests/keystore/operator-ci.yaml +++ b/tests/keystore/operator-ci.yaml @@ -1,8 +1,8 @@ operator: address: 0xcaB1b44dd1f1C265405878Ac1179cd94D0dBA634 delegation_approver_address: 0xcaB1b44dd1f1C265405878Ac1179cd94D0dBA634 - staker_opt_out_window_blocks: 0 metadata_url: https://madhur-test-public.s3.us-east-2.amazonaws.com/metadata.json + allocation_delay: 1200 el_delegation_manager_address: 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 eth_rpc_url: http://localhost:8545 private_key_store_path: /home/runner/.eigenlayer/operator_keys/opr0.ecdsa.key.json diff --git a/tests/user/.env b/tests/user/.env new file mode 100755 index 00000000..d14f6e06 --- /dev/null +++ b/tests/user/.env @@ -0,0 +1,23 @@ +CLI_PATH=../../bin/eigenlayer +NETWORK=anvil +RPC_URL=http://localhost:8545 +OUTPUT_FILE_FOLDER=output + +ACCOUNT_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +FIRST_ADMIN_ADDRESS=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 +SECOND_ADMIN_ADDRESS=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC + +ACCOUNT_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +FIRST_ADMIN_PRIVATE_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d +SECOND_ADMIN_PRIVATE_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a + +APPOINTEE_1=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 +APPOINTEE_2=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC +APPOINTEE_ADDRESS=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 + +TARGET_ADDRESS=0x3Aa5ebB10DC797CAC828524e59A333d0A371443c + +SELECTOR_1=0x4f906cf9 +SELECTOR_2=0x268959e5 + +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 diff --git a/tests/user/admin/admin_lifecycle_tests.sh b/tests/user/admin/admin_lifecycle_tests.sh new file mode 100755 index 00000000..675b0244 --- /dev/null +++ b/tests/user/admin/admin_lifecycle_tests.sh @@ -0,0 +1,310 @@ +#!/usr/bin/env bats + +load './admin_utils.sh' + +setup() { + echo "Setting up test environment..." + rm -f "$OUTPUT_FILE_FOLDER/output_*.txt" + + echo "Listing admins for account $ACCOUNT_ADDRESS..." + output_list_admins=$($CLI_PATH user admin list-admins \ + --account-address "$ACCOUNT_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK") + echo "$output_list_admins" + + echo "Running conditional_add_admin..." + output_conditional_add=$(conditional_add_admin \ + "$ACCOUNT_ADDRESS" \ + "$ACCOUNT_ADDRESS" \ + "$ACCOUNT_PRIVATE_KEY" \ + "$PERMISSION_CONTROLLER_ADDRESS" \ + "$RPC_URL" \ + "$NETWORK" \ + "$OUTPUT_ADD_FILE" \ + "$OUTPUT_ACCEPT_FILE") + echo "$output_conditional_add" +} + +teardown() { + echo "Cleaning up test environment..." + rm -f "$OUTPUT_FILE_FOLDER/output_*.txt" +} + +@test "Add $FIRST_ADMIN_ADDRESS as admin (calldata)" { + run $CLI_PATH user admin add-pending-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$FIRST_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$ACCOUNT_PRIVATE_KEY" \ + --output-type "calldata" \ + --output-file "$OUTPUT_FILE_FOLDER/output_add_first_admin.txt" + + [ "$status" -eq 0 ] + [[ -s "$OUTPUT_FILE_FOLDER/output_add_first_admin.txt" ]] +} + +@test "Add $FIRST_ADMIN_ADDRESS as admin (broadcast)" { + run $CLI_PATH user admin add-pending-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$FIRST_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$ACCOUNT_PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Verify $FIRST_ADMIN_ADDRESS is a pending admin" { + run $CLI_PATH user admin is-pending-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --pending-admin-address "$FIRST_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + echo "$output" + + [ "$status" -eq 0 ] + [[ "$output" == *"Address provided is a pending admin"* ]] +} + +@test "Remove $FIRST_ADMIN_ADDRESS as pending admin (calldata)" { + run $CLI_PATH user admin remove-pending-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$FIRST_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$ACCOUNT_PRIVATE_KEY" \ + --output-type "calldata" \ + --output-file "$OUTPUT_FILE_FOLDER/output_remove_pending_first_admin.txt" + + echo "$output" + + [ "$status" -eq 0 ] + [[ -s "$OUTPUT_FILE_FOLDER/output_remove_pending_first_admin.txt" ]] +} + +@test "Remove $FIRST_ADMIN_ADDRESS as pending admin (broadcast)" { + run $CLI_PATH user admin remove-pending-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$FIRST_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$ACCOUNT_PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Add $FIRST_ADMIN_ADDRESS as admin after removal (broadcast)" { + run $CLI_PATH user admin add-pending-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$FIRST_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$ACCOUNT_PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Accept $FIRST_ADMIN_ADDRESS as admin (calldata)" { + run $CLI_PATH user admin accept-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --caller-address "$FIRST_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$FIRST_ADMIN_PRIVATE_KEY" \ + --output-type "calldata" \ + --output-file "$OUTPUT_FILE_FOLDER/output_accept_first_admin.txt" + + [ "$status" -eq 0 ] + [[ -s "$OUTPUT_FILE_FOLDER/output_accept_first_admin.txt" ]] +} + +@test "Accept $FIRST_ADMIN_ADDRESS as admin (broadcast)" { + run $CLI_PATH user admin accept-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$FIRST_ADMIN_PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Add $SECOND_ADMIN_ADDRESS as admin (calldata)" { + run $CLI_PATH user admin add-pending-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$SECOND_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$FIRST_ADMIN_PRIVATE_KEY" \ + --output-type "calldata" \ + --output-file "$OUTPUT_FILE_FOLDER/output_add_second_admin.txt" + + [ "$status" -eq 0 ] + [[ -s "$OUTPUT_FILE_FOLDER/output_add_second_admin.txt" ]] +} + +@test "Add $SECOND_ADMIN_ADDRESS as admin (broadcast)" { + run $CLI_PATH user admin add-pending-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$SECOND_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$FIRST_ADMIN_PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Verify $SECOND_ADMIN_ADDRESS is listed as a pending admin" { + run $CLI_PATH user admin list-pending-admins \ + --account-address "$ACCOUNT_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + + [ "$status" -eq 0 ] + [[ "$output" != *"$FIRST_ADMIN_ADDRESS"* ]] + [[ "$output" == *"$SECOND_ADMIN_ADDRESS"* ]] +} + +@test "Verify $SECOND_ADMIN_ADDRESS is a pending admin" { + run $CLI_PATH user admin is-pending-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --pending-admin-address "$SECOND_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + echo "$output" + + [ "$status" -eq 0 ] + [[ "$output" == *"Address provided is a pending admin"* ]] +} + +@test "Accept $SECOND_ADMIN_ADDRESS as admin (calldata)" { + run $CLI_PATH user admin accept-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --caller-address "$SECOND_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$SECOND_ADMIN_PRIVATE_KEY" \ + --output-type "calldata" \ + --output-file "$OUTPUT_FILE_FOLDER/output_accept_second_admin.txt" + + [ "$status" -eq 0 ] + [[ -s "$OUTPUT_FILE_FOLDER/output_accept_second_admin.txt" ]] +} + +@test "Accept $SECOND_ADMIN_ADDRESS as admin (broadcast)" { + run $CLI_PATH user admin accept-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$SECOND_ADMIN_PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Verify all three admins are listed after acceptance" { + run $CLI_PATH user admin list-admins \ + --account-address "$ACCOUNT_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + echo "$output" + + [ "$status" -eq 0 ] + [[ "$output" == *"$ACCOUNT_ADDRESS"* ]] + [[ "$output" == *"$FIRST_ADMIN_ADDRESS"* ]] + [[ "$output" == *"$SECOND_ADMIN_ADDRESS"* ]] +} + +@test "Remove $SECOND_ADMIN_ADDRESS as admin (calldata)" { + run $CLI_PATH user admin remove-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$SECOND_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$FIRST_ADMIN_PRIVATE_KEY" \ + --output-type "calldata" \ + --output-file "$OUTPUT_FILE_FOLDER/output_remove_second_admin.txt" + + [ "$status" -eq 0 ] + [[ -s "$OUTPUT_FILE_FOLDER/output_remove_second_admin.txt" ]] +} + +@test "Remove $SECOND_ADMIN_ADDRESS as admin (broadcast)" { + run $CLI_PATH user admin remove-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$SECOND_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$FIRST_ADMIN_PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Remove $FIRST_ADMIN_ADDRESS as admin (calldata)" { + run $CLI_PATH user admin remove-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$FIRST_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$ACCOUNT_PRIVATE_KEY" \ + --output-type "calldata" \ + --output-file "$OUTPUT_FILE_FOLDER/output_remove_first_admin.txt" + + [ "$status" -eq 0 ] + [[ -s "$OUTPUT_FILE_FOLDER/output_remove_first_admin.txt" ]] +} + +@test "Remove $FIRST_ADMIN_ADDRESS as admin (broadcast)" { + run $CLI_PATH user admin remove-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$FIRST_ADMIN_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$ACCOUNT_PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Verify only root admins remains" { + run $CLI_PATH user admin list-admins \ + --account-address "$ACCOUNT_ADDRESS" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" == *"$ACCOUNT_ADDRESS"* ]] + [[ "$output" != *"$FIRST_ADMIN_ADDRESS"* ]] + [[ "$output" != *"$SECOND_ADMIN_ADDRESS"* ]] +} diff --git a/tests/user/admin/admin_utils.sh b/tests/user/admin/admin_utils.sh new file mode 100644 index 00000000..4a7b2ca1 --- /dev/null +++ b/tests/user/admin/admin_utils.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +broadcast_add_pending_admin() { + local account="$1" + local admin="$2" + local private_key="$3" + local permission_controller_address="$4" + local rpc_url="$5" + local network="$6" + + echo "Broadcasting add-pending-admin for $admin to $account..." + $CLI_PATH user admin add-pending-admin \ + --account-address "$account" \ + --admin-address "$admin" \ + --permission-controller-address "$permission_controller_address" \ + --eth-rpc-url "$rpc_url" \ + --network "$network" \ + --ecdsa-private-key "$private_key" \ + --broadcast +} + +broadcast_accept_admin() { + local account="$1" + local private_key="$2" + local permission_controller_address="$3" + local rpc_url="$4" + local network="$5" + + echo "Broadcasting accept-admin for $account..." + $CLI_PATH user admin accept-admin \ + --account-address "$account" \ + --permission-controller-address "$permission_controller_address" \ + --eth-rpc-url "$rpc_url" \ + --network "$network" \ + --ecdsa-private-key "$private_key" \ + --broadcast +} + +conditional_add_admin() { + local account="$1" + local admin="$2" + local private_key="$3" + local permission_controller_address="$4" + local rpc_url="$5" + local network="$6" + local output_add_file="$7" + local output_accept_file="$8" + local is_admin_string="is an admin" + + echo "Checking if $admin is an admin for $account..." + local is_admin_output=$($CLI_PATH user admin is-admin \ + --account-address "$account" \ + --admin-address "$admin" \ + --permission-controller-address "$permission_controller_address" \ + --eth-rpc-url "$rpc_url" \ + --network "$network" 2>&1) + + if echo "$is_admin_output" | grep -q "$is_admin_string"; then + echo "$admin is already an admin for $account." + else + echo "$admin is not an admin for $account. Adding as an admin..." + broadcast_add_pending_admin "$account" "$admin" "$private_key" "$permission_controller_address" "$rpc_url" "$network" "$output_add_file" + broadcast_accept_admin "$account" "$admin" "$private_key" "$permission_controller_address" "$rpc_url" "$network" "$output_accept_file" + fi +} + diff --git a/tests/user/admin/rotate_admin_tests.sh b/tests/user/admin/rotate_admin_tests.sh new file mode 100644 index 00000000..edf0fef3 --- /dev/null +++ b/tests/user/admin/rotate_admin_tests.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bats + +@test "Rotate admins so first admin address is the only admin" { + run $CLI_PATH user admin add-pending-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$FIRST_ADMIN_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$ACCOUNT_PRIVATE_KEY" \ + --broadcast + [ "$status" -eq 0 ] + + run $CLI_PATH user admin accept-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$FIRST_ADMIN_PRIVATE_KEY" \ + --broadcast + [ "$status" -eq 0 ] + + run $CLI_PATH user admin list-admins \ + --account-address "$ACCOUNT_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + [ "$status" -eq 0 ] + + [[ "$output" == *"$FIRST_ADMIN_ADDRESS"* ]] +} + +@test "Rotate admins again so root account is the only admin" { + run $CLI_PATH user admin add-pending-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$ACCOUNT_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$FIRST_ADMIN_PRIVATE_KEY" \ + --broadcast + [ "$status" -eq 0 ] + + run $CLI_PATH user admin accept-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$ACCOUNT_PRIVATE_KEY" \ + --broadcast + [ "$status" -eq 0 ] + + run $CLI_PATH user admin remove-admin \ + --account-address "$ACCOUNT_ADDRESS" \ + --admin-address "$FIRST_ADMIN_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$ACCOUNT_PRIVATE_KEY" \ + --broadcast + [ "$status" -eq 0 ] + + run $CLI_PATH user admin list-admins \ + --account-address "$ACCOUNT_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + [ "$status" -eq 0 ] + + [[ "$output" == *"$ACCOUNT_ADDRESS"* ]] + [[ "$output" != *"$FIRST_ADMIN_ADDRESS"* ]] +} + diff --git a/tests/user/appointee/appointee_lifecycle_tests.sh b/tests/user/appointee/appointee_lifecycle_tests.sh new file mode 100755 index 00000000..76057002 --- /dev/null +++ b/tests/user/appointee/appointee_lifecycle_tests.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bats + +verify_listed_permissions() { + local selector_1=$1 + local selector_2=$2 + local appointee=$3 + local account=$4 + local target=$5 + local output=$6 + + local selector_1_no_prefix=${selector_1#0x} + local selector_2_no_prefix=${selector_2#0x} + + [ "$status" -eq 0 ] + [[ "$output" == *"Appointee address: $appointee"* ]] + [[ "$output" == *"Appointed by: $account"* ]] + [[ "$output" == *"Target: $target, Selector: $selector_1_no_prefix"* ]] + [[ "$output" == *"Target: $target, Selector: $selector_2_no_prefix"* ]] +} + +@test "Add first appointee for selector 1" { + run $CLI_PATH user appointee set \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_1" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Add second appointee for selector 1" { + run $CLI_PATH user appointee set \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_2" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Add first appointee for selector 2" { + run $CLI_PATH user appointee set \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_1" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_2" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Add second appointee for selector 2" { + run $CLI_PATH user appointee set \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_2" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_2" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Verify multiple appointees for selector 1" { + run $CLI_PATH user appointee list \ + --account-address "$ACCOUNT_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" == *"$APPOINTEE_1"* ]] + [[ "$output" == *"$APPOINTEE_2"* ]] +} + +@test "Verify multiple appointees for selector 2" { + run $CLI_PATH user appointee list \ + --account-address "$ACCOUNT_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_2" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" == *"$APPOINTEE_1"* ]] + [[ "$output" == *"$APPOINTEE_2"* ]] +} + +@test "Verify appointee1's listed permissions" { + run $CLI_PATH user appointee list-permissions \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + SELECTOR_1_NO_PREFIX=${SELECTOR_1#0x} + SELECTOR_2_NO_PREFIX=${SELECTOR_2#0x} + verify_listed_permissions \ + "$SELECTOR_1_NO_PREFIX" \ + "$SELECTOR_2_NO_PREFIX" \ + "$APPOINTEE_1" \ + "$ACCOUNT_ADDRESS" \ + "$TARGET_ADDRESS" \ + "$output" +} + +@test "Verify appointee2's listed permissions" { + run $CLI_PATH user appointee list-permissions \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_2" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + SELECTOR_1_NO_PREFIX=${SELECTOR_1#0x} + SELECTOR_2_NO_PREFIX=${SELECTOR_2#0x} + verify_listed_permissions \ + "$SELECTOR_1_NO_PREFIX" \ + "$SELECTOR_2_NO_PREFIX" \ + "$APPOINTEE_2" \ + "$ACCOUNT_ADDRESS" \ + "$TARGET_ADDRESS" \ + "$output" +} + +@test "Test canCall permissions for selectors is true" { + run $CLI_PATH user appointee can-call \ + --appointee-address "$APPOINTEE_1" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" == *"CanCall Result: true"* ]] + + run $CLI_PATH user appointee can-call \ + --appointee-address "$APPOINTEE_1" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" == *"CanCall Result: true"* ]] + + run $CLI_PATH user appointee can-call \ + --appointee-address "$APPOINTEE_2" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" == *"CanCall Result: true"* ]] + + run $CLI_PATH user appointee can-call \ + --appointee-address "$APPOINTEE_2" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_2" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" == *"CanCall Result: true"* ]] +} + +@test "Remove first appointee for selector 1" { + run $CLI_PATH user appointee remove \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_1" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Verify only second appointee remains for selector 1" { + run $CLI_PATH user appointee list \ + --account-address "$ACCOUNT_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" != *"$APPOINTEE_1"* ]] + [[ "$output" == *"$APPOINTEE_2"* ]] +} + +@test "Remove second appointee for selector 1" { + run $CLI_PATH user appointee remove \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_2" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Verify list is empty after selector 1 removals" { + run $CLI_PATH user appointee list \ + --account-address "$ACCOUNT_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" != *"$APPOINTEE_1"* ]] + [[ "$output" != *"$APPOINTEE_2"* ]] +} + +@test "Remove first appointee for selector 2" { + run $CLI_PATH user appointee remove \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_1" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_2" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Verify only second appointee remains for selector 2" { + run $CLI_PATH user appointee list \ + --account-address "$ACCOUNT_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_2" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" != *"$APPOINTEE_1"* ]] + [[ "$output" == *"$APPOINTEE_2"* ]] +} + +@test "Remove second appointee for selector 2" { + run $CLI_PATH user appointee remove \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_2" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_2" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Verify list is empty after selector 2 removals" { + run $CLI_PATH user appointee list \ + --account-address "$ACCOUNT_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_2" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" != *"$APPOINTEE_1"* ]] + [[ "$output" != *"$APPOINTEE_2"* ]] +} + +@test "Test canCall permissions for selectors" { + run $CLI_PATH user appointee can-call \ + --appointee-address "$APPOINTEE_1" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" == *"CanCall Result: false"* ]] + + run $CLI_PATH user appointee can-call \ + --appointee-address "$APPOINTEE_2" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_2" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" == *"CanCall Result: false"* ]] +} + diff --git a/tests/user/appointee/appointee_output_tests.sh b/tests/user/appointee/appointee_output_tests.sh new file mode 100755 index 00000000..d77d431f --- /dev/null +++ b/tests/user/appointee/appointee_output_tests.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bats + +setup() { + echo "Setting up test environment..." + rm -f "$OUTPUT_FILE_FOLDER/output_*.txt" +} + +teardown() { + echo "Cleaning up test environment..." + rm -f "$OUTPUT_FILE_FOLDER/output_*.txt" +} + +@test "Verify canCall permissions for account" { + run $CLI_PATH user appointee can-call \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$ACCOUNT_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" == *"CanCall Result: true"* ]] +} + +@test "Set appointee and verify calldata output (calldata)" { + run $CLI_PATH user appointee set \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$PRIVATE_KEY" \ + --output-type "calldata" \ + --output-file "$OUTPUT_FILE_FOLDER/output_set.txt" + + [ "$status" -eq 0 ] + [[ -s "$OUTPUT_FILE_FOLDER/output_set.txt" ]] +} + +@test "Broadcast set appointee command (broadcast)" { + run $CLI_PATH user appointee set \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Remove appointee and verify calldata output (calldata)" { + run $CLI_PATH user appointee remove \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$PRIVATE_KEY" \ + --output-type "calldata" \ + --output-file "$OUTPUT_FILE_FOLDER/output_remove.txt" + + [ "$status" -eq 0 ] + [[ -s "$OUTPUT_FILE_FOLDER/output_remove.txt" ]] +} + +@test "Broadcast remove appointee command (broadcast)" { + run $CLI_PATH user appointee remove \ + --account-address "$ACCOUNT_ADDRESS" \ + --appointee-address "$APPOINTEE_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" \ + --ecdsa-private-key "$PRIVATE_KEY" \ + --broadcast + + [ "$status" -eq 0 ] +} + +@test "Verify appointee removal" { + run $CLI_PATH user appointee list \ + --account-address "$ACCOUNT_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" != *"$APPOINTEE_ADDRESS"* ]] +} + +@test "Verify canCall permissions for removed appointee" { + run $CLI_PATH user appointee can-call \ + --appointee-address "$APPOINTEE_ADDRESS" \ + --target-address "$TARGET_ADDRESS" \ + --selector "$SELECTOR_1" \ + --permission-controller-address "$PERMISSION_CONTROLLER_ADDRESS" \ + --eth-rpc-url "$RPC_URL" \ + --network "$NETWORK" + + [ "$status" -eq 0 ] + [[ "$output" == *"CanCall Result: false"* ]] +} diff --git a/tests/user/user-integration-tests-runner.sh b/tests/user/user-integration-tests-runner.sh new file mode 100755 index 00000000..941ed043 --- /dev/null +++ b/tests/user/user-integration-tests-runner.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +echo "Starting User command integration tests." + +export $(cat .env | xargs) + +any_test_failures=0 + +run_bats_test() { + local test_file=$1 + bats "$test_file" + if [ $? -ne 0 ]; then + any_test_failures=1 + fi +} + +run_bats_test "admin/rotate_admin_tests.sh" +run_bats_test "admin/admin_lifecycle_tests.sh" +run_bats_test "appointee/appointee_lifecycle_tests.sh" +run_bats_test "appointee/appointee_output_tests.sh" + +rm -rf output/ + +if [ $any_test_failures -eq 0 ]; then + echo "All tests passed." +else + echo "Some tests failed." +fi + +exit $any_test_failures \ No newline at end of file diff --git a/tests/web3signer/operator-ci.yaml b/tests/web3signer/operator-ci.yaml index c765a412..451a0c34 100644 --- a/tests/web3signer/operator-ci.yaml +++ b/tests/web3signer/operator-ci.yaml @@ -1,8 +1,8 @@ operator: address: 0x7dbc809c1ec153d45ffb0c75fb4fded68e34699e delegation_approver_address: 0xcaB1b44dd1f1C265405878Ac1179cd94D0dBA634 - staker_opt_out_window_blocks: 0 metadata_url: https://madhur-test-public.s3.us-east-2.amazonaws.com/metadata.json + allocation_delay: 1200 el_delegation_manager_address: 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 eth_rpc_url: http://localhost:8545 private_key_store_path: