From c7ef3ea7fb4e8b9079d62b75dc3637a3c368555a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 8 Jul 2026 22:58:17 +0100 Subject: [PATCH 1/2] chore: fast-forward main to v0.40-dev (#365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(system): propagate command-check and version parse fixes to v0.40-dev (#350) Propagates #349 to v0.40-dev. * docs: add branching guide (#353) * docs: add branching guide * ci: harden testnet smoke timeout * feat: support fee profiles in contract commands (#355) * feat: support fee profiles in contract commands * test: make fee profile deploy test portable * feat: staking validators discovery (#357) * feat: add staking validators discovery * feat: epoch-aware validator listing with below-min indicator * fix(staking): account-less client for read-only staking queries getReadOnlyStakingClient threw 'Account not found' on fresh installs; listings and other reads don't need a local account. * feat: vesting commands (#358) * feat: add vesting commands * feat(vesting): validator subcommands — create/join, deposit, exit, claim, operator-transfer, set-identity, list/status Drives the CON-607 Vesting.sol validator leg through the SDK's named vestingValidator* actions; list/status enumerate getValidatorWallets with per-wallet deposited principal. * chore(deps): update dependency uuid to v11.1.1 [security] (#320) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * fix(init): use backend provider id "google" for Gemini (#359) Selecting Gemini during `genlayer init` failed with: Requested providers '{'geminiai'}' do not match any stored providers. The selected provider id is forwarded verbatim to sim_createRandomValidators, but the backend's llm_provider table stores Gemini as "google". Rename the provider id geminiai -> google so it matches. Display name ("Gemini") and env var (GEMINI_API_KEY) are unchanged. Since "geminiai" never resolved to a valid provider, no working configuration relied on it. Fixes #271 Co-authored-by: Edgars Nemše * fix(docs-sync): stop overwriting the generated root _meta.json (#352) The sync-docs workflow rsynced the generated category-based docs/api-references/_meta.json into genlayer-docs and then immediately overwrote it with a hardcoded heredoc containing the pre-grouping flat command list (init, up, deploy, ...). Those keys no longer match the directory layout, so the genlayer-docs sidebar rendered broken entries on every sync (fixed manually in genlayer-docs#426; this removes the cause). Also make the generated root meta complete: - add "index": "Overview" for the generated index.mdx - append ungrouped top-level commands (estimate-fees, finalize, finalize-batch) so they get explicit nav entries instead of relying on Nextra's implicit append Snapshot under docs/api-references regenerated against current main (picks up the new estimate-fees command and latest help text). Co-authored-by: Albert Castellana Co-authored-by: Claude Fable 5 Co-authored-by: Edgars * fix: drop getSlashingAddress from validator-history (#361) getSlashingAddress() was removed from the genlayer-js SDK (v0.39+/v2-dev), causing `genlayer staking validator-history` to crash with `client.getSlashingAddress is not a function` before any history is fetched. Resolve the idleness (slashing) contract address dynamically via viem readContract against consensusMainContract.getIdlenessAddress(), falling back to the staking contract address if resolution fails so reward events still display. Port of #344 (by @ygd58) from the dead v0.39 line to v0.40-dev. Supersedes #344. Fixes #341 Co-authored-by: Edgars * feat(network): custom network profiles with deployment-file import (#362) * feat(network): custom network profiles with deployment-file import genlayer network add --base [--deployment ] [--rpc ] [--consensus-main|--consensus-data|--staking|--fee-manager |--rounds-storage|--appeals ] [--chain-id ] [--deployment-key ] Profiles persist as base + address overrides only; resolveNetwork loads the base chain fresh from genlayer-js and applies overrides, so ABIs never go stale. network set/list/info/remove and StakingAction --network accept custom aliases. The consensus deployments.json shape is parsed by walking the tree for ContractName->address leaves (ConsensusMain, ConsensusData, GenStaking/Staking, FeeManager, Rounds/RoundsStorage, Appeals); flags take precedence over the file. Adds a prepare script so npm install from a git ref builds dist. Verified: 576 vitest tests, full manual smoke (add/list/set/info/remove with a deployment file). * chore(deps): bump genlayer-js to v2-dev tip for vesting actions The locked v2-dev SHA (28e99fbc) predates the vesting client actions; vestingValidatorJoin and friends land at 666d1156. Verified live: vesting validator create succeeds against a #1162-branch consensus deployment. * docs(cli): regenerate API references; fix option placeholder regex The docs generator's option regex only matched placeholders, so flags with dots or hyphens in the value name (--base , --deployment , --deployment-key ) were silently dropped from the options tables. Widen to <[^>]+> and regenerate: adds the network add/remove pages and the previously undocumented vesting command section (validator create/deposit/exit/claim, operator-transfer, set-identity, delegate/undelegate/claim/withdraw/list). * fix(vesting): resolve validator wallet address in create output The join receipt does not carry the new wallet address, so the output printed validatorWallet: undefined. Read getValidatorWallets from the vesting contract after the join and report the newest entry. Verified live against a #1162-branch deployment. * fix: make git install build lifecycle robust (#363) * fix: make git install build script self contained * chore: refresh genlayer-js lockfile * fix: make keychain dependency optional * fix: restore git prepare build * fix: include build dependency for git installs * chore: keep esbuild as dev dependency * ci: publish prereleases to npm dist tags (#364) * ci: add clarke cli tarball release * ci: publish prereleases to npm dist tags * Release v0.40.0-rc1 [skip ci] --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Tobu <36818942+Tobu8888@users.noreply.github.com> Co-authored-by: Albert Castellana Co-authored-by: Albert Castellana Co-authored-by: Claude Fable 5 Co-authored-by: Edgars --- .github/workflows/publish.yml | 50 +- .github/workflows/smoke.yml | 4 +- .github/workflows/sync-docs.yml | 24 - CHANGELOG.md | 27 + CONTRIBUTING.md | 20 +- README.md | 59 +- docs/BRANCHING.md | 58 ++ docs/api-references/_meta.json | 7 +- docs/api-references/accounts/account/send.mdx | 2 +- docs/api-references/configuration/network.mdx | 4 +- .../configuration/network/add.mdx | 32 + .../configuration/network/remove.mdx | 21 + docs/api-references/contracts/call.mdx | 1 + docs/api-references/contracts/deploy.mdx | 7 + docs/api-references/contracts/write.mdx | 7 + docs/api-references/estimate-fees.mdx | 29 + docs/api-references/finalize-batch.mdx | 20 + docs/api-references/finalize.mdx | 20 + docs/api-references/index.mdx | 6 +- .../localnet/validators/create-random.mdx | 4 +- docs/api-references/staking/staking.mdx | 2 +- .../staking/staking/active-validators.mdx | 2 +- .../staking/staking/banned-validators.mdx | 2 +- .../staking/staking/delegation-info.mdx | 2 +- .../staking/staking/delegator-claim.mdx | 2 +- .../staking/staking/delegator-exit.mdx | 2 +- .../staking/staking/delegator-join.mdx | 2 +- .../staking/staking/epoch-info.mdx | 2 +- .../staking/staking/prime-all.mdx | 2 +- .../staking/quarantined-validators.mdx | 2 +- .../staking/staking/set-identity.mdx | 2 +- .../staking/staking/set-operator.mdx | 2 +- .../staking/staking/validator-claim.mdx | 2 +- .../staking/staking/validator-deposit.mdx | 2 +- .../staking/staking/validator-exit.mdx | 2 +- .../staking/staking/validator-history.mdx | 2 +- .../staking/staking/validator-info.mdx | 2 +- .../staking/staking/validator-join.mdx | 2 +- .../staking/staking/validator-prime.mdx | 2 +- .../staking/staking/validators.mdx | 7 +- docs/api-references/transactions/receipt.mdx | 2 +- docs/api-references/vesting.mdx | 28 + docs/api-references/vesting/claim.mdx | 27 + docs/api-references/vesting/delegate.mdx | 28 + docs/api-references/vesting/list.mdx | 21 + docs/api-references/vesting/undelegate.mdx | 27 + docs/api-references/vesting/validator.mdx | 31 + .../vesting/validator/claim.mdx | 27 + .../vesting/validator/create.mdx | 28 + .../vesting/validator/deposit.mdx | 28 + .../api-references/vesting/validator/exit.mdx | 28 + .../api-references/vesting/validator/join.mdx | 28 + .../api-references/vesting/validator/list.mdx | 22 + .../vesting/validator/operator-transfer.mdx | 25 + .../validator/operator-transfer/cancel.mdx | 27 + .../validator/operator-transfer/complete.mdx | 27 + .../validator/operator-transfer/initiate.mdx | 29 + .../vesting/validator/set-identity.mdx | 36 + .../vesting/validator/status.mdx | 22 + docs/api-references/vesting/withdraw.mdx | 23 + package-lock.json | 58 +- package.json | 13 +- scripts/generate-cli-docs.mjs | 12 +- scripts/run-esbuild.mjs | 12 + src/commands/account/index.ts | 2 +- src/commands/account/send.ts | 10 +- src/commands/account/show.ts | 2 +- src/commands/contracts/deploy.ts | 13 +- src/commands/contracts/estimateFees.ts | 39 +- src/commands/contracts/fees.ts | 247 +++++-- src/commands/contracts/index.ts | 52 +- src/commands/contracts/write.ts | 27 +- src/commands/network/index.ts | 25 + src/commands/network/setNetwork.ts | 266 +++++++- src/commands/staking/StakingAction.ts | 15 +- src/commands/staking/index.ts | 51 +- src/commands/staking/validatorHistory.ts | 46 +- src/commands/staking/validators.ts | 619 ++++++++++++++++++ src/commands/staking/wizard.ts | 10 +- src/commands/vesting/VestingAction.ts | 155 +++++ src/commands/vesting/claim.ts | 40 ++ src/commands/vesting/delegate.ts | 45 ++ src/commands/vesting/index.ts | 280 ++++++++ src/commands/vesting/list.ts | 157 +++++ src/commands/vesting/undelegate.ts | 53 ++ src/commands/vesting/validatorClaim.ts | 40 ++ src/commands/vesting/validatorCreate.ts | 57 ++ src/commands/vesting/validatorDeposit.ts | 44 ++ src/commands/vesting/validatorExit.ts | 53 ++ src/commands/vesting/validatorList.ts | 93 +++ .../vesting/validatorOperatorTransfer.ts | 116 ++++ src/commands/vesting/validatorSetIdentity.ts | 70 ++ src/commands/vesting/vestingTypes.ts | 156 +++++ src/commands/vesting/withdraw.ts | 41 ++ src/index.ts | 2 + src/lib/actions/BaseAction.ts | 25 +- src/lib/clients/system.ts | 36 +- src/lib/config/simulator.ts | 6 +- src/lib/networks/customNetworks.ts | 253 +++++++ tests/actions/customNetworkProfiles.test.ts | 297 +++++++++ tests/actions/deploy.test.ts | 91 ++- tests/actions/estimateFees.test.ts | 176 ++++- tests/actions/write.test.ts | 128 +++- tests/commands/deploy.test.ts | 48 +- tests/commands/estimateFees.test.ts | 42 +- tests/commands/network.test.ts | 56 ++ tests/commands/staking.test.ts | 31 + tests/commands/stakingValidators.test.ts | 197 ++++++ tests/commands/vesting.test.ts | 493 ++++++++++++++ tests/commands/write.test.ts | 25 + tests/index.test.ts | 4 + tests/libs/system.test.ts | 39 +- tests/services/simulator.test.ts | 15 + tests/smoke.test.ts | 21 +- 114 files changed, 5456 insertions(+), 409 deletions(-) create mode 100644 docs/BRANCHING.md create mode 100644 docs/api-references/configuration/network/add.mdx create mode 100644 docs/api-references/configuration/network/remove.mdx create mode 100644 docs/api-references/estimate-fees.mdx create mode 100644 docs/api-references/finalize-batch.mdx create mode 100644 docs/api-references/finalize.mdx create mode 100644 docs/api-references/vesting.mdx create mode 100644 docs/api-references/vesting/claim.mdx create mode 100644 docs/api-references/vesting/delegate.mdx create mode 100644 docs/api-references/vesting/list.mdx create mode 100644 docs/api-references/vesting/undelegate.mdx create mode 100644 docs/api-references/vesting/validator.mdx create mode 100644 docs/api-references/vesting/validator/claim.mdx create mode 100644 docs/api-references/vesting/validator/create.mdx create mode 100644 docs/api-references/vesting/validator/deposit.mdx create mode 100644 docs/api-references/vesting/validator/exit.mdx create mode 100644 docs/api-references/vesting/validator/join.mdx create mode 100644 docs/api-references/vesting/validator/list.mdx create mode 100644 docs/api-references/vesting/validator/operator-transfer.mdx create mode 100644 docs/api-references/vesting/validator/operator-transfer/cancel.mdx create mode 100644 docs/api-references/vesting/validator/operator-transfer/complete.mdx create mode 100644 docs/api-references/vesting/validator/operator-transfer/initiate.mdx create mode 100644 docs/api-references/vesting/validator/set-identity.mdx create mode 100644 docs/api-references/vesting/validator/status.mdx create mode 100644 docs/api-references/vesting/withdraw.mdx create mode 100644 scripts/run-esbuild.mjs create mode 100644 src/commands/staking/validators.ts create mode 100644 src/commands/vesting/VestingAction.ts create mode 100644 src/commands/vesting/claim.ts create mode 100644 src/commands/vesting/delegate.ts create mode 100644 src/commands/vesting/index.ts create mode 100644 src/commands/vesting/list.ts create mode 100644 src/commands/vesting/undelegate.ts create mode 100644 src/commands/vesting/validatorClaim.ts create mode 100644 src/commands/vesting/validatorCreate.ts create mode 100644 src/commands/vesting/validatorDeposit.ts create mode 100644 src/commands/vesting/validatorExit.ts create mode 100644 src/commands/vesting/validatorList.ts create mode 100644 src/commands/vesting/validatorOperatorTransfer.ts create mode 100644 src/commands/vesting/validatorSetIdentity.ts create mode 100644 src/commands/vesting/vestingTypes.ts create mode 100644 src/commands/vesting/withdraw.ts create mode 100644 src/lib/networks/customNetworks.ts create mode 100644 tests/actions/customNetworkProfiles.test.ts create mode 100644 tests/commands/stakingValidators.test.ts create mode 100644 tests/commands/vesting.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a8c7dea2..e5e27ca3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,6 +7,11 @@ name: Publish Package to NPM # workflow fires on the tag push, sanity-checks the tag matches # package.json, builds, publishes to npm, and creates the GitHub # Release. It never bumps or tags by itself. +# +# Stable tags such as v0.39.2 publish to npm's latest dist-tag. +# Prerelease tags such as v0.40.0-rc1 publish to a matching prerelease +# dist-tag (rc1) and create a GitHub prerelease. Prereleases must never +# become latest. on: workflow_dispatch: push: @@ -32,8 +37,14 @@ jobs: - run: npm ci - - name: Verify tag matches package.json version + - name: Verify tag and resolve npm dist-tag + id: version run: | + if [ "${GITHUB_REF_TYPE:-}" != "tag" ]; then + echo "Publish must run from a git tag. Current ref type: ${GITHUB_REF_TYPE:-unknown}" >&2 + exit 1 + fi + TAG_VERSION="${GITHUB_REF_NAME#v}" PKG_VERSION="$(node -p "require('./package.json').version")" if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then @@ -41,12 +52,34 @@ jobs: echo "Re-cut the release via scripts/release.sh so the tag and the committed version match." >&2 exit 1 fi + + IS_PRERELEASE=false + NPM_DIST_TAG=latest + if [[ "$TAG_VERSION" == *-* ]]; then + IS_PRERELEASE=true + NPM_DIST_TAG="${TAG_VERSION#*-}" + NPM_DIST_TAG="${NPM_DIST_TAG%%+*}" + + if [ -z "$NPM_DIST_TAG" ] || [ "$NPM_DIST_TAG" = "latest" ]; then + echo "Invalid prerelease npm dist-tag: '$NPM_DIST_TAG'" >&2 + exit 1 + fi + + if [[ "$NPM_DIST_TAG" =~ ^v?[0-9] ]]; then + echo "Invalid prerelease npm dist-tag '$NPM_DIST_TAG': dist-tags must not look like versions." >&2 + exit 1 + fi + fi + echo "Tag $GITHUB_REF_NAME matches package.json $PKG_VERSION." + echo "npm dist-tag: $NPM_DIST_TAG" + echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" + echo "npm_dist_tag=$NPM_DIST_TAG" >> "$GITHUB_OUTPUT" - run: npm run build - name: Publish to npm - run: npm publish --provenance --access public + run: npm publish --provenance --access public --tag "${{ steps.version.outputs.npm_dist_tag }}" env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -62,6 +95,17 @@ jobs: if [ -z "$NOTES" ]; then NOTES="Release $GITHUB_REF_NAME" fi + + NOTES="$NOTES + + npm install -g genlayer@${{ steps.version.outputs.npm_dist_tag }}" + + RELEASE_FLAGS=() + if [ "${{ steps.version.outputs.is_prerelease }}" = "true" ]; then + RELEASE_FLAGS+=(--prerelease) + fi + gh release create "$GITHUB_REF_NAME" \ --title "$GITHUB_REF_NAME" \ - --notes "$NOTES" + --notes "$NOTES" \ + "${RELEASE_FLAGS[@]}" diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 13650c28..ec29f228 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -10,7 +10,7 @@ jobs: smoke: name: Testnet Smoke Tests runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 continue-on-error: true steps: @@ -41,3 +41,5 @@ jobs: - name: Run smoke tests run: npm run test:smoke + env: + CLI_SMOKE_TIMEOUT_MS: 240000 diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 080706ca..abb65083 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -72,30 +72,6 @@ jobs: rsync -a "${{ github.workspace }}/docs/api-references/" pages/api-references/genlayer-cli/ # Copy README as sibling file (strip badges/emojis) sed -E '/^\[!\[.*\]\(https:\/\/(img\.shields\.io|dcbadge|badge\.fury)/d' "${{ github.workspace }}/README.md" > pages/api-references/genlayer-cli.mdx - # Write _meta.json - cat > pages/api-references/genlayer-cli/_meta.json << 'METAEOF' - { - "init": "init", - "up": "up", - "stop": "stop", - "new": "new", - "config": "config", - "network": "network", - "deploy": "deploy", - "call": "call", - "write": "write", - "schema": "schema", - "code": "code", - "receipt": "receipt", - "trace": "trace", - "appeal": "appeal", - "appeal-bond": "appeal-bond", - "account": "account", - "staking": "staking", - "localnet": "localnet", - "update": "update" - } - METAEOF if [ -z "$(git status --porcelain)" ]; then echo "No changes to commit" exit 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index c207c6db..8fb80b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [0.40.0-rc1](https://github.com/genlayerlabs/genlayer-cli/compare/v0.39.1...v0.40.0-rc1) (2026-07-08) + +### ⚠ BREAKING CHANGES + +* **contracts:** require consensus acceptance for success, not just the leader's execution result (#346) + +### Features + +* add v0.6 fee-aware commands ([#340](https://github.com/genlayerlabs/genlayer-cli/issues/340)) ([ca083ab](https://github.com/genlayerlabs/genlayer-cli/commit/ca083abbf854960a6638d5af63096b532a03cc5a)) +* branch-per-major release model ([#311](https://github.com/genlayerlabs/genlayer-cli/issues/311)) ([6fd2f3a](https://github.com/genlayerlabs/genlayer-cli/commit/6fd2f3a678ce348c92470828b03bdfc596afa9cb)), closes [genlayer-js#172](https://github.com/genlayerlabs/genlayer-js/issues/172) +* **network:** custom network profiles with deployment-file import ([#362](https://github.com/genlayerlabs/genlayer-cli/issues/362)) ([185d22b](https://github.com/genlayerlabs/genlayer-cli/commit/185d22bff86884b330c2037c2875a5d1629ec72b)), closes [#1162](https://github.com/genlayerlabs/genlayer-cli/issues/1162) [#1162](https://github.com/genlayerlabs/genlayer-cli/issues/1162) +* staking validators discovery ([#357](https://github.com/genlayerlabs/genlayer-cli/issues/357)) ([0e76bce](https://github.com/genlayerlabs/genlayer-cli/commit/0e76bcef4524907f80d1567ad688550e24c7192b)) +* support fee profiles in contract commands ([#355](https://github.com/genlayerlabs/genlayer-cli/issues/355)) ([6edfcfa](https://github.com/genlayerlabs/genlayer-cli/commit/6edfcfa6d1ad2fcbf761c1ed9f3a194d31243624)) +* vesting commands ([#358](https://github.com/genlayerlabs/genlayer-cli/issues/358)) ([7bcc41e](https://github.com/genlayerlabs/genlayer-cli/commit/7bcc41e7a063bd4a628f689bdf4d321b50230aff)) + +### Bug Fixes + +* **contracts:** require consensus acceptance for success, not just the leader's execution result ([#346](https://github.com/genlayerlabs/genlayer-cli/issues/346)) ([6fadcd7](https://github.com/genlayerlabs/genlayer-cli/commit/6fadcd7c9ef181042c823faeec1b7e7fb4d902b2)), closes [#345](https://github.com/genlayerlabs/genlayer-cli/issues/345) +* **docs-sync:** stop overwriting the generated root _meta.json ([#352](https://github.com/genlayerlabs/genlayer-cli/issues/352)) ([f1f1304](https://github.com/genlayerlabs/genlayer-cli/commit/f1f130461dc7d719c9f34086aeb40d8426f0602e)), closes [genlayer-docs#426](https://github.com/genlayerlabs/genlayer-docs/issues/426) +* drop getSlashingAddress from validator-history ([#361](https://github.com/genlayerlabs/genlayer-cli/issues/361)) ([32a0a42](https://github.com/genlayerlabs/genlayer-cli/commit/32a0a42d687a27c633a0ee29b6fbc84842c3cc18)), closes [#344](https://github.com/genlayerlabs/genlayer-cli/issues/344) [#344](https://github.com/genlayerlabs/genlayer-cli/issues/344) [#341](https://github.com/genlayerlabs/genlayer-cli/issues/341) +* fail CLI writes on execution errors ([#345](https://github.com/genlayerlabs/genlayer-cli/issues/345)) ([5d00884](https://github.com/genlayerlabs/genlayer-cli/commit/5d008844ad0b97760bbd025ed5aac61b41b2b881)) +* **init:** use backend provider id "google" for Gemini ([#359](https://github.com/genlayerlabs/genlayer-cli/issues/359)) ([561370f](https://github.com/genlayerlabs/genlayer-cli/commit/561370f955f23dc2e1952daac2c42aecb3f3431c)), closes [#271](https://github.com/genlayerlabs/genlayer-cli/issues/271) +* **localnet:** print validator count to stdout ([37519e1](https://github.com/genlayerlabs/genlayer-cli/commit/37519e18b6155bc762fa24ea809a53c3e83ac9a7)) +* make git install build lifecycle robust ([#363](https://github.com/genlayerlabs/genlayer-cli/issues/363)) ([a4f8a63](https://github.com/genlayerlabs/genlayer-cli/commit/a4f8a63ae9c945ecf99a707e700857d6f7729df7)) +* run CI on v0.39 branch ([#322](https://github.com/genlayerlabs/genlayer-cli/issues/322)) ([e129bab](https://github.com/genlayerlabs/genlayer-cli/commit/e129bab0c471c2d59d360b5ca8c1cb3190774ff8)) +* **system:** propagate command-check and version parse fixes to v0.40-dev ([#350](https://github.com/genlayerlabs/genlayer-cli/issues/350)) ([9ebf9e0](https://github.com/genlayerlabs/genlayer-cli/commit/9ebf9e0d1ab5266c58d2e8a274c0b42acc8ad753)), closes [#349](https://github.com/genlayerlabs/genlayer-cli/issues/349) + ## 0.39.1 (2026-05-06) ### Bug Fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e52bf0a0..5995aa80 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,19 +33,11 @@ Have ideas for new features or use cases? We're eager to hear them! But first: ## Branch model -This repo uses a branch-per-major release model. There is no `main`. - -- **`v0.39`** — current stable major (semver-zero, so 0.39 IS the major; 0.40 would be a major bump that gets its own branch). PRs for bug fixes / non-breaking features target this branch. -- **`v-dev`** — when next-major work is in progress, this branch is open for breaking changes. PRs introducing them target this branch. -- **Older majors** stay for back-ports. Default branch on github.com is whichever major is current stable. - -When you fork or clone, the default branch is `v0.39` today. If you have a `main` branch from a previous checkout, delete it locally: - -```sh -git checkout v0.39 -git branch -D main -git remote prune origin -``` +See [docs/BRANCHING.md](docs/BRANCHING.md) for the current release-train model. +In short: independently releasable work may target the stable branch directly; +multi-feature or cross-repo train work uses the active `*-dev` integration +branch and is promoted to the matching stable branch when ready. `main` is only +the default/static GitHub branch. The previous `staging` branch (beta channel) has been retired. Pre-releases now go through the same release script with an explicit version (`scripts/release.sh 0.39.2-beta.0`). @@ -138,4 +130,4 @@ Connect with the GenLayer community to discuss, collaborate, and share insights: - **[Discord Channel](https://discord.gg/8Jm4v89VAu)**: Our primary hub for discussions, support, and announcements. - **[Telegram Group](https://t.me/genlayer)**: For more informal chats and quick updates. -Your continuous feedback drives better product development. Please engage with us regularly to test, discuss, and improve the GenLayer CLI. \ No newline at end of file +Your continuous feedback drives better product development. Please engage with us regularly to test, discuss, and improve the GenLayer CLI. diff --git a/README.md b/README.md index d33dc464..61f32d6a 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,9 @@ OPTIONS (deploy): --contract (Optional) Path to the intelligent contract to deploy --rpc RPC URL for the network --fees Transaction fee options JSON passed to genlayer-js + --fee-profile Fee profile generated by gltest --fee-profile + --fee-preset Fee profile appeal posture: low, standard, or high + --appeal-rounds Override fee profile appeal rounds --fee-value Explicit fee deposit value --valid-until Unix timestamp after which the transaction is invalid --args Contract arguments (see Argument Types below) @@ -187,6 +190,9 @@ OPTIONS (call): OPTIONS (write): --rpc RPC URL for the network --fees Transaction fee options JSON passed to genlayer-js + --fee-profile Fee profile generated by gltest --fee-profile + --fee-preset Fee profile appeal posture: low, standard, or high + --appeal-rounds Override fee profile appeal rounds --fee-value Explicit fee deposit value --valid-until Unix timestamp after which the transaction is invalid --args Method arguments (see Argument Types below) @@ -194,6 +200,9 @@ OPTIONS (write): OPTIONS (estimate-fees): --rpc RPC URL for the network --fees Fee estimate options JSON, or a transaction fee object + --fee-profile Fee profile generated by gltest --fee-profile + --fee-preset Fee profile appeal posture: low, standard, or high + --appeal-rounds Override fee profile appeal rounds --include-report Include simulation fee accounting/report in the generated estimate output --args Method arguments for simulation-derived estimates @@ -205,11 +214,14 @@ EXAMPLES: genlayer deploy --contract ./my_contract.gpy genlayer deploy --contract ./my_contract.gpy --args "arg1" "arg2" 123 genlayer deploy --contract ./my_contract.gpy --fees '{"distribution":{"leaderTimeunitsAllocation":"100","validatorTimeunitsAllocation":"200","rotations":["0"]}}' + genlayer deploy --contract ./my_contract.gpy --fee-profile ./artifacts/fee-profile.json genlayer call 0x123456789abcdef greet --args "Hello World!" genlayer write 0x123456789abcdef updateValue --args 42 genlayer write 0x123456789abcdef updateValue --fees '{"distribution":{"leaderTimeunitsAllocation":"100","validatorTimeunitsAllocation":"200","rotations":["0"]}}' --args 42 + genlayer write 0x123456789abcdef updateValue --fee-profile ./artifacts/fee-profile.json --fee-preset standard --args 42 genlayer estimate-fees genlayer estimate-fees 0x123456789abcdef updateValue --args 42 + genlayer estimate-fees 0x123456789abcdef updateValue --fee-profile ./artifacts/fee-profile.json --json genlayer write 0x123456789abcdef sendReward --args 0x6857Ed54CbafaA74Fc0357145eC0ee1536ca45A0 genlayer write 0x123456789abcdef setScores --args '[1, 2, 3]' genlayer write 0x123456789abcdef setConfig --args '{"timeout": 30, "retries": 5}' @@ -218,6 +230,28 @@ EXAMPLES: ##### Transaction Fee Options +For reproducible application presets, pass the profile produced by +`gltest --fee-profile`: + +```bash +genlayer estimate-fees 0x123456789abcdef settle \ + --fee-profile ./artifacts/fee-profile.json \ + --fee-preset standard \ + --json + +genlayer write 0x123456789abcdef settle \ + --fee-profile ./artifacts/fee-profile.json \ + --fee-preset standard +``` + +`deploy` reads the profile's `deploy` entry. `write` and targeted +`estimate-fees` read `methods[method]`. The CLI converts the measured profile +entry into SDK fee-estimate options, asks `genlayer-js` for a transaction fee +preset, then sends that preset with the transaction. `--fee-preset` controls the +default appeal posture (`low`, `standard`, or `high`); use `--appeal-rounds` +for an explicit override. `--fees` can still be provided alongside +`--fee-profile` to override individual values, including `messageAllocations`. + `--fees` accepts the same transaction fee object as `genlayer-js`. Quote large integer values as strings to preserve precision. `messageAllocations[].messageType` may be `"internal"`, `"external"`, `0`, or `1`. @@ -253,29 +287,32 @@ preset for reproducible gas-unit debugging. The `--args` option automatically detects and converts values to the correct type: -| Type | Syntax | Example | -|------|--------|---------| -| Boolean | `true`, `false` | `--args true false` | -| Null | `null` | `--args null` | -| Integer | numeric value | `--args 42 -1` | -| Hex integer | `0x` prefix | `--args 0x1a` | -| String | any other value | `--args hello "multi word"` | -| Address | 40 hex chars with `0x` or `addr#` prefix | `--args 0x6857...a0` or `--args addr#6857...a0` | -| Bytes | `b#` prefix + hex | `--args b#deadbeef` | -| Array | JSON array in quotes | `--args '[1, 2, "three"]'` | -| Dict | JSON object in quotes | `--args '{"key": "value"}'` | +| Type | Syntax | Example | +| ----------- | ---------------------------------------- | ----------------------------------------------- | +| Boolean | `true`, `false` | `--args true false` | +| Null | `null` | `--args null` | +| Integer | numeric value | `--args 42 -1` | +| Hex integer | `0x` prefix | `--args 0x1a` | +| String | any other value | `--args hello "multi word"` | +| Address | 40 hex chars with `0x` or `addr#` prefix | `--args 0x6857...a0` or `--args addr#6857...a0` | +| Bytes | `b#` prefix + hex | `--args b#deadbeef` | +| Array | JSON array in quotes | `--args '[1, 2, "three"]'` | +| Dict | JSON object in quotes | `--args '{"key": "value"}'` | Large numbers that exceed JavaScript's safe integer range are automatically handled as BigInt to preserve precision. ##### Deploy Behavior + - If `--contract` is specified, the command will **deploy the given contract**. - If `--contract` is omitted, the CLI will **search for scripts inside the `deploy` folder**, sort them, and execute them sequentially. ##### Call vs Write + - `call` - Calls a contract method without sending a transaction or changing the state (read-only) - `write` - Sends a transaction to a contract method that modifies the state ##### Schema + - `schema` - Retrieves the contract schema #### Transaction Operations diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md new file mode 100644 index 00000000..5b8d3d60 --- /dev/null +++ b/docs/BRANCHING.md @@ -0,0 +1,58 @@ +# Branching and Release Trains + +This repo follows the GenLayer release-train model. + +## Current Train + +- Current stable branch: `v0.39` +- Active integration branch: `v0.40-dev` +- Next stable target: `v0.40` +- `main`: default/static branch alias for the active integration branch + +## Stable Branches + +Stable branches are long-lived release lines. For semver-zero packages, each +minor line is treated as the release line, for example `v0.39` or `v0.40`. + +PRs may target a stable branch directly when the merged result should be +releasable immediately. This is appropriate for bug fixes, small non-breaking +features, isolated release fixes, or a breaking change that is intentionally +shipping as the next version by itself. + +Stable branches must remain releasable. PRs into stable branches are expected to +pass the required cross-repo `E2E Tests` gate before merge. + +## Integration Branches + +Integration branches are optional. Use one when multiple changes need to +accumulate before release, especially for cross-repo work, dependent features, +breaking changes that must ship together, or a train that needs advisory E2E +while still expected to be red. + +Integration branches are named after the target stable branch plus `-dev`, for +example `v0.40-dev`. Feature PRs for that train target the integration branch. + +PRs into integration branches may run `E2E Tests` as advisory checks. They are +not the release gate. + +## Promotion and Release + +When an integration train is ready, open a promotion PR from the integration +branch to the matching stable branch, for example `v0.40-dev` to `v0.40`. + +That promotion PR is the release-readiness gate and must pass required +cross-repo `E2E Tests`. The actual package release is cut from the stable branch +using a version tag after the stable branch is ready. + +## `main` + +`main` exists for GitHub UX and tools that require a stable default branch. It is +not a release branch and it is not the integration target. + +This repo keeps `main` forwarded to the active integration branch using +automation. PRs opened against `main` are automatically retargeted to the branch +listed in `support/ci/ACTIVE_DEV_BRANCH`. + +When changing the active integration branch, update +`support/ci/ACTIVE_DEV_BRANCH`, the repo docs, and the corresponding +`genlayer-e2e` release-train matrix in the same change set. diff --git a/docs/api-references/_meta.json b/docs/api-references/_meta.json index aeb6b07b..bf2c81cb 100644 --- a/docs/api-references/_meta.json +++ b/docs/api-references/_meta.json @@ -1,9 +1,14 @@ { + "index": "Overview", "environment": "Environment", "configuration": "Configuration", "contracts": "Contracts", "transactions": "Transactions", "accounts": "Accounts", "staking": "Staking", - "localnet": "Localnet" + "localnet": "Localnet", + "estimate-fees": "estimate-fees", + "finalize": "finalize", + "finalize-batch": "finalize-batch", + "vesting": "vesting" } \ No newline at end of file diff --git a/docs/api-references/accounts/account/send.mdx b/docs/api-references/accounts/account/send.mdx index 86477235..42498133 100644 --- a/docs/api-references/accounts/account/send.mdx +++ b/docs/api-references/accounts/account/send.mdx @@ -18,7 +18,7 @@ Send GEN to an address | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --rpc <rpcUrl> | RPC URL for the network | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --account <name> | Account to send from | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/configuration/network.mdx b/docs/api-references/configuration/network.mdx index 5a1cec9d..c71d6c34 100644 --- a/docs/api-references/configuration/network.mdx +++ b/docs/api-references/configuration/network.mdx @@ -20,6 +20,8 @@ Network configuration ### Subcommands +- `genlayer add` — Add a custom network profile - `genlayer set` — Set the network to use -- `genlayer info` — Show current network configuration and contract addresses +- `genlayer info` — Show current network configuration and contract - `genlayer list` — List available networks +- `genlayer remove` — Remove a custom network profile diff --git a/docs/api-references/configuration/network/add.mdx b/docs/api-references/configuration/network/add.mdx new file mode 100644 index 00000000..a070fa9c --- /dev/null +++ b/docs/api-references/configuration/network/add.mdx @@ -0,0 +1,32 @@ +--- +title: network add +--- + +Add a custom network profile +Arguments: +alias Custom network alias + +### Usage + +`$ genlayer network add [options] ` + +### Arguments + +- `` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --base <built-in-alias> | Built-in base network alias | No | | +| | --deployment <path.json> | Consensus deployments JSON file | No | | +| | --deployment-key <dot.path> | Deployment JSON sub-object to scan | No | | +| | --rpc <url> | Node RPC URL override | No | | +| | --consensus-main <addr> | ConsensusMain contract address override | No | | +| | --consensus-data <addr> | ConsensusData contract address override | No | | +| | --staking <addr> | Staking contract address override | No | | +| | --fee-manager <addr> | FeeManager contract address override | No | | +| | --rounds-storage <addr> | RoundsStorage contract address override | No | | +| | --appeals <addr> | Appeals contract address override | No | | +| | --chain-id <n> | Chain ID override | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/configuration/network/remove.mdx b/docs/api-references/configuration/network/remove.mdx new file mode 100644 index 00000000..f9b3aeb8 --- /dev/null +++ b/docs/api-references/configuration/network/remove.mdx @@ -0,0 +1,21 @@ +--- +title: network remove +--- + +Remove a custom network profile +Arguments: +alias Custom network alias to remove + +### Usage + +`$ genlayer network remove [options] ` + +### Arguments + +- `` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/contracts/call.mdx b/docs/api-references/contracts/call.mdx index 02b21da6..d275fec6 100644 --- a/docs/api-references/contracts/call.mdx +++ b/docs/api-references/contracts/call.mdx @@ -18,4 +18,5 @@ Call a contract method without sending a transaction or changing the state | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --args <args...> | Contract arguments. Supported types: | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/contracts/deploy.mdx b/docs/api-references/contracts/deploy.mdx index b0677d0c..ca96ac0b 100644 --- a/docs/api-references/contracts/deploy.mdx +++ b/docs/api-references/contracts/deploy.mdx @@ -14,4 +14,11 @@ Deploy intelligent contracts | --- | --- | --- | :---: | --- | | | --contract <contractPath> | Path to the smart contract to deploy | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --fees <json> | Transaction fee options JSON passed to genlayer-js. | No | | +| | --fee-profile <path> | Path to a fee profile generated by gltest --fee-profile. Deploy uses the profile deploy entry; write and targeted estimate-fees use the matching method entry. --fees can still be provided to override profile values. | No | | +| | --fee-preset <preset> | Fee profile appeal posture: low, standard, or high | No | | +| | --appeal-rounds <count> | Override fee profile appeal rounds | No | | +| | --fee-value <wei> | Fee deposit value to send with the transaction | No | | +| | --valid-until <unixTimestamp> | Unix timestamp after which the transaction is invalid | No | | +| | --args <args...> | Contract arguments. Supported types: | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/contracts/write.mdx b/docs/api-references/contracts/write.mdx index 9050af76..b4760e37 100644 --- a/docs/api-references/contracts/write.mdx +++ b/docs/api-references/contracts/write.mdx @@ -18,4 +18,11 @@ Sends a transaction to a contract method that modifies the state | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --fees <json> | Transaction fee options JSON passed to genlayer-js. | No | | +| | --fee-profile <path> | Path to a fee profile generated by gltest --fee-profile. Deploy uses the profile deploy entry; write and targeted estimate-fees use the matching method entry. --fees can still be provided to override profile values. | No | | +| | --fee-preset <preset> | Fee profile appeal posture: low, standard, or high | No | | +| | --appeal-rounds <count> | Override fee profile appeal rounds | No | | +| | --fee-value <wei> | Fee deposit value to send with the transaction | No | | +| | --valid-until <unixTimestamp> | Unix timestamp after which the transaction is invalid | No | | +| | --args <args...> | Contract arguments. Supported types: | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/estimate-fees.mdx b/docs/api-references/estimate-fees.mdx new file mode 100644 index 00000000..217e9ae6 --- /dev/null +++ b/docs/api-references/estimate-fees.mdx @@ -0,0 +1,29 @@ +--- +title: estimate-fees +--- + +Build a transaction fee preset, optionally from a Studio/localnet write +simulation + +### Usage + +`$ genlayer estimate-fees [options] [contractAddress] [method]` + +### Arguments + +- `[contractAddress]` +- `[method]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --fees <json> | Fee estimate options JSON passed to genlayer-js estimateTransactionFees. | No | | +| | --fee-profile <path> | Path to a fee profile generated by gltest --fee-profile. Deploy uses the profile deploy entry; write and targeted estimate-fees use the matching method entry. --fees can still be provided to override profile values. | No | | +| | --fee-preset <preset> | Fee profile appeal posture: low, standard, or high | No | | +| | --appeal-rounds <count> | Override fee profile appeal rounds | No | | +| | --json | Print the fee estimate as JSON without spinner output | No | | +| | --include-report | Include simulation fee accounting/report in the generated estimate output | No | | +| | --args <args...> | Contract arguments. Supported types: | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/finalize-batch.mdx b/docs/api-references/finalize-batch.mdx new file mode 100644 index 00000000..afc1f6f9 --- /dev/null +++ b/docs/api-references/finalize-batch.mdx @@ -0,0 +1,20 @@ +--- +title: finalize-batch +--- + +Finalize a batch of idle transactions in a single call (public call) + +### Usage + +`$ genlayer finalize-batch [options] ` + +### Arguments + +- `` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/finalize.mdx b/docs/api-references/finalize.mdx new file mode 100644 index 00000000..b977d82d --- /dev/null +++ b/docs/api-references/finalize.mdx @@ -0,0 +1,20 @@ +--- +title: finalize +--- + +Finalize a transaction that is ready to be finalized (public call) + +### Usage + +`$ genlayer finalize [options] ` + +### Arguments + +- `` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/index.mdx b/docs/api-references/index.mdx index 49295cfd..dc97f6cb 100644 --- a/docs/api-references/index.mdx +++ b/docs/api-references/index.mdx @@ -6,7 +6,7 @@ GenLayer CLI is a development environment for the GenLayer ecosystem. It allows developers to interact with the protocol by creating accounts, sending transactions, and working with Intelligent Contracts by testing, debugging, and deploying them. -Version: `0.34.0` +Version: `0.39.1` ### Command List @@ -17,6 +17,7 @@ Version: `0.34.0` - `genlayer deploy` — Deploy intelligent contracts - `genlayer call` — Call a contract method without sending a transaction or changing the state - `genlayer write` — Sends a transaction to a contract method that modifies the state +- `genlayer estimate-fees` — Build a transaction fee preset, optionally from a Studio/localnet write simulation - `genlayer schema` — Get the schema for a deployed contract - `genlayer code` — Get the source for a deployed contract - `genlayer config` — Manage CLI configuration, including the default network @@ -28,7 +29,10 @@ Version: `0.34.0` - `genlayer appeal` — Appeal a transaction by its hash - `genlayer appeal-bond` — Show minimum appeal bond required for a transaction - `genlayer trace` — Get execution trace for a transaction (return data, stdout, stderr, GenVM logs) +- `genlayer finalize` — Finalize a transaction that is ready to be finalized (public call) +- `genlayer finalize-batch` — Finalize a batch of idle transactions in a single call (public call) - `genlayer staking` — Staking operations for validators and delegators +- `genlayer vesting` — Vesting operations for beneficiaries --- diff --git a/docs/api-references/localnet/localnet/validators/create-random.mdx b/docs/api-references/localnet/localnet/validators/create-random.mdx index 4fb23b53..926480b0 100644 --- a/docs/api-references/localnet/localnet/validators/create-random.mdx +++ b/docs/api-references/localnet/localnet/validators/create-random.mdx @@ -12,5 +12,7 @@ Create random validators | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --count <count> | Number of validators to create openai ollama) gpt-4o) | No | `[]` | +| | --count <count> | Number of validators to create | No | `1` | +| | --providers <providers...> | Space-separated list of provider names (e.g., openai ollama) | No | `[]` | +| | --models <models...> | Space-separated list of model names (e.g., gpt-4 gpt-4o) | No | `[]` | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking.mdx b/docs/api-references/staking/staking.mdx index 17f18239..f1bd84ac 100644 --- a/docs/api-references/staking/staking.mdx +++ b/docs/api-references/staking/staking.mdx @@ -38,5 +38,5 @@ Staking operations for validators and delegators - `genlayer active-validators` — List all active validators - `genlayer quarantined-validators` — List all quarantined validators - `genlayer banned-validators` — List all banned validators -- `genlayer validators` — Show validator set with stake, status, and voting power +- `genlayer validators` — List validators with stake, status, and optional explorer performance - `genlayer validator-history` — Show slash and reward history for a validator (default: last 10 epochs) diff --git a/docs/api-references/staking/staking/active-validators.mdx b/docs/api-references/staking/staking/active-validators.mdx index 89a90a41..441eba20 100644 --- a/docs/api-references/staking/staking/active-validators.mdx +++ b/docs/api-references/staking/staking/active-validators.mdx @@ -12,7 +12,7 @@ List all active validators | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/banned-validators.mdx b/docs/api-references/staking/staking/banned-validators.mdx index 83b750aa..c3b33247 100644 --- a/docs/api-references/staking/staking/banned-validators.mdx +++ b/docs/api-references/staking/staking/banned-validators.mdx @@ -12,7 +12,7 @@ List all banned validators | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/delegation-info.mdx b/docs/api-references/staking/staking/delegation-info.mdx index b75fe6ce..447750c4 100644 --- a/docs/api-references/staking/staking/delegation-info.mdx +++ b/docs/api-references/staking/staking/delegation-info.mdx @@ -19,7 +19,7 @@ Get delegation info for a delegator with a validator | | --validator <address> | Validator address (deprecated, use positional arg) | No | | | | --delegator <address> | Delegator address (defaults to signer) | No | | | | --account <name> | Account to use (for default delegator address) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/delegator-claim.mdx b/docs/api-references/staking/staking/delegator-claim.mdx index 325eb379..25c71120 100644 --- a/docs/api-references/staking/staking/delegator-claim.mdx +++ b/docs/api-references/staking/staking/delegator-claim.mdx @@ -20,7 +20,7 @@ Claim delegator withdrawals after unbonding period | | --delegator <address> | Delegator address (defaults to signer) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/delegator-exit.mdx b/docs/api-references/staking/staking/delegator-exit.mdx index 1e8b5ad0..b4f8864d 100644 --- a/docs/api-references/staking/staking/delegator-exit.mdx +++ b/docs/api-references/staking/staking/delegator-exit.mdx @@ -20,7 +20,7 @@ Exit as a delegator by withdrawing shares from a validator | | --shares <shares> | Number of shares to withdraw | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/delegator-join.mdx b/docs/api-references/staking/staking/delegator-join.mdx index 3219e752..d213113a 100644 --- a/docs/api-references/staking/staking/delegator-join.mdx +++ b/docs/api-references/staking/staking/delegator-join.mdx @@ -20,7 +20,7 @@ Join as a delegator by staking with a validator | | --amount <amount> | Amount to stake (in wei or with 'eth'/'gen' suffix) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/epoch-info.mdx b/docs/api-references/staking/staking/epoch-info.mdx index 7f4cf570..97a50bca 100644 --- a/docs/api-references/staking/staking/epoch-info.mdx +++ b/docs/api-references/staking/staking/epoch-info.mdx @@ -13,7 +13,7 @@ Get current epoch and staking parameters | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --epoch <number> | Show data for specific epoch (current or previous only) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/prime-all.mdx b/docs/api-references/staking/staking/prime-all.mdx index 5d69dbea..be8d1a7c 100644 --- a/docs/api-references/staking/staking/prime-all.mdx +++ b/docs/api-references/staking/staking/prime-all.mdx @@ -14,7 +14,7 @@ Prime all validators that need priming | --- | --- | --- | :---: | --- | | | --account <name> | Account to use (pays gas) | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/quarantined-validators.mdx b/docs/api-references/staking/staking/quarantined-validators.mdx index c6a55a01..b90d615e 100644 --- a/docs/api-references/staking/staking/quarantined-validators.mdx +++ b/docs/api-references/staking/staking/quarantined-validators.mdx @@ -12,7 +12,7 @@ List all quarantined validators | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/set-identity.mdx b/docs/api-references/staking/staking/set-identity.mdx index 9212e863..85046475 100644 --- a/docs/api-references/staking/staking/set-identity.mdx +++ b/docs/api-references/staking/staking/set-identity.mdx @@ -28,6 +28,6 @@ Set validator identity metadata (moniker, website, socials, etc.) | | --extra-cid <cid> | Extra data as IPFS CID or hex bytes (0x...) | No | | | | --account <name> | Account to use (must be validator operator) | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/set-operator.mdx b/docs/api-references/staking/staking/set-operator.mdx index 5c5b2e9f..0d08a104 100644 --- a/docs/api-references/staking/staking/set-operator.mdx +++ b/docs/api-references/staking/staking/set-operator.mdx @@ -21,6 +21,6 @@ Change the operator address for a validator wallet | | --operator <address> | New operator address (deprecated, use positional arg) | No | | | | --account <name> | Account to use (must be validator owner) | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-claim.mdx b/docs/api-references/staking/staking/validator-claim.mdx index bddb4657..3085a3af 100644 --- a/docs/api-references/staking/staking/validator-claim.mdx +++ b/docs/api-references/staking/staking/validator-claim.mdx @@ -19,6 +19,6 @@ Claim validator withdrawals after unbonding period | | --validator <address> | Validator wallet contract address (deprecated, use positional arg) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-deposit.mdx b/docs/api-references/staking/staking/validator-deposit.mdx index 1f0dd6a6..daf32fa0 100644 --- a/docs/api-references/staking/staking/validator-deposit.mdx +++ b/docs/api-references/staking/staking/validator-deposit.mdx @@ -20,6 +20,6 @@ Make an additional deposit to a validator wallet | | --amount <amount> | Amount to deposit (in wei or with 'eth'/'gen' suffix) | No | | | | --account <name> | Account to use (must be validator owner) | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-exit.mdx b/docs/api-references/staking/staking/validator-exit.mdx index 3c8dd58c..c5489f03 100644 --- a/docs/api-references/staking/staking/validator-exit.mdx +++ b/docs/api-references/staking/staking/validator-exit.mdx @@ -20,6 +20,6 @@ Exit as a validator by withdrawing shares | | --shares <shares> | Number of shares to withdraw | No | | | | --account <name> | Account to use (must be validator owner) | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-history.mdx b/docs/api-references/staking/staking/validator-history.mdx index e2f29f85..d2279901 100644 --- a/docs/api-references/staking/staking/validator-history.mdx +++ b/docs/api-references/staking/staking/validator-history.mdx @@ -23,7 +23,7 @@ Show slash and reward history for a validator (default: last 10 epochs) | | --all | Fetch complete history from genesis (slow) | No | | | | --limit <count> | Maximum number of events to show | No | `50` | | | --account <name> | Account to use (for default validator address) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-info.mdx b/docs/api-references/staking/staking/validator-info.mdx index bc4e6fd8..f0088acf 100644 --- a/docs/api-references/staking/staking/validator-info.mdx +++ b/docs/api-references/staking/staking/validator-info.mdx @@ -18,7 +18,7 @@ Get information about a validator | --- | --- | --- | :---: | --- | | | --validator <address> | Validator address (deprecated, use positional arg) | No | | | | --account <name> | Account to use (for default validator address) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | | --debug | Show raw unfiltered pending deposits/withdrawals | No | | diff --git a/docs/api-references/staking/staking/validator-join.mdx b/docs/api-references/staking/staking/validator-join.mdx index 24e9ff3b..e2d56331 100644 --- a/docs/api-references/staking/staking/validator-join.mdx +++ b/docs/api-references/staking/staking/validator-join.mdx @@ -16,7 +16,7 @@ Join as a validator by staking tokens | | --operator <address> | Operator address (defaults to signer) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-prime.mdx b/docs/api-references/staking/staking/validator-prime.mdx index 33d335a7..769d53f4 100644 --- a/docs/api-references/staking/staking/validator-prime.mdx +++ b/docs/api-references/staking/staking/validator-prime.mdx @@ -19,7 +19,7 @@ Prime a validator to prepare their stake record for the next epoch | | --validator <address> | Validator address to prime (deprecated, use positional arg) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validators.mdx b/docs/api-references/staking/staking/validators.mdx index 015b1518..e17e4965 100644 --- a/docs/api-references/staking/staking/validators.mdx +++ b/docs/api-references/staking/staking/validators.mdx @@ -2,7 +2,7 @@ title: staking validators --- -Show validator set with stake, status, and voting power +List validators with stake, status, and optional explorer performance ### Usage @@ -13,7 +13,10 @@ Show validator set with stake, status, and voting power | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --all | Include banned validators | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --json | Output machine-readable JSON | No | | +| | --sort-by <field> | Sort validators by stake or uptime (default: stake) | No | `stake` | +| | --explorer-url <url> | Explorer backend or explorer base URL for optional performance enrichment | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/transactions/receipt.mdx b/docs/api-references/transactions/receipt.mdx index 03039ed8..1ab60eda 100644 --- a/docs/api-references/transactions/receipt.mdx +++ b/docs/api-references/transactions/receipt.mdx @@ -16,7 +16,7 @@ Get transaction receipt by hash | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --status <status> | Transaction status to wait for (UNINITIALIZED, PENDING, PROPOSING, COMMITTING, REVEALING, ACCEPTED, UNDETERMINED, FINALIZED, CANCELED, APPEAL_REVEALING, APPEAL_COMMITTING, READY_TO_FINALIZE, VALIDATORS_TIMEOUT, LEADER_TIMEOUT) (default: "FINALIZED") | No | | +| | --status <status> | Transaction status to wait for (UNINITIALIZED, PENDING, PROPOSING, COMMITTING, REVEALING, ACCEPTED, UNDETERMINED, FINALIZED, CANCELED, APPEAL_REVEALING, APPEAL_COMMITTING, READY_TO_FINALIZE, VALIDATORS_TIMEOUT, LEADER_TIMEOUT, LEADER_REVEALING) | No | `FINALIZED` | | | --retries <retries> | Number of retries | No | `100` | | | --interval <interval> | Interval between retries in milliseconds (default: 5000) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | diff --git a/docs/api-references/vesting.mdx b/docs/api-references/vesting.mdx new file mode 100644 index 00000000..1742df61 --- /dev/null +++ b/docs/api-references/vesting.mdx @@ -0,0 +1,28 @@ +--- +title: vesting +--- + +Vesting operations for beneficiaries + +### Usage + +`$ genlayer vesting [options] [command]` + +### Arguments + +- `[command]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | + +### Subcommands + +- `genlayer list` — List beneficiary vesting contracts and state +- `genlayer delegate` — Delegate vesting-held tokens to a validator +- `genlayer undelegate` — Undelegate all vesting delegation shares +- `genlayer claim` — Claim vesting delegation withdrawals after +- `genlayer withdraw` — Withdraw vested tokens to the beneficiary +- `genlayer validator` — Vesting-backed validator operations diff --git a/docs/api-references/vesting/claim.mdx b/docs/api-references/vesting/claim.mdx new file mode 100644 index 00000000..43d64d47 --- /dev/null +++ b/docs/api-references/vesting/claim.mdx @@ -0,0 +1,27 @@ +--- +title: vesting claim +--- + +Claim vesting delegation withdrawals after unbonding period + +### Usage + +`$ genlayer vesting claim [options] [validator]` + +### Arguments + +- `[validator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --validator <address> | Validator address to claim from (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/delegate.mdx b/docs/api-references/vesting/delegate.mdx new file mode 100644 index 00000000..c0523790 --- /dev/null +++ b/docs/api-references/vesting/delegate.mdx @@ -0,0 +1,28 @@ +--- +title: vesting delegate +--- + +Delegate vesting-held tokens to a validator + +### Usage + +`$ genlayer vesting delegate [options] [validator]` + +### Arguments + +- `[validator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --validator <address> | Validator address to delegate to (deprecated, use positional arg) | No | | +| | --amount <amount> | Amount to delegate (in wei or with 'eth'/'gen' suffix) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/list.mdx b/docs/api-references/vesting/list.mdx new file mode 100644 index 00000000..36131325 --- /dev/null +++ b/docs/api-references/vesting/list.mdx @@ -0,0 +1,21 @@ +--- +title: vesting list +--- + +List beneficiary vesting contracts and state + +### Usage + +`$ genlayer vesting list [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --beneficiary <address> | Beneficiary address (defaults to signer) | No | | +| | --account <name> | Account to use (for default beneficiary address) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/undelegate.mdx b/docs/api-references/vesting/undelegate.mdx new file mode 100644 index 00000000..3ab6c783 --- /dev/null +++ b/docs/api-references/vesting/undelegate.mdx @@ -0,0 +1,27 @@ +--- +title: vesting undelegate +--- + +Undelegate all vesting delegation shares from a validator + +### Usage + +`$ genlayer vesting undelegate [options] [validator]` + +### Arguments + +- `[validator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --validator <address> | Validator address to undelegate from (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator.mdx b/docs/api-references/vesting/validator.mdx new file mode 100644 index 00000000..265a5fea --- /dev/null +++ b/docs/api-references/vesting/validator.mdx @@ -0,0 +1,31 @@ +--- +title: vesting validator +--- + +Vesting-backed validator operations + +### Usage + +`$ genlayer vesting validator [options] [command]` + +### Arguments + +- `[command]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | + +### Subcommands + +- `genlayer create` — Create a vesting-backed validator +- `genlayer join` — Create a vesting-backed validator +- `genlayer deposit` — Deposit more vesting-held tokens to a +- `genlayer exit` — Exit vesting validator self-stake by +- `genlayer claim` — Claim vesting validator withdrawals after +- `genlayer operator-transfer` — Manage vesting validator operator transfers +- `genlayer set-identity` — Set vesting validator identity metadata +- `genlayer list` — List validator wallets owned by a vesting +- `genlayer status` — Show validator wallets owned by a vesting diff --git a/docs/api-references/vesting/validator/claim.mdx b/docs/api-references/vesting/validator/claim.mdx new file mode 100644 index 00000000..cf112d09 --- /dev/null +++ b/docs/api-references/vesting/validator/claim.mdx @@ -0,0 +1,27 @@ +--- +title: vesting validator claim +--- + +Claim vesting validator withdrawals after unbonding period + +### Usage + +`$ genlayer vesting validator claim [options] [wallet]` + +### Arguments + +- `[wallet]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/create.mdx b/docs/api-references/vesting/validator/create.mdx new file mode 100644 index 00000000..f159851b --- /dev/null +++ b/docs/api-references/vesting/validator/create.mdx @@ -0,0 +1,28 @@ +--- +title: vesting validator create +--- + +Create a vesting-backed validator + +### Usage + +`$ genlayer vesting validator create [options] [operator]` + +### Arguments + +- `[operator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --operator <address> | Operator address (deprecated, use positional arg) | No | | +| | --amount <amount> | Amount to self-stake (in wei or with 'eth'/'gen' suffix) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/deposit.mdx b/docs/api-references/vesting/validator/deposit.mdx new file mode 100644 index 00000000..13ead169 --- /dev/null +++ b/docs/api-references/vesting/validator/deposit.mdx @@ -0,0 +1,28 @@ +--- +title: vesting validator deposit +--- + +Deposit more vesting-held tokens to a validator wallet + +### Usage + +`$ genlayer vesting validator deposit [options] [wallet]` + +### Arguments + +- `[wallet]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --amount <amount> | Amount to deposit (in wei or with 'eth'/'gen' suffix) | No | | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/exit.mdx b/docs/api-references/vesting/validator/exit.mdx new file mode 100644 index 00000000..02a3541c --- /dev/null +++ b/docs/api-references/vesting/validator/exit.mdx @@ -0,0 +1,28 @@ +--- +title: vesting validator exit +--- + +Exit vesting validator self-stake by withdrawing shares + +### Usage + +`$ genlayer vesting validator exit [options] [wallet]` + +### Arguments + +- `[wallet]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --shares <shares> | Number of shares to withdraw | No | | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/join.mdx b/docs/api-references/vesting/validator/join.mdx new file mode 100644 index 00000000..e2bd862b --- /dev/null +++ b/docs/api-references/vesting/validator/join.mdx @@ -0,0 +1,28 @@ +--- +title: vesting validator join +--- + +Create a vesting-backed validator + +### Usage + +`$ genlayer vesting validator join [options] [operator]` + +### Arguments + +- `[operator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --operator <address> | Operator address (deprecated, use positional arg) | No | | +| | --amount <amount> | Amount to self-stake (in wei or with 'eth'/'gen' suffix) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/list.mdx b/docs/api-references/vesting/validator/list.mdx new file mode 100644 index 00000000..9cdc9e72 --- /dev/null +++ b/docs/api-references/vesting/validator/list.mdx @@ -0,0 +1,22 @@ +--- +title: vesting validator list +--- + +List validator wallets owned by a vesting contract + +### Usage + +`$ genlayer vesting validator list [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --beneficiary <address> | Beneficiary address (defaults to signer) | No | | +| | --account <name> | Account to use (for default beneficiary address) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/operator-transfer.mdx b/docs/api-references/vesting/validator/operator-transfer.mdx new file mode 100644 index 00000000..0210b4e4 --- /dev/null +++ b/docs/api-references/vesting/validator/operator-transfer.mdx @@ -0,0 +1,25 @@ +--- +title: vesting validator operator-transfer +--- + +Manage vesting validator operator transfers + +### Usage + +`$ genlayer vesting validator operator-transfer [options] [command]` + +### Arguments + +- `[command]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | + +### Subcommands + +- `genlayer initiate` — Initiate a vesting validator operator transfer +- `genlayer complete` — Complete a vesting validator operator transfer +- `genlayer cancel` — Cancel a vesting validator operator transfer diff --git a/docs/api-references/vesting/validator/operator-transfer/cancel.mdx b/docs/api-references/vesting/validator/operator-transfer/cancel.mdx new file mode 100644 index 00000000..13f88d5f --- /dev/null +++ b/docs/api-references/vesting/validator/operator-transfer/cancel.mdx @@ -0,0 +1,27 @@ +--- +title: vesting validator operator-transfer cancel +--- + +Cancel a vesting validator operator transfer + +### Usage + +`$ genlayer vesting validator operator-transfer cancel [options] [wallet]` + +### Arguments + +- `[wallet]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/operator-transfer/complete.mdx b/docs/api-references/vesting/validator/operator-transfer/complete.mdx new file mode 100644 index 00000000..47dda0c7 --- /dev/null +++ b/docs/api-references/vesting/validator/operator-transfer/complete.mdx @@ -0,0 +1,27 @@ +--- +title: vesting validator operator-transfer complete +--- + +Complete a vesting validator operator transfer + +### Usage + +`$ genlayer vesting validator operator-transfer complete [options] [wallet]` + +### Arguments + +- `[wallet]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/operator-transfer/initiate.mdx b/docs/api-references/vesting/validator/operator-transfer/initiate.mdx new file mode 100644 index 00000000..e3596106 --- /dev/null +++ b/docs/api-references/vesting/validator/operator-transfer/initiate.mdx @@ -0,0 +1,29 @@ +--- +title: vesting validator operator-transfer initiate +--- + +Initiate a vesting validator operator transfer + +### Usage + +`$ genlayer vesting validator operator-transfer initiate [options] [wallet] [newOperator]` + +### Arguments + +- `[wallet]` +- `[newOperator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --new-operator <address> | New operator address (deprecated, use positional arg) | No | | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/set-identity.mdx b/docs/api-references/vesting/validator/set-identity.mdx new file mode 100644 index 00000000..463f37b8 --- /dev/null +++ b/docs/api-references/vesting/validator/set-identity.mdx @@ -0,0 +1,36 @@ +--- +title: vesting validator set-identity +--- + +Set vesting validator identity metadata + +### Usage + +`$ genlayer vesting validator set-identity [options] [wallet]` + +### Arguments + +- `[wallet]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --moniker <name> | Validator display name | No | | +| | --logo-uri <uri> | Logo URI | No | | +| | --website <url> | Website URL | No | | +| | --description <text> | Description | No | | +| | --email <email> | Contact email | No | | +| | --twitter <handle> | Twitter handle | No | | +| | --telegram <handle> | Telegram handle | No | | +| | --github <handle> | GitHub handle | No | | +| | --extra-cid <cid> | Extra data as IPFS CID or hex bytes (0x...) | No | | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/status.mdx b/docs/api-references/vesting/validator/status.mdx new file mode 100644 index 00000000..8ce0d443 --- /dev/null +++ b/docs/api-references/vesting/validator/status.mdx @@ -0,0 +1,22 @@ +--- +title: vesting validator status +--- + +Show validator wallets owned by a vesting contract + +### Usage + +`$ genlayer vesting validator status [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --beneficiary <address> | Beneficiary address (defaults to signer) | No | | +| | --account <name> | Account to use (for default beneficiary address) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/withdraw.mdx b/docs/api-references/vesting/withdraw.mdx new file mode 100644 index 00000000..294c87a0 --- /dev/null +++ b/docs/api-references/vesting/withdraw.mdx @@ -0,0 +1,23 @@ +--- +title: vesting withdraw +--- + +Withdraw vested tokens to the beneficiary + +### Usage + +`$ genlayer vesting withdraw [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --amount <amount> | Amount to withdraw (in wei or with 'eth'/'gen' suffix) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/package-lock.json b/package-lock.json index 434d9ee9..a9874ed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "genlayer", - "version": "0.39.1", + "version": "0.40.0-rc1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "genlayer", - "version": "0.39.1", + "version": "0.40.0-rc1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -19,7 +19,6 @@ "fs-extra": "^11.3.0", "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", "inquirer": "^12.0.0", - "keytar": "^7.9.0", "node-fetch": "^3.0.0", "open": "^10.1.0", "ora": "^8.2.0", @@ -42,7 +41,6 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^3.0.0", - "cross-env": "^7.0.3", "esbuild": ">=0.25.0", "eslint": "^9.0.0", "eslint-config-prettier": "^10.0.0", @@ -53,6 +51,9 @@ "release-it": "^19.0.0", "ts-node": "^10.9.2", "typescript": "^5.4.5" + }, + "optionalDependencies": { + "keytar": "^7.9.0" } }, "node_modules/@adraffy/ens-normalize": { @@ -4066,25 +4067,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4216,6 +4198,7 @@ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", + "optional": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -4358,6 +4341,7 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=8" } @@ -5279,6 +5263,7 @@ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "license": "(MIT OR WTFPL)", + "optional": true, "engines": { "node": ">=6" } @@ -5588,7 +5573,7 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#28e99fbc10ad962019e8314fb1b0d83d5a0e0938", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#57889281db1e34df49abcf6129ffe6f347e4de4d", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", @@ -5799,7 +5784,8 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/glob": { "version": "10.4.5", @@ -6961,6 +6947,7 @@ "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", "hasInstallScript": true, "license": "MIT", + "optional": true, "dependencies": { "node-addon-api": "^4.3.0", "prebuild-install": "^7.0.1" @@ -7267,6 +7254,7 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", + "optional": true, "engines": { "node": ">=10" }, @@ -7359,7 +7347,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/napi-postinstall": { "version": "0.3.3", @@ -7434,6 +7423,7 @@ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", "license": "MIT", + "optional": true, "dependencies": { "semver": "^7.3.5" }, @@ -7445,7 +7435,8 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/node-domexception": { "version": "1.0.0", @@ -8127,6 +8118,7 @@ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "license": "MIT", + "optional": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -8809,6 +8801,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8992,7 +8985,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/simple-get": { "version": "4.0.1", @@ -9013,6 +9007,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -9697,6 +9692,7 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", + "optional": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -9985,9 +9981,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index 64058f2d..1ce24e47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "genlayer", - "version": "0.39.1", + "version": "0.40.0-rc1", "description": "GenLayer Command Line Tool", "main": "src/index.ts", "type": "module", @@ -12,8 +12,10 @@ "test:watch": "vitest --watch", "test:coverage": "vitest run --coverage", "test:smoke": "vitest run --config vitest.smoke.config.ts", - "dev": "cross-env NODE_ENV=development node esbuild.config.js", - "build": "cross-env NODE_ENV=production node esbuild.config.js", + "dev": "node scripts/run-esbuild.mjs development", + "build": "node scripts/run-esbuild.mjs production", + "prepare": "npm run build", + "prepack": "npm run build", "release": "./scripts/release.sh", "postinstall": "node ./scripts/postinstall.js", "docs:cli": "node scripts/generate-cli-docs.mjs" @@ -46,7 +48,6 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^3.0.0", - "cross-env": "^7.0.3", "esbuild": ">=0.25.0", "eslint": "^9.0.0", "eslint-config-prettier": "^10.0.0", @@ -68,7 +69,6 @@ "fs-extra": "^11.3.0", "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", "inquirer": "^12.0.0", - "keytar": "^7.9.0", "node-fetch": "^3.0.0", "open": "^10.1.0", "ora": "^8.2.0", @@ -77,6 +77,9 @@ "viem": "^2.21.54", "vitest": "^3.0.0" }, + "optionalDependencies": { + "keytar": "^7.9.0" + }, "overrides": { "vite": { "rollup": "npm:@rollup/wasm-node" diff --git a/scripts/generate-cli-docs.mjs b/scripts/generate-cli-docs.mjs index f661f9ac..3bc6fb15 100644 --- a/scripts/generate-cli-docs.mjs +++ b/scripts/generate-cli-docs.mjs @@ -154,7 +154,7 @@ function parseHelp(text, programName, commandPath) { if (inOptions) { // e.g., " -V, --version output the version number" - const m = l.match(/^\s*(-\w)?,?\s*(--[\w-]+(?:\s+<\w+>)?)\s{2,}(.+)$/); + const m = l.match(/^\s*(-\w)?,?\s*(--[\w-]+(?:\s+<[^>]+>)?)\s{2,}(.+)$/); if (m) { const short = m[1] || ''; const long = m[2] || ''; @@ -294,10 +294,18 @@ async function main() { 'staking': 'Staking', 'localnet': 'Localnet', }; - const rootMeta = {}; + const rootMeta = { index: 'Overview' }; for (const [key, label] of Object.entries(GROUP_LABELS)) { rootMeta[key] = label; } + // Ungrouped top-level commands (e.g. finalize) still need nav entries + const ungrouped = outputs + .filter((o) => o.relDir === '' && o.filename !== 'index.mdx') + .map((o) => o.filename.replace(/\.mdx$/, '')) + .sort(); + for (const slug of ungrouped) { + if (!rootMeta[slug]) rootMeta[slug] = slug; + } await fs.writeFile(path.join(rootOut, '_meta.json'), JSON.stringify(rootMeta, null, 2), 'utf8'); // Write _meta.json for each group subdirectory diff --git a/scripts/run-esbuild.mjs b/scripts/run-esbuild.mjs new file mode 100644 index 00000000..0289619f --- /dev/null +++ b/scripts/run-esbuild.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +const mode = process.argv[2] ?? 'development'; + +if (!['development', 'production'].includes(mode)) { + console.error(`Unsupported build mode: ${mode}`); + process.exit(1); +} + +process.env.NODE_ENV = mode; + +await import('../esbuild.config.js'); diff --git a/src/commands/account/index.ts b/src/commands/account/index.ts index 3fa23f15..88580d46 100644 --- a/src/commands/account/index.ts +++ b/src/commands/account/index.ts @@ -98,7 +98,7 @@ export function initializeAccountCommands(program: Command) { .command("send ") .description("Send GEN to an address") .option("--rpc ", "RPC URL for the network") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--account ", "Account to send from") .option("--password ", "Password to unlock account (skips interactive prompt)") .action(async (to: string, amount: string, options: {rpc?: string; network?: string; account?: string; password?: string}) => { diff --git a/src/commands/account/send.ts b/src/commands/account/send.ts index 716d577b..e6905b46 100644 --- a/src/commands/account/send.ts +++ b/src/commands/account/send.ts @@ -1,4 +1,4 @@ -import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; +import {BaseAction, resolveNetwork} from "../../lib/actions/BaseAction"; import {parseEther, formatEther} from "viem"; import {createClient, createAccount} from "genlayer-js"; import type {GenLayerChain, Address, Hash} from "genlayer-js/types"; @@ -21,13 +21,9 @@ export class SendAction extends BaseAction { private getNetwork(networkOption?: string): GenLayerChain { if (networkOption) { - const network = BUILT_IN_NETWORKS[networkOption]; - if (!network) { - throw new Error(`Unknown network: ${networkOption}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); - } - return network; + return resolveNetwork(networkOption, this.getCustomNetworks()); } - return resolveNetwork(this.getConfig().network); + return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } private parseAmount(amount: string): bigint { diff --git a/src/commands/account/show.ts b/src/commands/account/show.ts index 161bac41..ee74506f 100644 --- a/src/commands/account/show.ts +++ b/src/commands/account/show.ts @@ -15,7 +15,7 @@ export class ShowAccountAction extends BaseAction { } private getNetwork(): GenLayerChain { - return resolveNetwork(this.getConfig().network); + return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } async execute(options?: ShowAccountOptions): Promise { diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 9fad7e68..845831c6 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -4,7 +4,7 @@ import {BaseAction} from "../../lib/actions/BaseAction"; import {pathToFileURL} from "url"; import {formatStakingAmount} from "genlayer-js"; import {buildSync} from "esbuild"; -import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; +import {ContractFeeCliOptions, parseValidUntil, resolveTransactionFees} from "./fees"; import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution"; export interface DeployOptions extends ContractFeeCliOptions { @@ -133,7 +133,10 @@ export class DeployAction extends BaseAction { const leaderOnly = false; const deployParams: any = {code: contractCode, args: options.args, leaderOnly}; - const fees = parseTransactionFees(options, {deployTargeted: true}); + const fees = await resolveTransactionFees(client, options, { + deployTargeted: true, + profileTarget: {kind: "deploy"}, + }); const validUntil = parseValidUntil(options); if (fees) deployParams.fees = fees; if (validUntil !== undefined) deployParams.validUntil = validUntil; @@ -160,8 +163,10 @@ export class DeployAction extends BaseAction { this.log("Consensus Status:", transactionConsensusStatus(result)); const contractAddress = - result.data?.contract_address ?? // localnet/studio - (result.txDataDecoded as any)?.contractAddress; // testnet + // localnet/studio + result.data?.contract_address ?? + // testnet + (result.txDataDecoded as any)?.contractAddress; this.succeedSpinner("Contract deployed successfully.", { "Transaction Hash": hash, diff --git a/src/commands/contracts/estimateFees.ts b/src/commands/contracts/estimateFees.ts index fa3cf873..10e5b11c 100644 --- a/src/commands/contracts/estimateFees.ts +++ b/src/commands/contracts/estimateFees.ts @@ -1,19 +1,14 @@ import {BaseAction} from "../../lib/actions/BaseAction"; -import {ContractFeeCliOptions, parseFeeEstimateOptions} from "./fees"; +import {ContractFeeCliOptions, FeeProfileTarget, parseFeeEstimateOptions, toTransactionFees} from "./fees"; -export interface EstimateFeesOptions extends Pick { +export interface EstimateFeesOptions + extends Pick { args?: any[]; rpc?: string; json?: boolean; includeReport?: boolean; } -const toTransactionFees = (estimate: Record): Record => ({ - distribution: estimate.distribution, - ...(estimate.messageAllocations ? {messageAllocations: estimate.messageAllocations} : {}), - feeValue: estimate.feeValue ?? estimate.fee_value, -}); - const toJsonSafe = (value: any): any => { if (typeof value === "bigint") return value.toString(); if (Array.isArray(value)) return value.map(toJsonSafe); @@ -27,9 +22,8 @@ const toJsonSafe = (value: any): any => { return value; }; -const simulationFeeReport = (simulation: Record): Record | undefined => ( - simulation.feeReport ?? simulation.feeAccounting?.execution_fee_report -); +const simulationFeeReport = (simulation: Record): Record | undefined => + simulation.feeReport ?? simulation.feeAccounting?.execution_fee_report; const withSimulationReport = (estimate: unknown, simulation: unknown): unknown => { if (!simulation || typeof simulation !== "object" || Array.isArray(simulation)) { @@ -39,7 +33,7 @@ const withSimulationReport = (estimate: unknown, simulation: unknown): unknown = const simulationRecord = simulation as Record; return { ...(estimate && typeof estimate === "object" && !Array.isArray(estimate) - ? estimate as Record + ? (estimate as Record) : {estimate}), simulation: { feeAccounting: simulationRecord.feeAccounting, @@ -59,6 +53,9 @@ export class EstimateFeesAction extends BaseAction { args, rpc, fees, + feeProfile, + feePreset, + appealRounds, json, includeReport, }: EstimateFeesOptions & { @@ -68,17 +65,22 @@ export class EstimateFeesAction extends BaseAction { try { const client = await this.getClient(rpc, true); await client.initializeConsensusSmartContract(); - const estimateOptions = parseFeeEstimateOptions({fees}); if (!json) this.startSpinner("Estimating transaction fees..."); let estimate: unknown; if (contractAddress || method) { if (!contractAddress || !method) { - this.failSpinner("Both contractAddress and method are required for simulation-derived fee estimates."); + this.failSpinner( + "Both contractAddress and method are required for simulation-derived fee estimates.", + ); return; } + const estimateOptions = parseFeeEstimateOptions( + {fees, feeProfile, feePreset, appealRounds}, + {profileTarget: {kind: "method", method}}, + ); if (!json) this.setSpinnerText(`Simulating ${method} on ${contractAddress}...`); if (!includeReport && typeof client.estimateTransactionFeesForWrite === "function") { estimate = await client.estimateTransactionFeesForWrite({ @@ -93,7 +95,9 @@ export class EstimateFeesAction extends BaseAction { return; } if (typeof client.estimateTransactionFeesFromSimulation !== "function") { - this.failSpinner("The active genlayer-js client does not support simulation-derived fee estimates."); + this.failSpinner( + "The active genlayer-js client does not support simulation-derived fee estimates.", + ); return; } @@ -118,6 +122,11 @@ export class EstimateFeesAction extends BaseAction { this.failSpinner("--include-report requires both contractAddress and method."); return; } + const profileTarget: FeeProfileTarget | undefined = feeProfile ? {kind: "deploy"} : undefined; + const estimateOptions = parseFeeEstimateOptions( + {fees, feeProfile, feePreset, appealRounds}, + {profileTarget}, + ); estimate = await client.estimateTransactionFees(estimateOptions); } diff --git a/src/commands/contracts/fees.ts b/src/commands/contracts/fees.ts index acd914dc..f276e96d 100644 --- a/src/commands/contracts/fees.ts +++ b/src/commands/contracts/fees.ts @@ -1,15 +1,40 @@ -import { - DEPLOY_CALL_KEY, - deriveExternalMessageCallKey, - deriveInternalMessageCallKey, -} from "genlayer-js"; +import fs from "fs"; +import path from "path"; +import {DEPLOY_CALL_KEY, deriveExternalMessageCallKey, deriveInternalMessageCallKey} from "genlayer-js"; export interface ContractFeeCliOptions { fees?: string; + feeProfile?: string; + feePreset?: string; + appealRounds?: string; feeValue?: string; validUntil?: string; } +export type FeeProfileTarget = {kind: "deploy"} | {kind: "method"; method: string}; + +type FeeParseConfig = { + deployTargeted?: boolean; + profileTarget?: FeeProfileTarget; +}; + +const FEE_PROFILE_PRESET_APPEAL_ROUNDS: Record = { + low: "0", + standard: "1", + high: "2", +}; + +const FEE_PROFILE_FIELDS = [ + "leaderTimeunitsAllocation", + "validatorTimeunitsAllocation", + "executionBudgetPerRound", + "executionConsumed", + "totalMessageFees", + "maxPriceGenPerTimeUnit", + "storageFeeMaxGasPrice", + "receiptFeeMaxGasPrice", +]; + const parseJsonObject = (value: string, optionName: string): Record => { let parsed: unknown; try { @@ -52,6 +77,23 @@ const parseBigNumberishOption = (value: string | undefined, optionName: string): return trimmed; }; +const toSafeNonNegativeNumber = (value: string, optionName: string): number => { + const parsed = BigInt(value); + if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`${optionName} is too large.`); + } + return Number(parsed); +}; + +const parseProfilePresetAppealRounds = (options: ContractFeeCliOptions): string => { + const preset = options.feePreset ?? "standard"; + const appealRounds = FEE_PROFILE_PRESET_APPEAL_ROUNDS[preset]; + if (appealRounds === undefined) { + throw new Error("--fee-preset must be one of: low, standard, high."); + } + return appealRounds; +}; + const normalizeMessageType = (messageType: unknown, index: number): 0 | 1 | undefined => { if (messageType === undefined) { return undefined; @@ -61,7 +103,9 @@ const normalizeMessageType = (messageType: unknown, index: number): 0 | 1 | unde if (messageType === 0 || messageType === 1) { return messageType; } - throw new Error(`--fees.messageAllocations[${index}].messageType must be "internal", "external", 0, or 1.`); + throw new Error( + `--fees.messageAllocations[${index}].messageType must be "internal", "external", 0, or 1.`, + ); } if (typeof messageType !== "string") { @@ -103,27 +147,20 @@ const normalizeMessageAllocationCallKey = ( messageType: 0 | 1 | undefined, index: number, ): Record => { - const helperFields = [ - "callKeyMethod", - "callKeySelector", - "callKeyCalldata", - "functionSelector", - ].filter((field) => allocation[field] !== undefined); + const helperFields = ["callKeyMethod", "callKeySelector", "callKeyCalldata", "functionSelector"].filter( + field => allocation[field] !== undefined, + ); if (allocation.callKey !== undefined && helperFields.length > 0) { - throw new Error(`--fees.messageAllocations[${index}] cannot combine callKey with call-key helper fields.`); + throw new Error( + `--fees.messageAllocations[${index}] cannot combine callKey with call-key helper fields.`, + ); } if (helperFields.length > 1) { throw new Error(`--fees.messageAllocations[${index}] must use only one call-key helper field.`); } - const { - callKeyMethod, - callKeySelector, - callKeyCalldata, - functionSelector, - ...normalized - } = allocation; + const {callKeyMethod, callKeySelector, callKeyCalldata, functionSelector, ...normalized} = allocation; if (helperFields.length === 0) { return normalized; @@ -132,7 +169,9 @@ const normalizeMessageAllocationCallKey = ( const helperField = helperFields[0]; if (helperField === "callKeyMethod") { if (messageType === 0) { - throw new Error(`--fees.messageAllocations[${index}].callKeyMethod requires an internal message allocation.`); + throw new Error( + `--fees.messageAllocations[${index}].callKeyMethod requires an internal message allocation.`, + ); } normalized.messageType = messageType ?? 1; normalized.callKey = deriveInternalMessageCallKey(readStringField(allocation, helperField, index)); @@ -140,7 +179,9 @@ const normalizeMessageAllocationCallKey = ( } if (messageType === 1) { - throw new Error(`--fees.messageAllocations[${index}].${helperField} requires an external message allocation.`); + throw new Error( + `--fees.messageAllocations[${index}].${helperField} requires an external message allocation.`, + ); } const selectorOrCalldata = readStringField(allocation, helperField, index); @@ -167,10 +208,14 @@ const normalizeMessageTypes = (fees: Record, deployTargeted = false } const messageType = normalizeMessageType(allocation.messageType, index); - const normalized = normalizeMessageAllocationCallKey({ - ...allocation, - ...(messageType === undefined ? {} : {messageType}), - }, messageType, index); + const normalized = normalizeMessageAllocationCallKey( + { + ...allocation, + ...(messageType === undefined ? {} : {messageType}), + }, + messageType, + index, + ); if (deployTargeted && normalized.callKey === undefined) { normalized.callKey = DEPLOY_CALL_KEY; } @@ -179,9 +224,105 @@ const normalizeMessageTypes = (fees: Record, deployTargeted = false }; }; +const flattenFeeEstimateOptions = ( + parsed: Record, + config: FeeParseConfig = {}, +): Record => { + const normalized = normalizeMessageTypes(parsed, config.deployTargeted); + if ( + normalized.distribution && + typeof normalized.distribution === "object" && + !Array.isArray(normalized.distribution) + ) { + const {distribution, messageAllocations, ...rest} = normalized; + return { + ...distribution, + ...(messageAllocations !== undefined ? {messageAllocations} : {}), + ...rest, + }; + } + return normalized; +}; + +const readFeeProfile = (profilePath: string): Record => { + const resolvedPath = path.resolve(profilePath); + let content: string; + try { + content = fs.readFileSync(resolvedPath, "utf-8"); + } catch (error) { + throw new Error(`Unable to read --fee-profile at ${resolvedPath}.`); + } + return parseJsonObject(content, "--fee-profile"); +}; + +const feeProfileEntry = (profile: Record, target: FeeProfileTarget): Record => { + const entry = target.kind === "deploy" ? profile.deploy : profile.methods?.[target.method]; + + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + const targetLabel = target.kind === "deploy" ? "deploy" : `method "${target.method}"`; + throw new Error(`--fee-profile does not contain a fee profile for ${targetLabel}.`); + } + return entry; +}; + +const profileEntryToEstimateOptions = ( + entry: Record, + options: ContractFeeCliOptions, +): Record => { + assertSafeJsonNumbers(entry, "--fee-profile entry"); + const result: Record = {}; + + for (const key of FEE_PROFILE_FIELDS) { + if (entry[key] !== undefined) { + result[key] = entry[key]; + } + } + + if (entry.messageAllocations !== undefined) { + result.messageAllocations = entry.messageAllocations; + } + + if (entry.rotations !== undefined) { + result.rotations = entry.rotations; + if (entry.appealRounds !== undefined) { + result.appealRounds = entry.appealRounds; + } + return result; + } + + const appealRounds = parseBigNumberishOption( + options.appealRounds ?? entry.appealRounds?.toString() ?? parseProfilePresetAppealRounds(options), + "--appeal-rounds", + )!; + const rotationsPerRound = parseBigNumberishOption( + entry.rotationsPerRound?.toString() ?? "0", + "--fee-profile rotationsPerRound", + )!; + const rotationCount = toSafeNonNegativeNumber(appealRounds, "--appeal-rounds") + 1; + + result.appealRounds = appealRounds; + result.rotations = Array(rotationCount).fill(rotationsPerRound); + return result; +}; + +const parseProfileEstimateOptions = ( + options: ContractFeeCliOptions, + config: FeeParseConfig = {}, +): Record | undefined => { + if (!options.feeProfile) { + return undefined; + } + + const target = config.profileTarget ?? {kind: "deploy" as const}; + return flattenFeeEstimateOptions( + profileEntryToEstimateOptions(feeProfileEntry(readFeeProfile(options.feeProfile), target), options), + config, + ); +}; + export const parseTransactionFees = ( options: ContractFeeCliOptions, - config: {deployTargeted?: boolean} = {}, + config: FeeParseConfig = {}, ): Record | undefined => { const feeValue = parseBigNumberishOption(options.feeValue, "--fee-value"); let fees = options.fees ? parseJsonObject(options.fees, "--fees") : undefined; @@ -197,21 +338,51 @@ export const parseTransactionFees = ( return fees; }; -export const parseFeeEstimateOptions = (options: Pick): Record | undefined => { +export const parseFeeEstimateOptions = ( + options: Pick, + config: FeeParseConfig = {}, +): Record | undefined => { + const profileOptions = parseProfileEstimateOptions(options, config); if (!options.fees) { - return undefined; + return profileOptions; } - const parsed = normalizeMessageTypes(parseJsonObject(options.fees, "--fees")); - if (parsed.distribution && typeof parsed.distribution === "object" && !Array.isArray(parsed.distribution)) { - const {distribution, messageAllocations, ...rest} = parsed; - return { - ...distribution, - ...(messageAllocations !== undefined ? {messageAllocations} : {}), - ...rest, - }; + const explicitOptions = flattenFeeEstimateOptions(parseJsonObject(options.fees, "--fees"), config); + if (!profileOptions) { + return explicitOptions; + } + return { + ...profileOptions, + ...explicitOptions, + }; +}; + +export const toTransactionFees = (estimate: Record): Record => ({ + distribution: estimate.distribution, + ...(estimate.messageAllocations ? {messageAllocations: estimate.messageAllocations} : {}), + feeValue: estimate.feeValue ?? estimate.fee_value, +}); + +export const resolveTransactionFees = async ( + client: {estimateTransactionFees?: (options?: Record) => Promise>}, + options: ContractFeeCliOptions, + config: FeeParseConfig = {}, +): Promise | undefined> => { + if (!options.feeProfile) { + return parseTransactionFees(options, config); + } + + if (typeof client.estimateTransactionFees !== "function") { + throw new Error("The active genlayer-js client does not support fee profile estimation."); + } + + const estimateOptions = parseFeeEstimateOptions(options, config); + const transactionFees = toTransactionFees(await client.estimateTransactionFees(estimateOptions)); + const feeValue = parseBigNumberishOption(options.feeValue, "--fee-value"); + if (feeValue !== undefined) { + transactionFees.feeValue = feeValue; } - return parsed; + return transactionFees; }; export const parseValidUntil = (options: ContractFeeCliOptions): string | undefined => { diff --git a/src/commands/contracts/index.ts b/src/commands/contracts/index.ts index 07c2f993..c18b5df2 100644 --- a/src/commands/contracts/index.ts +++ b/src/commands/contracts/index.ts @@ -78,7 +78,7 @@ const ARGS_HELP = [ ' str: hello, "multi word"', " address: 0x6857...a0 (40 hex chars) or addr#6857...a0", " bytes: b#deadbeef", - ' array: \'[1, 2, "three"]\'', + " array: '[1, 2, \"three\"]'", ' dict: \'{"key": "value"}\'', ].join("\n"); @@ -100,6 +100,12 @@ const FEE_ESTIMATE_HELP = [ "Use callKeyMethod for internal messages, or callKeySelector/callKeyCalldata for external messages.", ].join("\n"); +const FEE_PROFILE_HELP = [ + "Path to a fee profile generated by gltest --fee-profile.", + "Deploy uses the profile deploy entry; write and targeted estimate-fees use the matching method entry.", + "--fees can still be provided to override profile values.", +].join("\n"); + export function initializeContractsCommands(program: Command) { program .command("deploy") @@ -107,6 +113,9 @@ export function initializeContractsCommands(program: Command) { .option("--contract ", "Path to the smart contract to deploy") .option("--rpc ", "RPC URL for the network") .option("--fees ", FEES_HELP) + .option("--fee-profile ", FEE_PROFILE_HELP) + .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") + .option("--appeal-rounds ", "Override fee profile appeal rounds") .option("--fee-value ", "Fee deposit value to send with the transaction") .option("--valid-until ", "Unix timestamp after which the transaction is invalid") .option("--args ", ARGS_HELP, parseArg, []) @@ -124,12 +133,7 @@ export function initializeContractsCommands(program: Command) { .command("call ") .description("Call a contract method without sending a transaction or changing the state") .option("--rpc ", "RPC URL for the network") - .option( - "--args ", - ARGS_HELP, - parseArg, - [], - ) + .option("--args ", ARGS_HELP, parseArg, []) .action(async (contractAddress: string, method: string, options: CallOptions) => { const callAction = new CallAction(); await callAction.call({contractAddress, method, ...options}); @@ -140,14 +144,12 @@ export function initializeContractsCommands(program: Command) { .description("Sends a transaction to a contract method that modifies the state") .option("--rpc ", "RPC URL for the network") .option("--fees ", FEES_HELP) + .option("--fee-profile ", FEE_PROFILE_HELP) + .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") + .option("--appeal-rounds ", "Override fee profile appeal rounds") .option("--fee-value ", "Fee deposit value to send with the transaction") .option("--valid-until ", "Unix timestamp after which the transaction is invalid") - .option( - "--args ", - ARGS_HELP, - parseArg, - [], - ) + .option("--args ", ARGS_HELP, parseArg, []) .action(async (contractAddress: string, method: string, options: WriteOptions) => { const writeAction = new WriteAction(); await writeAction.write({contractAddress, method, ...options}); @@ -158,18 +160,22 @@ export function initializeContractsCommands(program: Command) { .description("Build a transaction fee preset, optionally from a Studio/localnet write simulation") .option("--rpc ", "RPC URL for the network") .option("--fees ", FEE_ESTIMATE_HELP) + .option("--fee-profile ", FEE_PROFILE_HELP) + .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") + .option("--appeal-rounds ", "Override fee profile appeal rounds") .option("--json", "Print the fee estimate as JSON without spinner output") .option("--include-report", "Include simulation fee accounting/report in the generated estimate output") - .option( - "--args ", - ARGS_HELP, - parseArg, - [], - ) - .action(async (contractAddress: string | undefined, method: string | undefined, options: EstimateFeesOptions) => { - const estimateFeesAction = new EstimateFeesAction(); - await estimateFeesAction.estimate({contractAddress, method, ...options}); - }); + .option("--args ", ARGS_HELP, parseArg, []) + .action( + async ( + contractAddress: string | undefined, + method: string | undefined, + options: EstimateFeesOptions, + ) => { + const estimateFeesAction = new EstimateFeesAction(); + await estimateFeesAction.estimate({contractAddress, method, ...options}); + }, + ); program .command("schema ") diff --git a/src/commands/contracts/write.ts b/src/commands/contracts/write.ts index a30aacbd..42622b8a 100644 --- a/src/commands/contracts/write.ts +++ b/src/commands/contracts/write.ts @@ -2,7 +2,7 @@ // import type {GenLayerClient} from "genlayer-js/types"; import {formatStakingAmount} from "genlayer-js"; import {BaseAction} from "../../lib/actions/BaseAction"; -import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; +import {ContractFeeCliOptions, parseValidUntil, resolveTransactionFees} from "./fees"; import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution"; export interface WriteOptions extends ContractFeeCliOptions { @@ -21,16 +21,14 @@ export class WriteAction extends BaseAction { args, rpc, fees, + feeProfile, + feePreset, + appealRounds, feeValue, validUntil, - }: { + }: WriteOptions & { contractAddress: string; method: string; - args: any[]; - rpc?: string; - fees?: string; - feeValue?: string; - validUntil?: string; }): Promise { const client = await this.getClient(rpc); await client.initializeConsensusSmartContract(); @@ -43,8 +41,19 @@ export class WriteAction extends BaseAction { args, value: 0n, }; - const parsedFees = parseTransactionFees({fees, feeValue, validUntil}); - const parsedValidUntil = parseValidUntil({fees, feeValue, validUntil}); + const parsedFees = await resolveTransactionFees( + client, + {fees, feeProfile, feePreset, appealRounds, feeValue, validUntil}, + {profileTarget: {kind: "method", method}}, + ); + const parsedValidUntil = parseValidUntil({ + fees, + feeProfile, + feePreset, + appealRounds, + feeValue, + validUntil, + }); if (parsedFees) writeParams.fees = parsedFees; if (parsedValidUntil !== undefined) writeParams.validUntil = parsedValidUntil; if (parsedFees?.feeValue !== undefined) { diff --git a/src/commands/network/index.ts b/src/commands/network/index.ts index a5f7ee27..4e786e0a 100644 --- a/src/commands/network/index.ts +++ b/src/commands/network/index.ts @@ -6,6 +6,24 @@ export function initializeNetworkCommands(program: Command) { const network = program.command("network").description("Network configuration"); + // genlayer network add + network + .command("add") + .description("Add a custom network profile") + .argument("", "Custom network alias") + .requiredOption("--base ", "Built-in base network alias") + .option("--deployment ", "Consensus deployments JSON file") + .option("--deployment-key ", "Deployment JSON sub-object to scan") + .option("--rpc ", "Node RPC URL override") + .option("--consensus-main ", "ConsensusMain contract address override") + .option("--consensus-data ", "ConsensusData contract address override") + .option("--staking ", "Staking contract address override") + .option("--fee-manager ", "FeeManager contract address override") + .option("--rounds-storage ", "RoundsStorage contract address override") + .option("--appeals ", "Appeals contract address override") + .option("--chain-id ", "Chain ID override") + .action((alias: string, options) => networkActions.addNetwork(alias, options)); + // genlayer network set [name] network .command("set") @@ -25,5 +43,12 @@ export function initializeNetworkCommands(program: Command) { .description("List available networks") .action(() => networkActions.listNetworks()); + // genlayer network remove + network + .command("remove") + .description("Remove a custom network profile") + .argument("", "Custom network alias to remove") + .action((alias: string) => networkActions.removeNetwork(alias)); + return program; } diff --git a/src/commands/network/setNetwork.ts b/src/commands/network/setNetwork.ts index 550e1fef..de771cb8 100644 --- a/src/commands/network/setNetwork.ts +++ b/src/commands/network/setNetwork.ts @@ -1,61 +1,113 @@ import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; import inquirer, {DistinctQuestion} from "inquirer"; +import { + CONTRACT_OVERRIDES, + CUSTOM_NETWORKS_CONFIG_KEY, + getContractAddress, + isValidAddress, + normalizeCustomNetworks, + parseDeploymentFile, + type ContractOverrideKey, + type CustomNetworkOverrides, + type CustomNetworkProfile, + type CustomNetworksConfig, +} from "../../lib/networks/customNetworks"; +import type {Address, GenLayerChain} from "genlayer-js/types"; -const networks = Object.entries(BUILT_IN_NETWORKS).map(([alias, network]) => ({ +const builtInNetworks = Object.entries(BUILT_IN_NETWORKS).map(([alias, network]) => ({ name: network.name, alias, - value: network, + type: "built-in" as const, })); +const CONTRACT_FLAG_OPTIONS: Array<{ + optionKey: keyof AddNetworkOptions; + overrideKey: ContractOverrideKey; + label: string; +}> = [ + {optionKey: "consensusMain", overrideKey: "consensusMain", label: "--consensus-main"}, + {optionKey: "consensusData", overrideKey: "consensusData", label: "--consensus-data"}, + {optionKey: "staking", overrideKey: "staking", label: "--staking"}, + {optionKey: "feeManager", overrideKey: "feeManager", label: "--fee-manager"}, + {optionKey: "roundsStorage", overrideKey: "roundsStorage", label: "--rounds-storage"}, + {optionKey: "appeals", overrideKey: "appeals", label: "--appeals"}, +]; + +export interface AddNetworkOptions { + base: string; + deployment?: string; + deploymentKey?: string; + rpc?: string; + consensusMain?: string; + consensusData?: string; + staking?: string; + feeManager?: string; + roundsStorage?: string; + appeals?: string; + chainId?: string; +} + +type NetworkEntry = + | {alias: string; name: string; type: "built-in"} + | {alias: string; name: string; type: "custom"; base: string; profile: CustomNetworkProfile}; + export class NetworkActions extends BaseAction { constructor() { super(); } - async showInfo(): Promise { - const storedNetwork = this.getConfigByKey("network") || "localnet"; - const network = resolveNetwork(storedNetwork); - - const info: Record = { - alias: storedNetwork, - name: network.name, - chainId: network.id?.toString() || "unknown", - rpc: network.rpcUrls?.default?.http?.[0] || "unknown", - mainContract: network.consensusMainContract?.address || "not set", - stakingContract: network.stakingContract?.address || "not set", - }; + async addNetwork(alias: string, options: AddNetworkOptions): Promise { + try { + const customNetworks = this.readCustomNetworks(); + const profile = this.buildCustomNetworkProfile(alias, options, customNetworks); + customNetworks[alias] = profile; + this.writeConfig(CUSTOM_NETWORKS_CONFIG_KEY, customNetworks); - if (network.blockExplorers?.default?.url) { - info.explorer = network.blockExplorers.default.url; + const network = resolveNetwork(alias, customNetworks); + this.succeedSpinner("Custom network profile added", this.formatNetworkInfo(alias, network, profile)); + } catch (error: any) { + this.failSpinner("Failed to add custom network profile", error.message || error); } + } + + async showInfo(): Promise { + const storedNetwork = this.getConfigByKey("network") || "localnet"; + const customNetworks = this.readCustomNetworks(); + const network = resolveNetwork(storedNetwork, customNetworks); + const profile = customNetworks[storedNetwork]; - this.succeedSpinner("Current network", info); + this.succeedSpinner("Current network", this.formatNetworkInfo(storedNetwork, network, profile)); } async listNetworks(): Promise { const currentNetwork = this.getConfigByKey("network") || "localnet"; + const entries = this.getNetworkEntries(); console.log(""); - for (const net of networks) { + for (const net of entries) { const marker = net.alias === currentNetwork ? "*" : " "; - console.log(`${marker} ${net.alias.padEnd(16)} ${net.name}`); + if (net.type === "custom") { + console.log(`${marker} ${net.alias.padEnd(20)} custom base: ${net.base} ${net.name}`); + } else { + console.log(`${marker} ${net.alias.padEnd(20)} built-in ${net.name}`); + } } console.log(""); } async setNetwork(networkName?: string): Promise { + const entries = this.getNetworkEntries(); + if (networkName || networkName === "") { - if (!networks.some(n => n.name === networkName || n.alias === networkName)) { - this.failSpinner(`Network ${networkName} not found`); - return; - } - const selectedNetwork = networks.find(n => n.name === networkName || n.alias === networkName); + const selectedNetwork = entries.find(n => + n.alias === networkName || (n.type === "built-in" && n.name === networkName), + ); if (!selectedNetwork) { this.failSpinner(`Network ${networkName} not found`); return; } this.writeConfig("network", selectedNetwork.alias); - this.succeedSpinner(`Network successfully set to ${selectedNetwork.name}`); + this.succeedSpinner(`Network successfully set to ${this.getNetworkDisplayName(selectedNetwork)}`); return; } @@ -64,14 +116,172 @@ export class NetworkActions extends BaseAction { type: "list", name: "selectedNetwork", message: "Select which network do you want to use:", - choices: networks.map(n => ({name: n.name, value: n.alias})), + choices: entries.map(n => ({ + name: this.getNetworkChoiceName(n), + value: n.alias, + })), }, ]; const networkAnswer = await inquirer.prompt(networkQuestions); const selectedAlias = networkAnswer.selectedNetwork; - const selectedNetwork = networks.find(n => n.alias === selectedAlias)!; + const selectedNetwork = entries.find(n => n.alias === selectedAlias)!; this.writeConfig("network", selectedAlias); - this.succeedSpinner(`Network successfully set to ${selectedNetwork.name}`); + this.succeedSpinner(`Network successfully set to ${this.getNetworkDisplayName(selectedNetwork)}`); + } + + async removeNetwork(alias: string): Promise { + try { + if (BUILT_IN_NETWORKS[alias]) { + throw new Error(`Cannot remove built-in network: ${alias}`); + } + + const customNetworks = this.readCustomNetworks(); + if (!customNetworks[alias]) { + throw new Error(`Custom network ${alias} not found`); + } + + delete customNetworks[alias]; + this.writeConfig(CUSTOM_NETWORKS_CONFIG_KEY, customNetworks); + + if ((this.getConfigByKey("network") || "localnet") === alias) { + this.writeConfig("network", "localnet"); + this.logWarning(`Removed active network ${alias}; active network reset to localnet.`); + } + + this.succeedSpinner(`Custom network ${alias} removed`); + } catch (error: any) { + this.failSpinner("Failed to remove custom network profile", error.message || error); + } + } + + private buildCustomNetworkProfile( + alias: string, + options: AddNetworkOptions, + customNetworks: CustomNetworksConfig, + ): CustomNetworkProfile { + if (BUILT_IN_NETWORKS[alias]) { + throw new Error(`Custom network alias cannot collide with built-in network: ${alias}`); + } + if (customNetworks[alias]) { + throw new Error(`Custom network ${alias} already exists`); + } + if (!options.base || !BUILT_IN_NETWORKS[options.base]) { + throw new Error(`Base network must be one of: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); + } + + const hasOverrideInput = Boolean( + options.deployment || + options.rpc || + options.chainId !== undefined || + CONTRACT_FLAG_OPTIONS.some(option => Boolean(options[option.optionKey])), + ); + if (!hasOverrideInput) { + throw new Error("Provide at least one override: --deployment, --rpc, --chain-id, or a contract address flag"); + } + + const overrides: CustomNetworkOverrides = {}; + if (options.deployment) { + const parsed = parseDeploymentFile(options.deployment, options.deploymentKey); + Object.assign(overrides, parsed.overrides); + for (const notice of parsed.notices) { + this.logInfo(notice); + } + } + + for (const option of CONTRACT_FLAG_OPTIONS) { + const address = options[option.optionKey]; + if (!address) continue; + if (!isValidAddress(address)) { + throw new Error(`Invalid address for ${option.label}: ${address}`); + } + overrides[option.overrideKey] = address as Address; + } + + if (options.rpc) { + overrides.rpcUrl = options.rpc; + } + + if (options.chainId !== undefined) { + const chainId = Number(options.chainId); + if (!Number.isInteger(chainId) || chainId <= 0) { + throw new Error(`Invalid --chain-id value: ${options.chainId}`); + } + overrides.chainId = chainId; + } + + return { + base: options.base, + overrides, + }; + } + + private formatNetworkInfo( + alias: string, + network: GenLayerChain, + profile?: CustomNetworkProfile, + ): Record { + const info: Record = { + alias, + type: profile ? "custom" : "built-in", + }; + + if (profile) { + info.base = profile.base; + } + + info.name = network.name; + info.chainId = this.formatInheritedValue(network.id?.toString() || "unknown", Boolean(profile?.overrides.chainId), profile); + info.rpc = this.formatInheritedValue(network.rpcUrls?.default?.http?.[0] || "unknown", Boolean(profile?.overrides.rpcUrl), profile); + + for (const contract of CONTRACT_OVERRIDES) { + const address = getContractAddress(network, contract.overrideKey) || "not set"; + info[contract.label] = this.formatInheritedValue(address, Boolean(profile?.overrides[contract.overrideKey]), profile); + } + + if (network.blockExplorers?.default?.url) { + info.explorer = network.blockExplorers.default.url; + } + + return info; + } + + private formatInheritedValue(value: string, overridden: boolean, profile?: CustomNetworkProfile): string { + if (!profile) return value; + return `${value} (${overridden ? "overridden" : "inherited"})`; + } + + private getNetworkEntries(): NetworkEntry[] { + const customNetworks = this.readCustomNetworks(); + const customEntries = Object.entries(customNetworks).map(([alias, profile]) => { + const network = resolveNetwork(alias, customNetworks); + return { + alias, + name: network.name, + type: "custom" as const, + base: profile.base, + profile, + }; + }); + + return [...builtInNetworks, ...customEntries]; + } + + private getNetworkChoiceName(entry: NetworkEntry): string { + if (entry.type === "custom") { + return `${entry.alias} (custom, base: ${entry.base})`; + } + return entry.name; + } + + private getNetworkDisplayName(entry: NetworkEntry): string { + if (entry.type === "custom") { + return `${entry.alias} (custom)`; + } + return entry.name; + } + + private readCustomNetworks(): CustomNetworksConfig { + return normalizeCustomNetworks(this.getConfigByKey(CUSTOM_NETWORKS_CONFIG_KEY)); } } diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index 12d92133..ab78e8c9 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -52,14 +52,10 @@ export class StakingAction extends BaseAction { private getNetwork(config: StakingConfig): GenLayerChain { // Priority: --network option > global config > localnet default if (config.network) { - const network = BUILT_IN_NETWORKS[config.network]; - if (!network) { - throw new Error(`Unknown network: ${config.network}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); - } - return {...network}; + return {...resolveNetwork(config.network, this.getCustomNetworks())}; } - return resolveNetwork(this.getConfig().network); + return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } protected async getStakingClient(config: StakingConfig): Promise> { @@ -113,7 +109,12 @@ export class StakingAction extends BaseAction { const keystorePath = this.getKeystorePath(accountName); if (!existsSync(keystorePath)) { - throw new Error(`Account '${accountName}' not found. Run 'genlayer account create --name ${accountName}' first.`); + // Read-only queries don't need a local account: fall back to an + // account-less client so listings work on a fresh install. + return createClient({ + chain: network, + endpoint: config.rpc, + }); } const keystoreData = JSON.parse(readFileSync(keystorePath, "utf-8")); diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index 41a1d535..b5b097eb 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -1,4 +1,5 @@ import {Command} from "commander"; +import type {StakingConfig} from "./StakingAction"; import {ValidatorJoinAction, ValidatorJoinOptions} from "./validatorJoin"; import {ValidatorDepositAction, ValidatorDepositOptions} from "./validatorDeposit"; import {ValidatorExitAction, ValidatorExitOptions} from "./validatorExit"; @@ -11,6 +12,7 @@ import {DelegatorExitAction, DelegatorExitOptions} from "./delegatorExit"; import {DelegatorClaimAction, DelegatorClaimOptions} from "./delegatorClaim"; import {StakingInfoAction, StakingInfoOptions} from "./stakingInfo"; import {ValidatorHistoryAction, ValidatorHistoryOptions} from "./validatorHistory"; +import {ValidatorsAction, ValidatorsOptions} from "./validators"; import {ValidatorWizardAction, WizardOptions} from "./wizard"; export function initializeStakingCommands(program: Command) { @@ -38,7 +40,7 @@ export function initializeStakingCommands(program: Command) { .option("--operator
", "Operator address (defaults to signer)") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (options: ValidatorJoinOptions) => { @@ -53,7 +55,7 @@ export function initializeStakingCommands(program: Command) { .requiredOption("--amount ", "Amount to deposit (in wei or with 'eth'/'gen' suffix)") .option("--account ", "Account to use (must be validator owner)") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .action(async (validatorArg: string | undefined, options: ValidatorDepositOptions) => { const validator = validatorArg || options.validator; @@ -72,7 +74,7 @@ export function initializeStakingCommands(program: Command) { .requiredOption("--shares ", "Number of shares to withdraw") .option("--account ", "Account to use (must be validator owner)") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .action(async (validatorArg: string | undefined, options: ValidatorExitOptions) => { const validator = validatorArg || options.validator; @@ -90,7 +92,7 @@ export function initializeStakingCommands(program: Command) { .option("--validator
", "Validator wallet contract address (deprecated, use positional arg)") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .action(async (validatorArg: string | undefined, options: ValidatorClaimOptions) => { const validator = validatorArg || options.validator; @@ -108,7 +110,7 @@ export function initializeStakingCommands(program: Command) { .option("--validator
", "Validator address to prime (deprecated, use positional arg)") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (validatorArg: string | undefined, options: ValidatorPrimeOptions) => { @@ -126,7 +128,7 @@ export function initializeStakingCommands(program: Command) { .description("Prime all validators that need priming") .option("--account ", "Account to use (pays gas)") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (options: StakingConfig) => { @@ -141,7 +143,7 @@ export function initializeStakingCommands(program: Command) { .option("--operator
", "New operator address (deprecated, use positional arg)") .option("--account ", "Account to use (must be validator owner)") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .action(async (validatorArg: string | undefined, operatorArg: string | undefined, options: SetOperatorOptions) => { const validator = validatorArg || options.validator; @@ -169,7 +171,7 @@ export function initializeStakingCommands(program: Command) { .option("--extra-cid ", "Extra data as IPFS CID or hex bytes (0x...)") .option("--account ", "Account to use (must be validator operator)") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .action(async (validatorArg: string | undefined, options: SetIdentityOptions) => { const validator = validatorArg || options.validator; @@ -189,7 +191,7 @@ export function initializeStakingCommands(program: Command) { .requiredOption("--amount ", "Amount to stake (in wei or with 'eth'/'gen' suffix)") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (validatorArg: string | undefined, options: DelegatorJoinOptions) => { @@ -209,7 +211,7 @@ export function initializeStakingCommands(program: Command) { .requiredOption("--shares ", "Number of shares to withdraw") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (validatorArg: string | undefined, options: DelegatorExitOptions) => { @@ -229,7 +231,7 @@ export function initializeStakingCommands(program: Command) { .option("--delegator
", "Delegator address (defaults to signer)") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (validatorArg: string | undefined, options: DelegatorClaimOptions) => { @@ -248,7 +250,7 @@ export function initializeStakingCommands(program: Command) { .description("Get information about a validator") .option("--validator
", "Validator address (deprecated, use positional arg)") .option("--account ", "Account to use (for default validator address)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .option("--debug", "Show raw unfiltered pending deposits/withdrawals") @@ -264,7 +266,7 @@ export function initializeStakingCommands(program: Command) { .option("--validator
", "Validator address (deprecated, use positional arg)") .option("--delegator
", "Delegator address (defaults to signer)") .option("--account ", "Account to use (for default delegator address)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (validatorArg: string | undefined, options: StakingInfoOptions & {delegator?: string}) => { @@ -281,7 +283,7 @@ export function initializeStakingCommands(program: Command) { .command("epoch-info") .description("Get current epoch and staking parameters") .option("--epoch ", "Show data for specific epoch (current or previous only)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (options: StakingInfoOptions & {epoch?: string}) => { @@ -292,7 +294,7 @@ export function initializeStakingCommands(program: Command) { staking .command("active-validators") .description("List all active validators") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (options: StakingInfoOptions) => { @@ -303,7 +305,7 @@ export function initializeStakingCommands(program: Command) { staking .command("quarantined-validators") .description("List all quarantined validators") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (options: StakingInfoOptions) => { @@ -314,7 +316,7 @@ export function initializeStakingCommands(program: Command) { staking .command("banned-validators") .description("List all banned validators") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (options: StakingInfoOptions) => { @@ -324,14 +326,17 @@ export function initializeStakingCommands(program: Command) { staking .command("validators") - .description("Show validator set with stake, status, and voting power") + .description("List validators with stake, status, and optional explorer performance") .option("--all", "Include banned validators") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--json", "Output machine-readable JSON") + .option("--sort-by ", "Sort validators by stake or uptime (default: stake)", "stake") + .option("--explorer-url ", "Explorer backend or explorer base URL for optional performance enrichment") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (options: StakingInfoOptions & {all?: boolean}) => { - const action = new StakingInfoAction(); - await action.listValidators(options); + .action(async (options: ValidatorsOptions) => { + const action = new ValidatorsAction(); + await action.execute(options); }); staking @@ -344,7 +349,7 @@ export function initializeStakingCommands(program: Command) { .option("--all", "Fetch complete history from genesis (slow)") .option("--limit ", "Maximum number of events to show (default: 50)") .option("--account ", "Account to use (for default validator address)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (validatorArg: string | undefined, options: ValidatorHistoryOptions) => { diff --git a/src/commands/staking/validatorHistory.ts b/src/commands/staking/validatorHistory.ts index 071ad84c..aed86ad3 100644 --- a/src/commands/staking/validatorHistory.ts +++ b/src/commands/staking/validatorHistory.ts @@ -1,6 +1,7 @@ -import {StakingAction, StakingConfig, BUILT_IN_NETWORKS} from "./StakingAction"; +import {resolveNetwork} from "../../lib/actions/BaseAction"; +import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address, GenLayerChain} from "genlayer-js/types"; -import {createPublicClient, http} from "viem"; +import {createPublicClient, http, getContract} from "viem"; import Table from "cli-table3"; import chalk from "chalk"; @@ -65,18 +66,11 @@ export class ValidatorHistoryAction extends StakingAction { private getNetworkForHistory(config: StakingConfig): GenLayerChain { if (config.network) { - const network = BUILT_IN_NETWORKS[config.network]; - if (!network) { - throw new Error(`Unknown network: ${config.network}`); - } - return network; + return resolveNetwork(config.network, this.getCustomNetworks()); } // Check global config const globalNetwork = this.getConfig().network; - if (globalNetwork && BUILT_IN_NETWORKS[globalNetwork]) { - return BUILT_IN_NETWORKS[globalNetwork]; - } - return BUILT_IN_NETWORKS["localnet"]; + return resolveNetwork(globalNetwork, this.getCustomNetworks()); } async execute(options: ValidatorHistoryOptions): Promise { @@ -104,7 +98,35 @@ export class ValidatorHistoryAction extends StakingAction { // Get addresses const stakingAddress = client.getStakingContract().address; - const slashingAddress = await client.getSlashingAddress(); + + // getSlashingAddress() was removed in SDK v0.39+. + // Read idleness contract address from AddressManager via viem. + const ADDRESS_MANAGER_ABI = [{ + type: "function", + name: "getIdlenessAddress", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "address" }], + }] as const; + + // Fallback: use staking address if idleness address cannot be resolved + let slashingAddress: string = stakingAddress; + try { + const tempClient = createPublicClient({ + chain, + transport: http(chain.rpcUrls.default.http[0]), + }); + const consensusAddress = chain.consensusMainContract?.address as `0x${string}` | undefined; + if (consensusAddress) { + slashingAddress = await tempClient.readContract({ + address: consensusAddress, + abi: ADDRESS_MANAGER_ABI, + functionName: "getIdlenessAddress", + }) as string; + } + } catch (_) { + // If resolution fails, slash events won't be fetched but reward events will still work + } // Create public client for log fetching const publicClient = createPublicClient({ diff --git a/src/commands/staking/validators.ts b/src/commands/staking/validators.ts new file mode 100644 index 00000000..e1da318e --- /dev/null +++ b/src/commands/staking/validators.ts @@ -0,0 +1,619 @@ +import {resolveNetwork} from "../../lib/actions/BaseAction"; +import {StakingAction, StakingConfig} from "./StakingAction"; +import type {Address, GenLayerChain, ValidatorInfo} from "genlayer-js/types"; +import Table from "cli-table3"; +import chalk from "chalk"; + +const ACTIVATION_DELAY_EPOCHS = 2n; +const UNBONDING_PERIOD_EPOCHS = 7n; +const EXPLORER_PAGE_SIZE = 100; +const EXPLORER_TIMEOUT_MS = 5000; + +export interface ValidatorsOptions extends StakingConfig { + all?: boolean; + json?: boolean; + explorerUrl?: string; + sortBy?: "stake" | "uptime" | string; +} + +interface ExplorerValidatorSummary { + validator_address?: string; + validatorAddress?: string; + status?: string | null; + is_active?: boolean; + isActive?: boolean; + apy?: string | null; + idle_pct_7d?: number | null; + idlePct7d?: number | null; + rotation_pct_7d?: number | null; + rotationPct7d?: number | null; + minority_pct_7d?: number | null; + minorityPct7d?: number | null; + transaction_count?: number; + transactionCount?: number | null; +} + +interface ExplorerAddressValidator { + delegators?: unknown[]; + total_votes_7d?: number; + totalVotes7d?: number | null; + minority_votes_7d?: number; + minorityVotes7d?: number | null; + successful_appeals_7d?: number; + successfulAppeals7d?: number | null; + idle_pct_7d?: number | null; + idlePct7d?: number | null; + rotation_pct_7d?: number | null; + rotationPct7d?: number | null; + minority_pct_7d?: number | null; + minorityPct7d?: number | null; + apy?: string | null; +} + +interface ExplorerPerformance { + apy?: string | null; + uptimePct?: number | null; + idlePct7d?: number | null; + rotationPct7d?: number | null; + minorityPct7d?: number | null; + totalVotes7d?: number | null; + minorityVotes7d?: number | null; + successfulAppeals7d?: number | null; + transactionCount?: number | null; +} + +interface ExplorerData { + endpoint: string; + validators: Map; +} + +interface ValidatorRow { + address: Address; + owner: Address; + operator: Address; + moniker?: string; + active: boolean; + live: boolean; + banned: boolean; + bannedUntilEpoch?: string; + status: string; + belowMin: boolean; + selfStake: string; + selfStakeRaw: bigint; + delegatedStake: string; + delegatedStakeRaw: bigint; + totalStake: string; + totalStakeRaw: bigint; + pendingDepositRaw: bigint; + pendingWithdrawalRaw: bigint; + primedEpoch: string; + needsPriming: boolean; + delegatorCount: number | null; + epochsActive: number | null; + isMine: boolean; + performance?: ExplorerPerformance; +} + +export class ValidatorsAction extends StakingAction { + async execute(options: ValidatorsOptions): Promise { + this.startSpinner("Fetching validator set..."); + + try { + const client: any = await this.getReadOnlyStakingClient(options); + + let myAddress: Address | null = null; + try { + myAddress = await this.getSignerAddress(); + } catch { + // Listing validators should not require a local account. + } + + const [allTreeAddresses, activeAddresses, quarantinedList, bannedList, epochInfo] = await Promise.all([ + this.getAllValidatorsFromTree(options), + client.getActiveValidators(), + client.getQuarantinedValidatorsDetailed(), + client.getBannedValidators(), + client.getEpochInfo(), + ]); + + const quarantinedSet = new Map(quarantinedList.map((v: any) => [v.validator.toLowerCase(), v])); + const bannedSet = new Map(bannedList.map((v: any) => [v.validator.toLowerCase(), v])); + const activeSet = new Set(activeAddresses.map((a: string) => a.toLowerCase())); + const currentEpoch = BigInt(epochInfo.currentEpoch); + const validatorMinStakeRaw = BigInt(epochInfo.validatorMinStakeRaw ?? 0n); + + const allAddresses: Address[] = options.all + ? allTreeAddresses + : allTreeAddresses.filter((addr: Address) => !bannedSet.has(addr.toLowerCase())); + + this.setSpinnerText(`Fetching details for ${allAddresses.length} validators...`); + + const validatorInfos = await this.fetchValidatorInfos(client, allAddresses); + const explorerUrl = options.explorerUrl || this.getDefaultExplorerUrl(options); + const explorerData = explorerUrl + ? await this.fetchExplorerData(explorerUrl, validatorInfos.map(info => info.address)) + : null; + + const rows = validatorInfos.map(info => { + const addrLower = info.address.toLowerCase(); + const isQuarantined = quarantinedSet.has(addrLower); + const isBanned = info.banned || bannedSet.has(addrLower); + const isActive = activeSet.has(addrLower); + const bannedInfo = bannedSet.get(addrLower); + const quarantinedInfo = quarantinedSet.get(addrLower); + const performance = explorerData?.validators.get(addrLower); + + return this.buildRow({ + info, + currentEpoch, + validatorMinStakeRaw, + isActive, + isQuarantined, + isBanned, + bannedInfo, + quarantinedInfo, + myAddress, + performance, + }); + }); + + const sortedRows = this.sortRows(rows, options.sortBy || "stake"); + + this.stopSpinner(); + + if (options.json) { + const output = { + count: sortedRows.length, + activeCount: sortedRows.filter(row => row.active).length, + current_epoch: currentEpoch.toString(), + sortBy: this.normalizeSortBy(options.sortBy || "stake"), + explorer: explorerData + ? {enabled: true, url: explorerUrl, endpoint: explorerData.endpoint} + : {enabled: false, url: explorerUrl || null}, + validators: sortedRows.map(row => this.toJsonRow(row)), + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + + this.printTable(sortedRows, currentEpoch); + } catch (error: any) { + this.failSpinner("Failed to list validators", error.message || error); + } + } + + private async fetchValidatorInfos(client: any, addresses: Address[]): Promise { + const BATCH_SIZE = 5; + const validatorInfos: ValidatorInfo[] = []; + + for (let i = 0; i < addresses.length; i += BATCH_SIZE) { + const batch = addresses.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map(addr => client.getValidatorInfo(addr as Address)), + ); + validatorInfos.push(...batchResults); + if (i + BATCH_SIZE < addresses.length) { + this.setSpinnerText(`Fetching details... ${Math.min(i + BATCH_SIZE, addresses.length)}/${addresses.length}`); + } + } + + return validatorInfos; + } + + private buildRow({ + info, + currentEpoch, + validatorMinStakeRaw, + isActive, + isQuarantined, + isBanned, + bannedInfo, + quarantinedInfo, + myAddress, + performance, + }: { + info: ValidatorInfo; + currentEpoch: bigint; + validatorMinStakeRaw: bigint; + isActive: boolean; + isQuarantined: boolean; + isBanned: boolean; + bannedInfo: any; + quarantinedInfo: any; + myAddress: Address | null; + performance?: ExplorerPerformance & {delegatorCount?: number}; + }): ValidatorRow { + let status: string; + let bannedUntilEpoch: string | undefined; + const belowMin = info.vStakeRaw < validatorMinStakeRaw; + const totalStakeRaw = info.vStakeRaw + info.dStakeRaw; + + if (isBanned) { + if (bannedInfo?.permanentlyBanned) { + status = "banned"; + bannedUntilEpoch = "permanent"; + } else { + const epoch = bannedInfo?.untilEpoch ?? info.bannedEpoch; + status = epoch !== undefined ? `banned(e${epoch})` : "banned"; + bannedUntilEpoch = epoch !== undefined ? epoch.toString() : undefined; + } + } else if (isQuarantined) { + const untilEpoch = quarantinedInfo?.untilEpoch; + status = untilEpoch !== undefined ? `quarantined(e${untilEpoch})` : "quarantined"; + } else if (belowMin && currentEpoch < ACTIVATION_DELAY_EPOCHS) { + status = "pending-activation"; + } else if (belowMin && currentEpoch >= ACTIVATION_DELAY_EPOCHS) { + status = "inactive/below-min"; + } else if (isActive) { + status = "active"; + } else { + status = info.live ? "pending" : "inactive"; + } + + const trulyPendingDeposits = info.pendingDeposits.filter(d => d.epoch + ACTIVATION_DELAY_EPOCHS > currentEpoch); + const trulyPendingWithdrawals = info.pendingWithdrawals.filter(w => w.epoch + UNBONDING_PERIOD_EPOCHS > currentEpoch); + + const isMine = myAddress + ? info.owner.toLowerCase() === myAddress.toLowerCase() || + info.operator.toLowerCase() === myAddress.toLowerCase() + : false; + + return { + address: info.address, + owner: info.owner, + operator: info.operator, + moniker: info.identity?.moniker || undefined, + active: isActive, + live: info.live, + banned: isBanned, + bannedUntilEpoch, + status, + belowMin, + selfStake: info.vStake, + selfStakeRaw: info.vStakeRaw, + delegatedStake: info.dStake, + delegatedStakeRaw: info.dStakeRaw, + totalStake: this.formatAmount(totalStakeRaw), + totalStakeRaw, + pendingDepositRaw: trulyPendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n), + pendingWithdrawalRaw: trulyPendingWithdrawals.reduce((sum, w) => sum + w.stakeRaw, 0n), + primedEpoch: info.ePrimed.toString(), + needsPriming: info.needsPriming, + delegatorCount: performance?.delegatorCount ?? null, + epochsActive: null, + isMine, + performance, + }; + } + + private sortRows(rows: ValidatorRow[], sortBy: string): ValidatorRow[] { + const normalized = this.normalizeSortBy(sortBy); + const sorted = [...rows]; + + if (normalized === "uptime") { + sorted.sort((a, b) => { + const au = a.performance?.uptimePct; + const bu = b.performance?.uptimePct; + if (au === undefined || au === null) return bu === undefined || bu === null ? this.compareStakeDescending(a, b) : 1; + if (bu === undefined || bu === null) return -1; + if (bu !== au) return bu - au; + return this.compareStakeDescending(a, b); + }); + return sorted; + } + + sorted.sort((a, b) => this.compareStakeDescending(a, b)); + return sorted; + } + + private compareStakeDescending(a: ValidatorRow, b: ValidatorRow): number { + if (a.totalStakeRaw > b.totalStakeRaw) return -1; + if (a.totalStakeRaw < b.totalStakeRaw) return 1; + return a.address.localeCompare(b.address); + } + + private normalizeSortBy(sortBy: string): "stake" | "uptime" { + return sortBy === "uptime" ? "uptime" : "stake"; + } + + private resolveExplorerNetwork(config: StakingConfig): GenLayerChain { + if (config.network) { + return resolveNetwork(config.network, this.getCustomNetworks()); + } + + return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); + } + + private getDefaultExplorerUrl(options: ValidatorsOptions): string | undefined { + const network = this.resolveExplorerNetwork(options); + if ((network as any).isStudio) { + return undefined; + } + + return network.blockExplorers?.default?.url; + } + + private async fetchExplorerData(explorerUrl: string, addresses: Address[]): Promise { + const validatorsResult = await this.fetchExplorerValidators(explorerUrl); + if (!validatorsResult) { + return null; + } + + await this.enrichDelegatorCounts(validatorsResult, addresses); + return validatorsResult; + } + + private async fetchExplorerValidators(explorerUrl: string): Promise { + for (const endpoint of this.getValidatorEndpointCandidates(explorerUrl)) { + try { + const validators = new Map(); + let page = 1; + let total = 0; + + do { + const url = new URL(endpoint); + url.searchParams.set("page", page.toString()); + url.searchParams.set("page_size", EXPLORER_PAGE_SIZE.toString()); + + const response = await this.fetchJson(url.toString()); + if (!response || !Array.isArray(response.validators)) { + throw new Error("Unexpected validators response"); + } + + total = typeof response.total === "number" ? response.total : response.validators.length; + + for (const item of response.validators as ExplorerValidatorSummary[]) { + const address = item.validator_address || item.validatorAddress; + if (!address) continue; + validators.set(address.toLowerCase(), this.toExplorerPerformance(item)); + } + + page += 1; + } while (validators.size < total && page <= 50); + + return {endpoint, validators}; + } catch { + // Try the next plausible deployment path; explorer enrichment is optional. + } + } + + return null; + } + + private async enrichDelegatorCounts(explorerData: ExplorerData, addresses: Address[]): Promise { + const apiBase = explorerData.endpoint.replace(/\/validators\/?$/, ""); + const BATCH_SIZE = 5; + + for (let i = 0; i < addresses.length; i += BATCH_SIZE) { + const batch = addresses.slice(i, i + BATCH_SIZE); + await Promise.all(batch.map(async address => { + try { + const response = await this.fetchJson(`${apiBase}/address/${address}`); + const validator = response?.validator as ExplorerAddressValidator | undefined; + if (!validator) return; + + const lower = address.toLowerCase(); + const existing = explorerData.validators.get(lower) || {}; + const detailPerformance = this.toExplorerPerformance(validator); + const delegatorCount = Array.isArray(validator.delegators) ? validator.delegators.length : existing.delegatorCount; + explorerData.validators.set(lower, this.mergeExplorerPerformance(existing, detailPerformance, delegatorCount)); + } catch { + // Delegator counts are enrichment-only and should never break chain output. + } + })); + } + } + + private mergeExplorerPerformance( + existing: ExplorerPerformance & {delegatorCount?: number}, + incoming: ExplorerPerformance, + delegatorCount?: number, + ): ExplorerPerformance & {delegatorCount?: number} { + return { + apy: incoming.apy ?? existing.apy, + uptimePct: incoming.uptimePct ?? existing.uptimePct, + idlePct7d: incoming.idlePct7d ?? existing.idlePct7d, + rotationPct7d: incoming.rotationPct7d ?? existing.rotationPct7d, + minorityPct7d: incoming.minorityPct7d ?? existing.minorityPct7d, + totalVotes7d: incoming.totalVotes7d ?? existing.totalVotes7d, + minorityVotes7d: incoming.minorityVotes7d ?? existing.minorityVotes7d, + successfulAppeals7d: incoming.successfulAppeals7d ?? existing.successfulAppeals7d, + transactionCount: incoming.transactionCount ?? existing.transactionCount, + delegatorCount, + }; + } + + private toExplorerPerformance(item: ExplorerValidatorSummary | ExplorerAddressValidator): ExplorerPerformance { + const idlePct7d = this.pickNumber((item as any).idle_pct_7d, (item as any).idlePct7d); + + return { + apy: (item as any).apy ?? undefined, + uptimePct: idlePct7d === undefined || idlePct7d === null ? null : Math.max(0, 100 - idlePct7d), + idlePct7d, + rotationPct7d: this.pickNumber((item as any).rotation_pct_7d, (item as any).rotationPct7d), + minorityPct7d: this.pickNumber((item as any).minority_pct_7d, (item as any).minorityPct7d), + totalVotes7d: this.pickNumber((item as any).total_votes_7d, (item as any).totalVotes7d), + minorityVotes7d: this.pickNumber((item as any).minority_votes_7d, (item as any).minorityVotes7d), + successfulAppeals7d: this.pickNumber((item as any).successful_appeals_7d, (item as any).successfulAppeals7d), + transactionCount: this.pickNumber((item as any).transaction_count, (item as any).transactionCount), + }; + } + + private pickNumber(...values: unknown[]): number | null | undefined { + for (const value of values) { + if (typeof value === "number") return value; + if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) { + return Number(value); + } + if (value === null) return null; + } + return undefined; + } + + private async fetchJson(url: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), EXPLORER_TIMEOUT_MS); + + try { + const response = await fetch(url, { + headers: {accept: "application/json"}, + signal: controller.signal, + }); + + if (!response.ok) { + return null; + } + + return await response.json(); + } finally { + clearTimeout(timeout); + } + } + + private getValidatorEndpointCandidates(explorerUrl: string): string[] { + const base = this.normalizeUrl(explorerUrl); + const url = new URL(base); + const path = url.pathname.replace(/\/+$/, ""); + const originWithPath = `${url.origin}${path}`; + const candidates = new Set(); + + if (path.endsWith("/api/v1")) { + candidates.add(`${originWithPath}/validators`); + } else if (path.endsWith("/api")) { + candidates.add(`${originWithPath}/v1/validators`); + } else { + candidates.add(`${originWithPath}/api/v1/validators`); + candidates.add(`${originWithPath}/explorer/api/v1/validators`); + candidates.add(`${originWithPath}/validators`); + } + + return [...candidates]; + } + + private normalizeUrl(url: string): string { + if (/^https?:\/\//i.test(url)) { + return url; + } + return `https://${url}`; + } + + private toJsonRow(row: ValidatorRow) { + return { + address: row.address, + owner: row.owner, + operator: row.operator, + moniker: row.moniker || null, + active: row.active, + live: row.live, + banned: row.banned, + bannedUntilEpoch: row.bannedUntilEpoch || null, + status: row.status, + below_min: row.belowMin, + stake: { + total: row.totalStake, + totalRaw: row.totalStakeRaw.toString(), + self: row.selfStake, + selfRaw: row.selfStakeRaw.toString(), + delegated: row.delegatedStake, + delegatedRaw: row.delegatedStakeRaw.toString(), + }, + delegatorCount: row.delegatorCount, + epochsActive: row.epochsActive, + primedEpoch: row.primedEpoch, + needsPriming: row.needsPriming, + pending: { + depositRaw: row.pendingDepositRaw.toString(), + withdrawalRaw: row.pendingWithdrawalRaw.toString(), + }, + performance: row.performance + ? { + apy: row.performance.apy ?? null, + uptimePct: row.performance.uptimePct ?? null, + idlePct7d: row.performance.idlePct7d ?? null, + rotationPct7d: row.performance.rotationPct7d ?? null, + minorityPct7d: row.performance.minorityPct7d ?? null, + totalVotes7d: row.performance.totalVotes7d ?? null, + minorityVotes7d: row.performance.minorityVotes7d ?? null, + successfulAppeals7d: row.performance.successfulAppeals7d ?? null, + transactionCount: row.performance.transactionCount ?? null, + } + : null, + }; + } + + private printTable(rows: ValidatorRow[], currentEpoch: bigint): void { + const table = new Table({ + head: [ + chalk.cyan("#"), + chalk.cyan("Validator"), + chalk.cyan("Total Stake"), + chalk.cyan("Self"), + chalk.cyan("Deleg Stake"), + chalk.cyan("Delegators"), + chalk.cyan("Active"), + chalk.cyan("Status"), + chalk.cyan("Uptime"), + chalk.cyan("Epochs"), + ], + style: {head: [], border: []}, + }); + + rows.forEach((row, idx) => { + table.push([ + (idx + 1).toString(), + this.formatValidatorCell(row), + this.formatCompactStake(row.totalStakeRaw), + this.formatCompactStake(row.selfStakeRaw), + this.formatCompactStake(row.delegatedStakeRaw), + row.delegatorCount === null ? "-" : row.delegatorCount.toString(), + row.active ? chalk.green("yes") : chalk.gray("no"), + this.colorStatus(row.status), + row.performance?.uptimePct === null || row.performance?.uptimePct === undefined + ? "-" + : `${row.performance.uptimePct.toFixed(1)}%`, + row.epochsActive === null ? "-" : row.epochsActive.toString(), + ]); + }); + + console.log(""); + console.log(chalk.gray(`Current epoch: ${currentEpoch}`)); + console.log(table.toString()); + console.log(""); + const activeCount = rows.filter(row => row.active).length; + console.log(chalk.gray(`Total: ${rows.length} validators (${activeCount} active)`)); + console.log(""); + } + + private formatValidatorCell(row: ValidatorRow): string { + let roleTag = ""; + if (row.isMine) { + roleTag = chalk.cyan(" [mine]"); + } + + const moniker = row.moniker && row.moniker.length > 20 + ? row.moniker.slice(0, 19) + "..." + : row.moniker; + + return moniker + ? `${moniker}${roleTag}\n${chalk.gray(row.address)}` + : `${chalk.gray(row.address)}${roleTag}`; + } + + private colorStatus(status: string): string { + if (status === "active") return chalk.green(status); + if (status.startsWith("banned")) return chalk.red(status); + if (status.startsWith("quarantined")) return chalk.yellow(status); + if (status === "inactive/below-min") return chalk.yellow(status); + if (status === "pending" || status === "pending-activation") return chalk.gray(status); + return status; + } + + private formatCompactStake(raw: bigint): string { + const value = Number(raw) / 1e18; + if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; + if (value >= 1000) return `${(value / 1000).toFixed(1)}K`; + if (value >= 1) return value.toFixed(1); + if (value > 0) return value.toPrecision(2); + return "0"; + } +} diff --git a/src/commands/staking/wizard.ts b/src/commands/staking/wizard.ts index 00452588..d7137b8a 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -1,4 +1,5 @@ import {StakingAction, StakingConfig, BUILT_IN_NETWORKS} from "./StakingAction"; +import {resolveNetwork} from "../../lib/actions/BaseAction"; import {CreateAccountAction} from "../account/create"; import {ExportAccountAction} from "../account/export"; import inquirer from "inquirer"; @@ -187,10 +188,7 @@ export class ValidatorWizardAction extends StakingAction { console.log("-------------------------\n"); if (options.network) { - const network = BUILT_IN_NETWORKS[options.network]; - if (!network) { - this.failSpinner(`Unknown network: ${options.network}`); - } + const network = resolveNetwork(options.network, this.getCustomNetworks()); state.networkAlias = options.network; this.writeConfig("network", options.network); console.log(`Using network: ${network.name}\n`); @@ -228,7 +226,7 @@ export class ValidatorWizardAction extends StakingAction { this.startSpinner("Checking balance and staking requirements..."); - const network = BUILT_IN_NETWORKS[state.networkAlias!]; + const network = resolveNetwork(state.networkAlias!, this.getCustomNetworks()); const client = createClient({ chain: network, account: state.accountAddress as Address, @@ -780,7 +778,7 @@ export class ValidatorWizardAction extends StakingAction { } console.log(` Staked Amount: ${state.stakeAmount}`); - console.log(` Network: ${BUILT_IN_NETWORKS[state.networkAlias].name}`); + console.log(` Network: ${resolveNetwork(state.networkAlias, this.getCustomNetworks()).name}`); if (state.identity) { console.log(` Identity:`); diff --git a/src/commands/vesting/VestingAction.ts b/src/commands/vesting/VestingAction.ts new file mode 100644 index 00000000..8e1627d0 --- /dev/null +++ b/src/commands/vesting/VestingAction.ts @@ -0,0 +1,155 @@ +import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; +import {createClient, createAccount, formatStakingAmount, parseStakingAmount} from "genlayer-js"; +import type {Address, GenLayerChain} from "genlayer-js/types"; +import {existsSync, readFileSync} from "fs"; +import {ethers} from "ethers"; +import type {VestingClient, VestingFactoryLookupOptions} from "./vestingTypes"; + +export {BUILT_IN_NETWORKS}; + +export interface VestingConfig { + rpc?: string; + network?: string; + account?: string; + password?: string; + vesting?: string; + factory?: string; + addressManager?: string; +} + +export class VestingAction extends BaseAction { + private _vestingClient: VestingClient | null = null; + private _passwordOverride: string | undefined; + + constructor() { + super(); + } + + private getNetwork(config: VestingConfig): GenLayerChain { + if (config.network) { + return {...resolveNetwork(config.network, this.getCustomNetworks())}; + } + + return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); + } + + protected async getVestingClient(config: VestingConfig): Promise { + if (!this._vestingClient) { + if (config.account) { + this.accountOverride = config.account; + } + if (config.password) { + this._passwordOverride = config.password; + } + + const network = this.getNetwork(config); + const privateKey = await this.getPrivateKeyForVesting(); + const account = createAccount(privateKey as `0x${string}`); + + this._vestingClient = createClient({ + chain: network, + endpoint: config.rpc, + account, + }) as VestingClient; + } + return this._vestingClient; + } + + protected async getReadOnlyVestingClient(config: VestingConfig): Promise { + if (config.account) { + this.accountOverride = config.account; + } + + const network = this.getNetwork(config); + + return createClient({ + chain: network, + endpoint: config.rpc, + }) as VestingClient; + } + + private async getPrivateKeyForVesting(): Promise { + const accountName = this.resolveAccountName(); + const keystorePath = this.getKeystorePath(accountName); + + if (!existsSync(keystorePath)) { + throw new Error(`Account '${accountName}' not found. Run 'genlayer account create --name ${accountName}' first.`); + } + + const keystoreJson = readFileSync(keystorePath, "utf-8"); + const keystoreData = JSON.parse(keystoreJson); + + if (!this.isValidKeystoreFormat(keystoreData)) { + throw new Error("Invalid keystore format."); + } + + const cachedKey = await this.keychainManager.getPrivateKey(accountName); + if (cachedKey) { + const tempAccount = createAccount(cachedKey as `0x${string}`); + const cachedAddress = tempAccount.address.toLowerCase(); + const keystoreAddress = `0x${keystoreData.address.toLowerCase().replace(/^0x/, '')}`; + + if (cachedAddress !== keystoreAddress) { + await this.keychainManager.removePrivateKey(accountName); + } else { + return cachedKey; + } + } + + let password: string; + if (this._passwordOverride) { + password = this._passwordOverride; + } else { + this.stopSpinner(); + password = await this.promptPassword(`Enter password to unlock account '${accountName}':`); + } + this.startSpinner("Unlocking account..."); + + const wallet = await ethers.Wallet.fromEncryptedJson(keystoreJson, password); + return wallet.privateKey; + } + + protected parseAmount(amount: string): bigint { + return parseStakingAmount(amount); + } + + protected formatAmount(amount: bigint): string { + return formatStakingAmount(amount); + } + + protected async getSignerAddress(): Promise
{ + const accountName = this.resolveAccountName(); + const keystorePath = this.getKeystorePath(accountName); + if (!existsSync(keystorePath)) { + throw new Error(`Account '${accountName}' not found.`); + } + const keystoreData = JSON.parse(readFileSync(keystorePath, "utf-8")); + const addr = keystoreData.address as string; + return (addr.startsWith("0x") ? addr : `0x${addr}`) as Address; + } + + protected getFactoryLookupOptions(options: VestingConfig): VestingFactoryLookupOptions | undefined { + const lookup: VestingFactoryLookupOptions = {}; + if (options.factory) lookup.factory = options.factory as Address; + if (options.addressManager) lookup.addressManager = options.addressManager as Address; + return Object.keys(lookup).length > 0 ? lookup : undefined; + } + + protected async resolveBeneficiaryVesting(client: VestingClient, options: VestingConfig): Promise
{ + if (options.vesting) { + return options.vesting as Address; + } + + const beneficiary = await this.getSignerAddress(); + const vestings = await client.getBeneficiaryVestings(beneficiary, this.getFactoryLookupOptions(options)); + + if (vestings.length === 0) { + throw new Error(`No vesting contract found for beneficiary ${beneficiary}.`); + } + if (vestings.length > 1) { + throw new Error(`Multiple vesting contracts found for beneficiary ${beneficiary}. Use --vesting
.`); + } + + return vestings[0]; + } +} diff --git a/src/commands/vesting/claim.ts b/src/commands/vesting/claim.ts new file mode 100644 index 00000000..13481e81 --- /dev/null +++ b/src/commands/vesting/claim.ts @@ -0,0 +1,40 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingClaimOptions extends VestingConfig { + validator: string; +} + +export class VestingClaimAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingClaimOptions): Promise { + this.startSpinner("Claiming vesting delegation withdrawal..."); + + try { + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Claiming vesting delegation withdrawal from validator ${options.validator}...`); + + const result = await client.vestingDelegatorClaim({ + vesting, + validator: options.validator as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + validator: options.validator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting claim successful!", output); + } catch (error: any) { + this.failSpinner("Failed to claim vesting withdrawal", error.message || error); + } + } +} diff --git a/src/commands/vesting/delegate.ts b/src/commands/vesting/delegate.ts new file mode 100644 index 00000000..e6c24720 --- /dev/null +++ b/src/commands/vesting/delegate.ts @@ -0,0 +1,45 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingDelegateOptions extends VestingConfig { + validator: string; + amount: string; +} + +export class VestingDelegateAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingDelegateOptions): Promise { + this.startSpinner("Delegating vesting tokens..."); + + try { + const client = await this.getVestingClient(options); + const amount = this.parseAmount(options.amount); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Delegating ${this.formatAmount(amount)} from vesting ${vesting} to validator ${options.validator}...`); + + const result = await client.vestingDelegatorJoin({ + vesting, + validator: options.validator as Address, + amount, + }); + + const output = { + transactionHash: result.transactionHash, + vesting: result.vesting, + validator: result.validator, + beneficiary: result.beneficiary, + amount: result.amount, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting delegation successful!", output); + } catch (error: any) { + this.failSpinner("Failed to delegate vesting tokens", error.message || error); + } + } +} diff --git a/src/commands/vesting/index.ts b/src/commands/vesting/index.ts new file mode 100644 index 00000000..c73ef39f --- /dev/null +++ b/src/commands/vesting/index.ts @@ -0,0 +1,280 @@ +import {Command} from "commander"; +import {VestingListAction, VestingListOptions} from "./list"; +import {VestingDelegateAction, VestingDelegateOptions} from "./delegate"; +import {VestingUndelegateAction, VestingUndelegateOptions} from "./undelegate"; +import {VestingClaimAction, VestingClaimOptions} from "./claim"; +import {VestingWithdrawAction, VestingWithdrawOptions} from "./withdraw"; +import {VestingValidatorCreateAction, VestingValidatorCreateOptions} from "./validatorCreate"; +import {VestingValidatorDepositAction, VestingValidatorDepositOptions} from "./validatorDeposit"; +import {VestingValidatorExitAction, VestingValidatorExitOptions} from "./validatorExit"; +import {VestingValidatorClaimAction, VestingValidatorClaimOptions} from "./validatorClaim"; +import { + VestingValidatorCancelOperatorTransferAction, + VestingValidatorCompleteOperatorTransferAction, + VestingValidatorInitiateOperatorTransferAction, + VestingValidatorOperatorTransferOptions, +} from "./validatorOperatorTransfer"; +import {VestingValidatorSetIdentityAction, VestingValidatorSetIdentityOptions} from "./validatorSetIdentity"; +import {VestingValidatorListAction, VestingValidatorListOptions} from "./validatorList"; + +function addReadOptions(command: Command): Command { + return command + .option("--account ", "Account to use (for default beneficiary address)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--factory
", "VestingFactory address (overrides AddressManager lookup)") + .option("--address-manager
", "AddressManager address (overrides consensus lookup)"); +} + +function addWriteOptions(command: Command): Command { + return command + .option("--vesting
", "Vesting contract address (overrides beneficiary lookup)") + .option("--account ", "Account to use") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--factory
", "VestingFactory address (overrides AddressManager lookup)") + .option("--address-manager
", "AddressManager address (overrides consensus lookup)"); +} + +function addValidatorReadOptions(command: Command): Command { + return addReadOptions( + command + .option("--vesting
", "Vesting contract address (overrides beneficiary lookup)") + .option("--beneficiary
", "Beneficiary address (defaults to signer)"), + ); +} + +function addWalletOption(command: Command): Command { + return command.option("--wallet
", "Validator wallet address (deprecated, use positional arg)"); +} + +function requireWallet(walletArg: string | undefined, options: {wallet?: string}): string { + const wallet = walletArg || options.wallet; + if (!wallet) { + console.error("Error: validator wallet address is required"); + process.exit(1); + } + return wallet; +} + +function requireOperator(operatorArg: string | undefined, options: {operator?: string}): string { + const operator = operatorArg || options.operator; + if (!operator) { + console.error("Error: operator address is required"); + process.exit(1); + } + return operator; +} + +export function initializeVestingCommands(program: Command) { + const vesting = program.command("vesting").description("Vesting operations for beneficiaries"); + + addReadOptions( + vesting + .command("list") + .description("List beneficiary vesting contracts and state") + .option("--beneficiary
", "Beneficiary address (defaults to signer)"), + ).action(async (options: VestingListOptions) => { + const action = new VestingListAction(); + await action.execute(options); + }); + + addWriteOptions( + vesting + .command("delegate [validator]") + .description("Delegate vesting-held tokens to a validator") + .option("--validator
", "Validator address to delegate to (deprecated, use positional arg)") + .requiredOption("--amount ", "Amount to delegate (in wei or with 'eth'/'gen' suffix)"), + ).action(async (validatorArg: string | undefined, options: VestingDelegateOptions) => { + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new VestingDelegateAction(); + await action.execute({...options, validator}); + }); + + addWriteOptions( + vesting + .command("undelegate [validator]") + .description("Undelegate all vesting delegation shares from a validator") + .option("--validator
", "Validator address to undelegate from (deprecated, use positional arg)"), + ).action(async (validatorArg: string | undefined, options: VestingUndelegateOptions) => { + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new VestingUndelegateAction(); + await action.execute({...options, validator}); + }); + + addWriteOptions( + vesting + .command("claim [validator]") + .description("Claim vesting delegation withdrawals after unbonding period") + .option("--validator
", "Validator address to claim from (deprecated, use positional arg)"), + ).action(async (validatorArg: string | undefined, options: VestingClaimOptions) => { + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new VestingClaimAction(); + await action.execute({...options, validator}); + }); + + addWriteOptions( + vesting + .command("withdraw") + .description("Withdraw vested tokens to the beneficiary") + .requiredOption("--amount ", "Amount to withdraw (in wei or with 'eth'/'gen' suffix)"), + ).action(async (options: VestingWithdrawOptions) => { + const action = new VestingWithdrawAction(); + await action.execute(options); + }); + + const validator = vesting.command("validator").description("Vesting-backed validator operations"); + + const addCreateCommand = (name: string) => { + addWriteOptions( + validator + .command(`${name} [operator]`) + .description("Create a vesting-backed validator") + .option("--operator
", "Operator address (deprecated, use positional arg)") + .requiredOption("--amount ", "Amount to self-stake (in wei or with 'eth'/'gen' suffix)"), + ).action(async (operatorArg: string | undefined, options: VestingValidatorCreateOptions) => { + const operator = requireOperator(operatorArg, options); + const action = new VestingValidatorCreateAction(); + await action.execute({...options, operator}); + }); + }; + + addCreateCommand("create"); + addCreateCommand("join"); + + addWriteOptions( + addWalletOption( + validator + .command("deposit [wallet]") + .description("Deposit more vesting-held tokens to a validator wallet") + .requiredOption("--amount ", "Amount to deposit (in wei or with 'eth'/'gen' suffix)"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorDepositOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorDepositAction(); + await action.execute({...options, wallet}); + }); + + addWriteOptions( + addWalletOption( + validator + .command("exit [wallet]") + .description("Exit vesting validator self-stake by withdrawing shares") + .requiredOption("--shares ", "Number of shares to withdraw"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorExitOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorExitAction(); + await action.execute({...options, wallet}); + }); + + addWriteOptions( + addWalletOption( + validator + .command("claim [wallet]") + .description("Claim vesting validator withdrawals after unbonding period"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorClaimOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorClaimAction(); + await action.execute({...options, wallet}); + }); + + const operatorTransfer = validator.command("operator-transfer").description("Manage vesting validator operator transfers"); + + addWriteOptions( + addWalletOption( + operatorTransfer + .command("initiate [wallet] [newOperator]") + .description("Initiate a vesting validator operator transfer") + .option("--new-operator
", "New operator address (deprecated, use positional arg)"), + ), + ).action(async (walletArg: string | undefined, newOperatorArg: string | undefined, options: VestingValidatorOperatorTransferOptions) => { + const wallet = requireWallet(walletArg, options); + const newOperator = newOperatorArg || options.newOperator; + if (!newOperator) { + console.error("Error: new operator address is required"); + process.exit(1); + } + const action = new VestingValidatorInitiateOperatorTransferAction(); + await action.execute({...options, wallet, newOperator}); + }); + + addWriteOptions( + addWalletOption( + operatorTransfer + .command("complete [wallet]") + .description("Complete a vesting validator operator transfer"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorOperatorTransferOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorCompleteOperatorTransferAction(); + await action.execute({...options, wallet}); + }); + + addWriteOptions( + addWalletOption( + operatorTransfer + .command("cancel [wallet]") + .description("Cancel a vesting validator operator transfer"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorOperatorTransferOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorCancelOperatorTransferAction(); + await action.execute({...options, wallet}); + }); + + addWriteOptions( + addWalletOption( + validator + .command("set-identity [wallet]") + .description("Set vesting validator identity metadata") + .option("--moniker ", "Validator display name") + .option("--logo-uri ", "Logo URI") + .option("--website ", "Website URL") + .option("--description ", "Description") + .option("--email ", "Contact email") + .option("--twitter ", "Twitter handle") + .option("--telegram ", "Telegram handle") + .option("--github ", "GitHub handle") + .option("--extra-cid ", "Extra data as IPFS CID or hex bytes (0x...)"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorSetIdentityOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorSetIdentityAction(); + await action.execute({...options, wallet}); + }); + + addValidatorReadOptions( + validator + .command("list") + .description("List validator wallets owned by a vesting contract"), + ).action(async (options: VestingValidatorListOptions) => { + const action = new VestingValidatorListAction(); + await action.execute(options); + }); + + addValidatorReadOptions( + validator + .command("status") + .description("Show validator wallets owned by a vesting contract"), + ).action(async (options: VestingValidatorListOptions) => { + const action = new VestingValidatorListAction(); + await action.execute(options); + }); + + return program; +} diff --git a/src/commands/vesting/list.ts b/src/commands/vesting/list.ts new file mode 100644 index 00000000..ad1a69c1 --- /dev/null +++ b/src/commands/vesting/list.ts @@ -0,0 +1,157 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; +import type {VestingState} from "./vestingTypes"; +import Table from "cli-table3"; +import chalk from "chalk"; + +export interface VestingListOptions extends VestingConfig { + beneficiary?: string; +} + +const CATEGORY_LABELS: Record = { + 0: "Unspecified", + 1: "Team", + 2: "Advisor", + 3: "Investor", + 4: "Foundation", + 5: "Ecosystem", + 6: "Other", +}; + +function formatTimestamp(value: bigint): string { + if (value === 0n) return "not set"; + return new Date(Number(value) * 1000).toISOString(); +} + +function formatDuration(seconds: bigint): string { + if (seconds === 0n) return "0s"; + + let remaining = Number(seconds); + const days = Math.floor(remaining / 86400); + remaining %= 86400; + const hours = Math.floor(remaining / 3600); + remaining %= 3600; + const minutes = Math.floor(remaining / 60); + const secs = remaining % 60; + + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + if (minutes) parts.push(`${minutes}m`); + if (secs || parts.length === 0) parts.push(`${secs}s`); + return parts.join(" "); +} + +function formatBps(value: bigint): string { + return `${(Number(value) / 100).toFixed(2).replace(/\.00$/, "")}%`; +} + +function formatManualUnlock(state: VestingState): string { + if (!state.needsManualUnlock) return "manual: no"; + return `manual: ${state.manualUnlocked ? "unlocked" : "required"}`; +} + +function formatSchedule(state: VestingState): string { + return [ + `start: ${formatTimestamp(state.startDate)}`, + `cliff: ${formatDuration(state.cliffDuration)}`, + `period: ${formatDuration(state.periodDuration)} x ${state.numberOfPeriods.toString()}`, + `cliff unlock: ${formatBps(state.cliffUnlockBps)}`, + formatManualUnlock(state), + ].join("\n"); +} + +function formatRevocation(state: VestingState): string { + if (state.revoked) { + return [ + `revoked: yes`, + `at: ${formatTimestamp(state.revokedAt)}`, + `vested: ${state.vestedAtRevocation}`, + `total: ${state.totalAmountAtRevocation}`, + ].join("\n"); + } + + if (state.vestingStopped) { + return [ + `revoked: no`, + `stopped: yes`, + `at: ${formatTimestamp(state.vestingStoppedAt)}`, + `vested: ${state.vestedAtStop}`, + ].join("\n"); + } + + return ["revoked: no", "stopped: no"].join("\n"); +} + +export class VestingListAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingListOptions): Promise { + this.startSpinner("Fetching vesting contracts..."); + + try { + const client = await this.getReadOnlyVestingClient(options); + const beneficiary = (options.beneficiary as Address | undefined) || (await this.getSignerAddress()); + + this.setSpinnerText(`Fetching vesting contracts for ${beneficiary}...`); + + const vestings = await client.getBeneficiaryVestings(beneficiary, this.getFactoryLookupOptions(options)); + + if (vestings.length === 0) { + this.succeedSpinner("No vesting contracts found", {beneficiary, count: 0}); + return; + } + + this.setSpinnerText(`Fetching state for ${vestings.length} vesting contract${vestings.length === 1 ? "" : "s"}...`); + const states = await Promise.all( + vestings.map(async (vesting) => ({ + vesting, + state: await client.getVestingState(vesting), + })), + ); + + this.stopSpinner(); + + const table = new Table({ + head: [ + chalk.cyan("Contract"), + chalk.cyan("Name"), + chalk.cyan("Category"), + chalk.cyan("Total"), + chalk.cyan("Vested"), + chalk.cyan("Locked"), + chalk.cyan("Withdrawable"), + chalk.cyan("Schedule"), + chalk.cyan("Revocation"), + ], + style: {head: [], border: []}, + wordWrap: true, + }); + + states.forEach(({vesting, state}) => { + table.push([ + vesting, + state.name || "-", + CATEGORY_LABELS[state.category] || state.category.toString(), + state.totalAmount, + state.vestedAmount, + state.unvestedAmount, + state.withdrawableAmount, + formatSchedule(state), + formatRevocation(state), + ]); + }); + + console.log(""); + console.log(table.toString()); + console.log(""); + console.log(chalk.gray(`Beneficiary: ${beneficiary}`)); + console.log(chalk.gray(`Total: ${states.length} vesting contract${states.length === 1 ? "" : "s"}`)); + console.log(""); + } catch (error: any) { + this.failSpinner("Failed to list vesting contracts", error.message || error); + } + } +} diff --git a/src/commands/vesting/undelegate.ts b/src/commands/vesting/undelegate.ts new file mode 100644 index 00000000..bc232142 --- /dev/null +++ b/src/commands/vesting/undelegate.ts @@ -0,0 +1,53 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingUndelegateOptions extends VestingConfig { + validator: string; +} + +export class VestingUndelegateAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingUndelegateOptions): Promise { + this.startSpinner("Initiating vesting undelegation..."); + + try { + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Fetching vesting delegation shares for validator ${options.validator}...`); + const stakeInfo = await client.getStakeInfo(vesting, options.validator as Address); + const shares = stakeInfo.shares; + + if (shares <= 0n) { + this.failSpinner(`No delegation shares found for vesting ${vesting} with validator ${options.validator}.`); + return; + } + + this.setSpinnerText(`Undelegating ${shares.toString()} shares from validator ${options.validator}...`); + + const result = await client.vestingDelegatorExit({ + vesting, + validator: options.validator as Address, + shares, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + validator: options.validator, + sharesWithdrawn: shares.toString(), + stake: stakeInfo.stake, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + note: "Withdrawal will be claimable after the unbonding period", + }; + + this.succeedSpinner("Vesting undelegation initiated!", output); + } catch (error: any) { + this.failSpinner("Failed to undelegate vesting tokens", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorClaim.ts b/src/commands/vesting/validatorClaim.ts new file mode 100644 index 00000000..c6d33490 --- /dev/null +++ b/src/commands/vesting/validatorClaim.ts @@ -0,0 +1,40 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorClaimOptions extends VestingConfig { + wallet: string; +} + +export class VestingValidatorClaimAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorClaimOptions): Promise { + this.startSpinner("Claiming vesting validator withdrawal..."); + + try { + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Claiming vesting validator withdrawal from wallet ${options.wallet}...`); + + const result = await client.vestingValidatorClaim({ + vesting, + wallet: options.wallet as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting validator claim successful!", output); + } catch (error: any) { + this.failSpinner("Failed to claim vesting validator withdrawal", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorCreate.ts b/src/commands/vesting/validatorCreate.ts new file mode 100644 index 00000000..0b76aaf0 --- /dev/null +++ b/src/commands/vesting/validatorCreate.ts @@ -0,0 +1,57 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorCreateOptions extends VestingConfig { + operator: string; + amount: string; +} + +export class VestingValidatorCreateAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorCreateOptions): Promise { + this.startSpinner("Creating vesting-backed validator..."); + + try { + const client = await this.getVestingClient(options); + const amount = this.parseAmount(options.amount); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} from vesting ${vesting}...`); + + const result = await client.vestingValidatorJoin({ + vesting, + operator: options.operator as Address, + amount, + }); + + // The join receipt does not carry the wallet address; the vesting + // contract tracks its wallets, so the newest entry is the one created. + let validatorWallet = result.validatorWallet || result.wallet; + if (!validatorWallet) { + try { + const wallets = await client.getValidatorWallets(vesting); + validatorWallet = wallets[wallets.length - 1]; + } catch { + validatorWallet = "(read getValidatorWallets to inspect)"; + } + } + + const output = { + transactionHash: result.transactionHash, + vesting, + validatorWallet, + operator: result.operator || options.operator, + amount: result.amount || this.formatAmount(amount), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting-backed validator created!", output); + } catch (error: any) { + this.failSpinner("Failed to create vesting-backed validator", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorDeposit.ts b/src/commands/vesting/validatorDeposit.ts new file mode 100644 index 00000000..f6a0b802 --- /dev/null +++ b/src/commands/vesting/validatorDeposit.ts @@ -0,0 +1,44 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorDepositOptions extends VestingConfig { + wallet: string; + amount: string; +} + +export class VestingValidatorDepositAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorDepositOptions): Promise { + this.startSpinner("Depositing vesting tokens to validator..."); + + try { + const client = await this.getVestingClient(options); + const amount = this.parseAmount(options.amount); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Depositing ${this.formatAmount(amount)} from vesting ${vesting} to wallet ${options.wallet}...`); + + const result = await client.vestingValidatorDeposit({ + vesting, + wallet: options.wallet as Address, + amount, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + amount: this.formatAmount(amount), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting validator deposit successful!", output); + } catch (error: any) { + this.failSpinner("Failed to deposit vesting validator tokens", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorExit.ts b/src/commands/vesting/validatorExit.ts new file mode 100644 index 00000000..18e53363 --- /dev/null +++ b/src/commands/vesting/validatorExit.ts @@ -0,0 +1,53 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorExitOptions extends VestingConfig { + wallet: string; + shares: string; +} + +export class VestingValidatorExitAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorExitOptions): Promise { + this.startSpinner("Initiating vesting validator exit..."); + + try { + let shares: bigint; + try { + shares = BigInt(options.shares); + if (shares <= 0n) throw new Error("must be positive"); + } catch { + this.failSpinner(`Invalid shares value: "${options.shares}". Must be a positive whole number.`); + return; + } + + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Exiting ${shares.toString()} validator shares from wallet ${options.wallet}...`); + + const result = await client.vestingValidatorExit({ + vesting, + wallet: options.wallet as Address, + shares, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + sharesWithdrawn: shares.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + note: "Withdrawal will be claimable after the unbonding period unless settled immediately in epoch 0", + }; + + this.succeedSpinner("Vesting validator exit initiated!", output); + } catch (error: any) { + this.failSpinner("Failed to exit vesting validator", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorList.ts b/src/commands/vesting/validatorList.ts new file mode 100644 index 00000000..ca94d206 --- /dev/null +++ b/src/commands/vesting/validatorList.ts @@ -0,0 +1,93 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; +import Table from "cli-table3"; +import chalk from "chalk"; + +export interface VestingValidatorListOptions extends VestingConfig { + beneficiary?: string; +} + +interface WalletState { + wallet: Address; + deposited: bigint | string; +} + +export class VestingValidatorListAction extends VestingAction { + constructor() { + super(); + } + + private formatDeposited(value: bigint | string): string { + return typeof value === "bigint" ? this.formatAmount(value) : value; + } + + async execute(options: VestingValidatorListOptions): Promise { + this.startSpinner("Fetching vesting validator wallets..."); + + try { + const client = await this.getReadOnlyVestingClient(options); + let vesting: Address; + + if (options.vesting) { + vesting = options.vesting as Address; + } else { + const beneficiary = (options.beneficiary as Address | undefined) || (await this.getSignerAddress()); + this.setSpinnerText(`Resolving vesting contract for ${beneficiary}...`); + const vestings = await client.getBeneficiaryVestings(beneficiary, this.getFactoryLookupOptions(options)); + + if (vestings.length === 0) { + this.succeedSpinner("No vesting contracts found", {beneficiary, count: 0}); + return; + } + if (vestings.length > 1) { + throw new Error(`Multiple vesting contracts found for beneficiary ${beneficiary}. Use --vesting
.`); + } + vesting = vestings[0]; + } + + this.setSpinnerText(`Fetching validator wallets for vesting ${vesting}...`); + const wallets = await client.getValidatorWallets(vesting); + + if (wallets.length === 0) { + this.succeedSpinner("No vesting validator wallets found", {vesting, count: 0}); + return; + } + + const states: WalletState[] = await Promise.all( + wallets.map(async (wallet) => ({ + wallet, + deposited: await client.validatorDeposited(vesting, wallet), + })), + ); + + this.stopSpinner(); + + const table = new Table({ + head: [ + chalk.cyan("Vesting"), + chalk.cyan("Validator Wallet"), + chalk.cyan("Deposited"), + ], + style: {head: [], border: []}, + wordWrap: true, + }); + + states.forEach(({wallet, deposited}) => { + table.push([ + vesting, + wallet, + this.formatDeposited(deposited), + ]); + }); + + console.log(""); + console.log(table.toString()); + console.log(""); + console.log(chalk.gray(`Vesting: ${vesting}`)); + console.log(chalk.gray(`Total: ${states.length} validator wallet${states.length === 1 ? "" : "s"}`)); + console.log(""); + } catch (error: any) { + this.failSpinner("Failed to list vesting validator wallets", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorOperatorTransfer.ts b/src/commands/vesting/validatorOperatorTransfer.ts new file mode 100644 index 00000000..51b026e4 --- /dev/null +++ b/src/commands/vesting/validatorOperatorTransfer.ts @@ -0,0 +1,116 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorOperatorTransferOptions extends VestingConfig { + wallet: string; + newOperator?: string; +} + +export class VestingValidatorInitiateOperatorTransferAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorOperatorTransferOptions): Promise { + this.startSpinner("Initiating vesting validator operator transfer..."); + + try { + if (!options.newOperator) { + this.failSpinner("New operator address is required."); + return; + } + + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Initiating operator transfer for wallet ${options.wallet} to ${options.newOperator}...`); + + const result = await client.vestingValidatorInitiateOperatorTransfer({ + vesting, + wallet: options.wallet as Address, + newOperator: options.newOperator as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + newOperator: options.newOperator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting validator operator transfer initiated!", output); + } catch (error: any) { + this.failSpinner("Failed to initiate vesting validator operator transfer", error.message || error); + } + } +} + +export class VestingValidatorCompleteOperatorTransferAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorOperatorTransferOptions): Promise { + this.startSpinner("Completing vesting validator operator transfer..."); + + try { + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Completing operator transfer for wallet ${options.wallet}...`); + + const result = await client.vestingValidatorCompleteOperatorTransfer({ + vesting, + wallet: options.wallet as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting validator operator transfer completed!", output); + } catch (error: any) { + this.failSpinner("Failed to complete vesting validator operator transfer", error.message || error); + } + } +} + +export class VestingValidatorCancelOperatorTransferAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorOperatorTransferOptions): Promise { + this.startSpinner("Cancelling vesting validator operator transfer..."); + + try { + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Cancelling operator transfer for wallet ${options.wallet}...`); + + const result = await client.vestingValidatorCancelOperatorTransfer({ + vesting, + wallet: options.wallet as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting validator operator transfer cancelled!", output); + } catch (error: any) { + this.failSpinner("Failed to cancel vesting validator operator transfer", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorSetIdentity.ts b/src/commands/vesting/validatorSetIdentity.ts new file mode 100644 index 00000000..368e7e5b --- /dev/null +++ b/src/commands/vesting/validatorSetIdentity.ts @@ -0,0 +1,70 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; +import {toHex} from "viem"; + +export interface VestingValidatorSetIdentityOptions extends VestingConfig { + wallet: string; + moniker?: string; + logoUri?: string; + website?: string; + description?: string; + email?: string; + twitter?: string; + telegram?: string; + github?: string; + extraCid?: string; +} + +export class VestingValidatorSetIdentityAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorSetIdentityOptions): Promise { + this.startSpinner("Setting vesting validator identity..."); + + try { + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + const extraCid = options.extraCid ? toHex(new TextEncoder().encode(options.extraCid)) : "0x"; + + this.setSpinnerText(`Setting identity for vesting validator wallet ${options.wallet}...`); + + const result = await client.vestingValidatorSetIdentity({ + vesting, + wallet: options.wallet as Address, + moniker: options.moniker || "", + logoUri: options.logoUri || "", + website: options.website || "", + description: options.description || "", + email: options.email || "", + twitter: options.twitter || "", + telegram: options.telegram || "", + github: options.github || "", + extraCid, + }); + + const output: Record = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + if (options.moniker) output.moniker = options.moniker; + if (options.logoUri) output.logoUri = options.logoUri; + if (options.website) output.website = options.website; + if (options.description) output.description = options.description; + if (options.email) output.email = options.email; + if (options.twitter) output.twitter = options.twitter; + if (options.telegram) output.telegram = options.telegram; + if (options.github) output.github = options.github; + if (options.extraCid) output.extraCid = options.extraCid; + + this.succeedSpinner("Vesting validator identity set!", output); + } catch (error: any) { + this.failSpinner("Failed to set vesting validator identity", error.message || error); + } + } +} diff --git a/src/commands/vesting/vestingTypes.ts b/src/commands/vesting/vestingTypes.ts new file mode 100644 index 00000000..aeaf76c8 --- /dev/null +++ b/src/commands/vesting/vestingTypes.ts @@ -0,0 +1,156 @@ +import type {Address, GenLayerChain, GenLayerClient} from "genlayer-js/types"; + +// LOCKSTEP(genlayer-js#feat/vesting-actions): local CLI-facing type shim until +// genlayer-js#v2-dev publishes VestingActions and VestingState. +export interface VestingTransactionResult { + transactionHash: `0x${string}`; + blockNumber: bigint; + gasUsed: bigint; +} + +export interface VestingDelegatorJoinResult extends VestingTransactionResult { + vesting: Address; + validator: Address; + beneficiary: Address; + amount: string; + amountRaw: bigint; +} + +export interface VestingWithdrawResult extends VestingTransactionResult { + vesting: Address; + beneficiary: Address; + amount: string; + amountRaw: bigint; +} + +export interface VestingValidatorJoinResult extends VestingTransactionResult { + vesting?: Address; + validatorWallet?: Address; + wallet?: Address; + operator?: Address; + beneficiary?: Address; + amount?: string; + amountRaw?: bigint; +} + +export interface VestingFactoryLookupOptions { + factory?: Address; + addressManager?: Address; +} + +export interface VestingState { + name: string; + category: number; + beneficiary: Address; + creator: Address; + revoker: Address; + factory: Address; + addressManager: Address; + totalAmount: string; + totalAmountRaw: bigint; + startDate: bigint; + cliffDuration: bigint; + periodDuration: bigint; + numberOfPeriods: bigint; + cliffUnlockBps: bigint; + needsManualUnlock: boolean; + manualUnlocked: boolean; + revoked: boolean; + vestingStopped: boolean; + totalWithdrawn: string; + totalWithdrawnRaw: bigint; + vestedAtRevocation: string; + vestedAtRevocationRaw: bigint; + totalAmountAtRevocation: string; + totalAmountAtRevocationRaw: bigint; + revokedAt: bigint; + vestingStoppedAt: bigint; + vestedAtStop: string; + vestedAtStopRaw: bigint; + postRevocationBeneficiaryRewards: string; + postRevocationBeneficiaryRewardsRaw: bigint; + postRevocationBeneficiaryLosses: string; + postRevocationBeneficiaryLossesRaw: bigint; + accumulatedRewards: string; + accumulatedRewardsRaw: bigint; + accumulatedLosses: string; + accumulatedLossesRaw: bigint; + vestedAmount: string; + vestedAmountRaw: bigint; + unvestedAmount: string; + unvestedAmountRaw: bigint; + withdrawableAmount: string; + withdrawableAmountRaw: bigint; +} + +export type VestingClient = GenLayerClient & { + getBeneficiaryVestings: (beneficiary: Address, options?: VestingFactoryLookupOptions) => Promise; + getVestingState: (vesting: Address) => Promise; + vestingDelegatorJoin: (options: { + vesting: Address; + validator: Address; + amount: bigint | string; + }) => Promise; + vestingDelegatorExit: (options: { + vesting: Address; + validator: Address; + shares: bigint | string; + }) => Promise; + vestingDelegatorClaim: (options: { + vesting: Address; + validator: Address; + }) => Promise; + vestingWithdraw: (options: { + vesting: Address; + amount: bigint | string; + }) => Promise; + vestingValidatorJoin: (options: { + vesting: Address; + operator: Address; + amount: bigint | string; + }) => Promise; + vestingValidatorDeposit: (options: { + vesting: Address; + wallet: Address; + amount: bigint | string; + }) => Promise; + vestingValidatorExit: (options: { + vesting: Address; + wallet: Address; + shares: bigint | string; + }) => Promise; + vestingValidatorClaim: (options: { + vesting: Address; + wallet: Address; + }) => Promise; + vestingValidatorInitiateOperatorTransfer: (options: { + vesting: Address; + wallet: Address; + newOperator: Address; + }) => Promise; + vestingValidatorCompleteOperatorTransfer: (options: { + vesting: Address; + wallet: Address; + }) => Promise; + vestingValidatorCancelOperatorTransfer: (options: { + vesting: Address; + wallet: Address; + }) => Promise; + vestingValidatorSetIdentity: (options: { + vesting: Address; + wallet: Address; + moniker: string; + logoUri: string; + website: string; + description: string; + email: string; + twitter: string; + telegram: string; + github: string; + extraCid: `0x${string}`; + }) => Promise; + getValidatorWallets: (vesting: Address) => Promise; + validatorWalletCount: (vesting: Address) => Promise; + validatorDeposited: (vesting: Address, wallet: Address) => Promise; + isValidatorWallet: (vesting: Address, wallet: Address) => Promise; +}; diff --git a/src/commands/vesting/withdraw.ts b/src/commands/vesting/withdraw.ts new file mode 100644 index 00000000..024d58e3 --- /dev/null +++ b/src/commands/vesting/withdraw.ts @@ -0,0 +1,41 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; + +export interface VestingWithdrawOptions extends VestingConfig { + amount: string; +} + +export class VestingWithdrawAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingWithdrawOptions): Promise { + this.startSpinner("Withdrawing vested tokens..."); + + try { + const client = await this.getVestingClient(options); + const amount = this.parseAmount(options.amount); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Withdrawing ${this.formatAmount(amount)} from vesting ${vesting}...`); + + const result = await client.vestingWithdraw({ + vesting, + amount, + }); + + const output = { + transactionHash: result.transactionHash, + vesting: result.vesting, + beneficiary: result.beneficiary, + amount: result.amount, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting withdrawal successful!", output); + } catch (error: any) { + this.failSpinner("Failed to withdraw vested tokens", error.message || error); + } + } +} diff --git a/src/index.ts b/src/index.ts index c78a5cd0..c8bc1850 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import {initializeScaffoldCommands} from "../src/commands/scaffold"; import {initializeNetworkCommands} from "../src/commands/network"; import {initializeTransactionsCommands} from "../src/commands/transactions"; import {initializeStakingCommands} from "../src/commands/staking"; +import {initializeVestingCommands} from "../src/commands/vesting"; export function initializeCLI() { program.version(version).description(CLI_DESCRIPTION); @@ -25,6 +26,7 @@ export function initializeCLI() { initializeNetworkCommands(program); initializeTransactionsCommands(program); initializeStakingCommands(program); + initializeVestingCommands(program); program.parse(process.argv); } diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index d555a6dd..92a0d665 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -7,6 +7,12 @@ import { inspect } from "util"; import {createClient, createAccount} from "genlayer-js"; import {localnet, studionet, testnetAsimov, testnetBradbury} from "genlayer-js/chains"; import type {GenLayerClient, GenLayerChain, Hash, Address, Account} from "genlayer-js/types"; +import { + applyCustomNetworkProfile, + CUSTOM_NETWORKS_CONFIG_KEY, + normalizeCustomNetworks, + type CustomNetworksConfig, +} from "../networks/customNetworks"; // Built-in networks - always resolve fresh from genlayer-js export const BUILT_IN_NETWORKS: Record = { @@ -20,7 +26,7 @@ export const BUILT_IN_NETWORKS: Record = { * Resolves a stored network config to a fresh chain object. * Handles both new format (alias string) and old format (JSON object) for backwards compat. */ -export function resolveNetwork(stored: string | undefined): GenLayerChain { +export function resolveNetwork(stored: string | undefined, customNetworks?: CustomNetworksConfig): GenLayerChain { if (!stored) return localnet; // Try as alias first (new format) @@ -28,6 +34,15 @@ export function resolveNetwork(stored: string | undefined): GenLayerChain { return BUILT_IN_NETWORKS[stored]; } + const customNetwork = customNetworks?.[stored]; + if (customNetwork) { + const baseNetwork = BUILT_IN_NETWORKS[customNetwork.base]; + if (!baseNetwork) { + throw new Error(`Custom network ${stored} references unknown base network: ${customNetwork.base}`); + } + return applyCustomNetworkProfile(baseNetwork, customNetwork); + } + // Backwards compat: try parsing as JSON (old format) try { const parsed = JSON.parse(stored); @@ -62,6 +77,10 @@ export class BaseAction extends ConfigFileManager { this.keychainManager = new KeychainManager(); } + protected getCustomNetworks(): CustomNetworksConfig { + return normalizeCustomNetworks(this.getConfigByKey(CUSTOM_NETWORKS_CONFIG_KEY)); + } + private async decryptKeystore(keystoreJson: string, attempt: number = 1): Promise { try { const message = attempt === 1 @@ -97,7 +116,7 @@ export class BaseAction extends ConfigFileManager { protected async getClient(rpcUrl?: string, readOnly: boolean = false): Promise> { if (!this._genlayerClient) { - const network = resolveNetwork(this.getConfig().network); + const network = resolveNetwork(this.getConfig().network, this.getCustomNetworks()); const account = await this.getAccount(readOnly); this._genlayerClient = createClient({ chain: network, @@ -298,4 +317,4 @@ export class BaseAction extends ConfigFileManager { protected setSpinnerText(message: string): void { this.spinner.text = chalk.blue(message); } -} \ No newline at end of file +} diff --git a/src/lib/clients/system.ts b/src/lib/clients/system.ts index 30cb4eb4..1fcc0b71 100644 --- a/src/lib/clients/system.ts +++ b/src/lib/clients/system.ts @@ -9,9 +9,7 @@ export async function checkCommand(command: string, toolName: string): Promise { } export async function getVersion(toolName: string): Promise { + let toolResponse: {stdout?: string; stderr?: string}; + try { - const toolResponse = await util.promisify(exec)(`${toolName} --version`); + toolResponse = await util.promisify(exec)(`${toolName} --version`); + } catch (error) { + throw new Error(`Error getting ${toolName} version.`); + } - if (toolResponse.stderr) { - throw new Error(toolResponse.stderr); - } + if (toolResponse.stderr) { + throw new Error(`Error getting ${toolName} version.`); + } - try { - const versionMatch = toolResponse.stdout.match(/(\d+\.\d+\.\d+)/); - if (versionMatch) { - return versionMatch[1]; - } - } catch (err) { - throw new Error(`Could not parse ${toolName} version.`); - } - } catch (error) { + if (toolResponse.stdout == null) { throw new Error(`Error getting ${toolName} version.`); } - return ""; + const versionMatch = toolResponse.stdout.match(/(\d+\.\d+\.\d+)/); + if (versionMatch) { + return versionMatch[1]; + } + + throw new Error( + `Could not parse ${toolName} version from output: "${toolResponse.stdout}". Expected format: X.Y.Z` + ); } diff --git a/src/lib/config/simulator.ts b/src/lib/config/simulator.ts index 6a6718ea..12ade540 100644 --- a/src/lib/config/simulator.ts +++ b/src/lib/config/simulator.ts @@ -23,7 +23,7 @@ export type RunningPlatform = (typeof AVAILABLE_PLATFORMS)[number]; export const STARTING_TIMEOUT_WAIT_CYLCE = 2000; export const STARTING_TIMEOUT_ATTEMPTS = 120; -export type AiProviders = "ollama" | "openai" | "heuristai" | "geminiai" | "xai"; +export type AiProviders = "ollama" | "openai" | "heuristai" | "google" | "xai"; export type AiProvidersEnvVars = "ollama" | "OPENAIKEY" | "HEURISTAIAPIKEY" | "GEMINI_API_KEY" | "XAI_API_KEY"; export type AiProvidersConfigType = { [key in AiProviders]: {name: string; hint: string; envVar?: AiProvidersEnvVars; cliOptionValue: string}; @@ -47,11 +47,11 @@ export const AI_PROVIDERS_CONFIG: AiProvidersConfigType = { envVar: "HEURISTAIAPIKEY", cliOptionValue: "heuristai", }, - geminiai: { + google: { name: "Gemini", hint: '(You will need to provide an API key.)', envVar: "GEMINI_API_KEY", - cliOptionValue: "geminiai", + cliOptionValue: "google", }, xai: { name: "XAI", diff --git a/src/lib/networks/customNetworks.ts b/src/lib/networks/customNetworks.ts new file mode 100644 index 00000000..d3f815fb --- /dev/null +++ b/src/lib/networks/customNetworks.ts @@ -0,0 +1,253 @@ +import {readFileSync} from "fs"; +import type {Address, GenLayerChain} from "genlayer-js/types"; + +export const CUSTOM_NETWORKS_CONFIG_KEY = "customNetworks"; +export const ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/; + +export type ContractOverrideKey = + | "consensusMain" + | "consensusData" + | "staking" + | "feeManager" + | "roundsStorage" + | "appeals"; + +export interface CustomNetworkOverrides { + rpcUrl?: string; + chainId?: number; + consensusMain?: Address; + consensusData?: Address; + staking?: Address; + feeManager?: Address; + roundsStorage?: Address; + appeals?: Address; +} + +export interface CustomNetworkProfile { + base: string; + overrides: CustomNetworkOverrides; +} + +export type CustomNetworksConfig = Record; + +export const CONTRACT_OVERRIDES: Array<{ + overrideKey: ContractOverrideKey; + chainField: keyof GenLayerChain; + label: string; +}> = [ + {overrideKey: "consensusMain", chainField: "consensusMainContract", label: "consensusMain"}, + {overrideKey: "consensusData", chainField: "consensusDataContract", label: "consensusData"}, + {overrideKey: "staking", chainField: "stakingContract", label: "staking"}, + {overrideKey: "feeManager", chainField: "feeManagerContract", label: "feeManager"}, + {overrideKey: "roundsStorage", chainField: "roundsStorageContract", label: "roundsStorage"}, + {overrideKey: "appeals", chainField: "appealsContract", label: "appeals"}, +]; + +const DEPLOYMENT_KEY_TO_OVERRIDE: Record = { + ConsensusMain: "consensusMain", + ConsensusData: "consensusData", + GenStaking: "staking", + Staking: "staking", + FeeManager: "feeManager", + Rounds: "roundsStorage", + RoundsStorage: "roundsStorage", + Appeals: "appeals", +}; + +const CONSENSUS_MAIN_WITH_FEES = "ConsensusMainWithFees"; +const SCANNED_DEPLOYMENT_KEYS = new Set([ + ...Object.keys(DEPLOYMENT_KEY_TO_OVERRIDE), + CONSENSUS_MAIN_WITH_FEES, +]); + +interface FoundDeploymentAddress { + path: string; + address: string; +} + +export interface ParsedDeploymentOverrides { + overrides: Partial>; + notices: string[]; +} + +export function normalizeCustomNetworks(value: unknown): CustomNetworksConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return value as CustomNetworksConfig; +} + +export function isValidAddress(value: string): value is Address { + return ADDRESS_REGEX.test(value); +} + +export function parseDeploymentFile(filePath: string, deploymentKey?: string): ParsedDeploymentOverrides { + const content = readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(content); + return parseDeploymentObject(parsed, deploymentKey); +} + +export function parseDeploymentObject(input: unknown, deploymentKey?: string): ParsedDeploymentOverrides { + const selected = deploymentKey ? selectDeploymentObject(input, deploymentKey) : input; + if (!selected || typeof selected !== "object" || Array.isArray(selected)) { + throw new Error("Deployment selection must be a JSON object"); + } + + const found: Record = {}; + walkDeploymentObject(selected, [], found); + + for (const [contractName, entries] of Object.entries(found)) { + if (entries.length > 1) { + const paths = entries.map(entry => entry.path).join(", "); + throw new Error( + `Ambiguous ${contractName} entries found at ${paths}. ` + + "Pass --deployment-key to select one deployment.", + ); + } + } + + const overrides: ParsedDeploymentOverrides["overrides"] = {}; + const fieldSources: Partial> = {}; + + for (const [contractName, entries] of Object.entries(found)) { + const entry = entries[0]; + if (!entry) continue; + if (!isValidAddress(entry.address)) { + throw new Error(`Invalid address for ${contractName} at ${entry.path}: ${entry.address}`); + } + if (contractName === CONSENSUS_MAIN_WITH_FEES) { + continue; + } + + const overrideKey = DEPLOYMENT_KEY_TO_OVERRIDE[contractName]; + if (!overrideKey) continue; + if (fieldSources[overrideKey]) { + throw new Error( + `Multiple deployment entries map to ${overrideKey}: ${fieldSources[overrideKey]} and ${contractName}. ` + + `Use an explicit --${toFlagName(overrideKey)} override.`, + ); + } + overrides[overrideKey] = entry.address as Address; + fieldSources[overrideKey] = contractName; + } + + const notices: string[] = []; + if (found.ConsensusMain?.length && found[CONSENSUS_MAIN_WITH_FEES]?.length) { + notices.push( + "ConsensusMainWithFees exists in the deployment file; using ConsensusMain. " + + "Use --consensus-main to choose ConsensusMainWithFees.", + ); + } + + return {overrides, notices}; +} + +export function applyCustomNetworkProfile( + baseChain: GenLayerChain, + profile: CustomNetworkProfile, +): GenLayerChain { + const chain = cloneChain(baseChain); + const overrides = profile.overrides || {}; + + if (overrides.chainId !== undefined) { + (chain as any).id = overrides.chainId; + } + + if (overrides.rpcUrl) { + const rpcUrls = (chain.rpcUrls || {}) as any; + const defaultRpc = rpcUrls.default || {}; + const currentHttp = Array.isArray(defaultRpc.http) ? defaultRpc.http : []; + (chain as any).rpcUrls = { + ...rpcUrls, + default: { + ...defaultRpc, + http: [overrides.rpcUrl, ...currentHttp.slice(1)], + }, + }; + } + + for (const contract of CONTRACT_OVERRIDES) { + const address = overrides[contract.overrideKey]; + if (!address) continue; + const current = (chain as any)[contract.chainField]; + (chain as any)[contract.chainField] = { + ...(current || {}), + address, + }; + } + + return chain; +} + +export function getContractAddress(chain: GenLayerChain, overrideKey: ContractOverrideKey): string | undefined { + const contract = CONTRACT_OVERRIDES.find(item => item.overrideKey === overrideKey); + if (!contract) return undefined; + return (chain as any)[contract.chainField]?.address; +} + +function selectDeploymentObject(input: unknown, deploymentKey: string): unknown { + const segments = deploymentKey.split(".").filter(Boolean); + if (!segments.length) { + throw new Error("--deployment-key must not be empty"); + } + + let selected = input as any; + for (const segment of segments) { + if (!selected || typeof selected !== "object" || Array.isArray(selected) || !(segment in selected)) { + throw new Error(`Deployment key not found: ${deploymentKey}`); + } + selected = selected[segment]; + } + + return selected; +} + +function walkDeploymentObject( + node: Record, + path: string[], + found: Record, +): void { + for (const [key, value] of Object.entries(node)) { + const nextPath = [...path, key]; + if (typeof value === "string" && SCANNED_DEPLOYMENT_KEYS.has(key)) { + if (!found[key]) found[key] = []; + found[key].push({path: nextPath.join("."), address: value}); + continue; + } + if (value && typeof value === "object" && !Array.isArray(value)) { + walkDeploymentObject(value as Record, nextPath, found); + } + } +} + +function cloneChain(baseChain: GenLayerChain): GenLayerChain { + const chain = {...baseChain} as any; + + if (baseChain.rpcUrls) { + chain.rpcUrls = {}; + for (const [key, value] of Object.entries(baseChain.rpcUrls as any)) { + chain.rpcUrls[key] = value && typeof value === "object" + ? { + ...value, + http: Array.isArray((value as any).http) ? [...(value as any).http] : (value as any).http, + } + : value; + } + } + + for (const contract of CONTRACT_OVERRIDES) { + const current = (baseChain as any)[contract.chainField]; + if (current) { + chain[contract.chainField] = {...current}; + } + } + + return chain as GenLayerChain; +} + +function toFlagName(overrideKey: ContractOverrideKey): string { + return overrideKey.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`); +} diff --git a/tests/actions/customNetworkProfiles.test.ts b/tests/actions/customNetworkProfiles.test.ts new file mode 100644 index 00000000..a9e6f0f8 --- /dev/null +++ b/tests/actions/customNetworkProfiles.test.ts @@ -0,0 +1,297 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import {tmpdir} from "os"; +import {NetworkActions} from "../../src/commands/network/setNetwork"; +import {resolveNetwork} from "../../src/lib/actions/BaseAction"; +import {parseDeploymentObject} from "../../src/lib/networks/customNetworks"; +import {testnetBradbury} from "genlayer-js/chains"; +import {StakingAction} from "../../src/commands/staking/StakingAction"; + +const ADDR_1 = "0x1111111111111111111111111111111111111111"; +const ADDR_2 = "0x2222222222222222222222222222222222222222"; +const ADDR_3 = "0x3333333333333333333333333333333333333333"; +const ADDR_4 = "0x4444444444444444444444444444444444444444"; +const ADDR_5 = "0x5555555555555555555555555555555555555555"; +const ADDR_6 = "0x6666666666666666666666666666666666666666"; +const ADDR_A = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +describe("custom network profiles", () => { + let tempHome: string; + let action: NetworkActions; + let succeedSpy: any; + let failSpy: any; + let warningSpy: any; + let infoSpy: any; + let consoleSpy: any; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(tmpdir(), "genlayer-custom-network-")); + vi.spyOn(os, "homedir").mockReturnValue(tempHome); + action = new NetworkActions(); + succeedSpy = vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + failSpy = vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); + warningSpy = vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + infoSpy = vi.spyOn(action as any, "logInfo").mockImplementation(() => {}); + consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempHome, {recursive: true, force: true}); + }); + + test("network add stores flags-only overrides", async () => { + await action.addNetwork("bradbury-clarke", { + base: "testnet-bradbury", + rpc: "http://localhost:9999", + consensusMain: ADDR_1, + chainId: "4222", + }); + + expect(failSpy).not.toHaveBeenCalled(); + expect(readConfig().customNetworks["bradbury-clarke"]).toEqual({ + base: "testnet-bradbury", + overrides: { + consensusMain: ADDR_1, + rpcUrl: "http://localhost:9999", + chainId: 4222, + }, + }); + expect(succeedSpy).toHaveBeenCalledWith( + "Custom network profile added", + expect.objectContaining({ + alias: "bradbury-clarke", + base: "testnet-bradbury", + consensusMain: `${ADDR_1} (overridden)`, + rpc: "http://localhost:9999 (overridden)", + }), + ); + }); + + test("network add sources overrides from a deployment file", async () => { + const deploymentPath = writeDeployment({ + genlayerTestnet: { + deployment_x: { + ConsensusMain: ADDR_1, + ConsensusData: ADDR_2, + GenStaking: ADDR_3, + FeeManager: ADDR_4, + Rounds: ADDR_5, + Appeals: ADDR_6, + }, + }, + }); + + await action.addNetwork("bradbury-deployment", { + base: "testnet-bradbury", + deployment: deploymentPath, + }); + + expect(failSpy).not.toHaveBeenCalled(); + expect(readConfig().customNetworks["bradbury-deployment"].overrides).toEqual({ + consensusMain: ADDR_1, + consensusData: ADDR_2, + staking: ADDR_3, + feeManager: ADDR_4, + roundsStorage: ADDR_5, + appeals: ADDR_6, + }); + }); + + test("network add gives address flags precedence over deployment file", async () => { + const deploymentPath = writeDeployment({ + deployment_x: { + ConsensusMain: ADDR_1, + ConsensusData: ADDR_2, + }, + }); + + await action.addNetwork("bradbury-precedence", { + base: "testnet-bradbury", + deployment: deploymentPath, + consensusMain: ADDR_A, + }); + + expect(failSpy).not.toHaveBeenCalled(); + expect(readConfig().customNetworks["bradbury-precedence"].overrides).toEqual({ + consensusMain: ADDR_A, + consensusData: ADDR_2, + }); + }); + + test("network add validates base, alias, addresses, and override presence", async () => { + await action.addNetwork("localnet", {base: "testnet-bradbury", rpc: "http://localhost:9999"}); + await action.addNetwork("bad-base", {base: "missing", rpc: "http://localhost:9999"}); + await action.addNetwork("bad-address", {base: "testnet-bradbury", consensusMain: "0x123"}); + await action.addNetwork("empty", {base: "testnet-bradbury"}); + + expect(failSpy).toHaveBeenNthCalledWith( + 1, + "Failed to add custom network profile", + "Custom network alias cannot collide with built-in network: localnet", + ); + expect(failSpy).toHaveBeenNthCalledWith( + 2, + "Failed to add custom network profile", + "Base network must be one of: localnet, studionet, testnet-asimov, testnet-bradbury", + ); + expect(failSpy).toHaveBeenNthCalledWith( + 3, + "Failed to add custom network profile", + "Invalid address for --consensus-main: 0x123", + ); + expect(failSpy).toHaveBeenNthCalledWith( + 4, + "Failed to add custom network profile", + "Provide at least one override: --deployment, --rpc, --chain-id, or a contract address flag", + ); + }); + + test("network add rejects ambiguous deployment contract names", async () => { + const deploymentPath = writeDeployment({ + net_a: {deployment: {ConsensusMain: ADDR_1}}, + net_b: {deployment: {ConsensusMain: ADDR_2}}, + }); + + await action.addNetwork("ambiguous", { + base: "testnet-bradbury", + deployment: deploymentPath, + }); + + expect(failSpy).toHaveBeenCalledWith( + "Failed to add custom network profile", + expect.stringContaining("Pass --deployment-key "), + ); + }); + + test("network add supports --deployment-key", async () => { + const deploymentPath = writeDeployment({ + net_a: {deployment: {ConsensusMain: ADDR_1}}, + net_b: {deployment: {ConsensusMain: ADDR_2}}, + }); + + await action.addNetwork("keyed", { + base: "testnet-bradbury", + deployment: deploymentPath, + deploymentKey: "net_b.deployment", + }); + + expect(failSpy).not.toHaveBeenCalled(); + expect(readConfig().customNetworks.keyed.overrides).toEqual({ + consensusMain: ADDR_2, + }); + }); + + test("network set, list, info, and remove handle custom profiles", async () => { + await action.addNetwork("bradbury-clarke", { + base: "testnet-bradbury", + rpc: "http://localhost:9999", + consensusMain: ADDR_1, + }); + succeedSpy.mockClear(); + + await action.setNetwork("bradbury-clarke"); + expect(readConfig().network).toBe("bradbury-clarke"); + expect(succeedSpy).toHaveBeenCalledWith("Network successfully set to bradbury-clarke (custom)"); + + await action.listNetworks(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("bradbury-clarke")); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("custom base: testnet-bradbury")); + + succeedSpy.mockClear(); + await action.showInfo(); + expect(succeedSpy).toHaveBeenCalledWith( + "Current network", + expect.objectContaining({ + alias: "bradbury-clarke", + type: "custom", + base: "testnet-bradbury", + rpc: "http://localhost:9999 (overridden)", + consensusMain: `${ADDR_1} (overridden)`, + consensusData: expect.stringContaining("(inherited)"), + }), + ); + + await action.removeNetwork("bradbury-clarke"); + expect(warningSpy).toHaveBeenCalledWith("Removed active network bradbury-clarke; active network reset to localnet."); + expect(readConfig().network).toBe("localnet"); + expect(readConfig().customNetworks["bradbury-clarke"]).toBeUndefined(); + }); + + test("network remove refuses built-ins", async () => { + await action.removeNetwork("localnet"); + + expect(failSpy).toHaveBeenCalledWith( + "Failed to remove custom network profile", + "Cannot remove built-in network: localnet", + ); + }); + + test("deployment parser notices ConsensusMainWithFees when ConsensusMain is present", () => { + const parsed = parseDeploymentObject({ + deployment: { + ConsensusMain: ADDR_1, + ConsensusMainWithFees: ADDR_2, + }, + }); + + expect(parsed.overrides.consensusMain).toBe(ADDR_1); + expect(parsed.notices[0]).toContain("ConsensusMainWithFees exists"); + }); + + test("resolveNetwork applies custom overrides while retaining base ABI objects", () => { + const resolved = resolveNetwork("bradbury-clarke", { + "bradbury-clarke": { + base: "testnet-bradbury", + overrides: { + consensusMain: ADDR_1, + rpcUrl: "http://localhost:9999", + chainId: 4222, + }, + }, + }); + + const resolvedChain = resolved as any; + const baseChain = testnetBradbury as any; + expect(resolved).not.toBe(testnetBradbury); + expect(resolvedChain.id).toBe(4222); + expect(resolvedChain.rpcUrls.default.http[0]).toBe("http://localhost:9999"); + expect(resolvedChain.consensusMainContract.address).toBe(ADDR_1); + expect(resolvedChain.consensusMainContract.abi).toBe(baseChain.consensusMainContract.abi); + expect(resolvedChain.consensusDataContract.abi).toBe(baseChain.consensusDataContract.abi); + }); + + test("StakingAction.getNetwork accepts a custom alias", () => { + const stakingAction = new StakingAction(); + vi.spyOn(stakingAction as any, "getConfigByKey").mockImplementation((key: string) => { + if (key === "customNetworks") { + return { + "bradbury-clarke": { + base: "testnet-bradbury", + overrides: { + staking: ADDR_3, + }, + }, + }; + } + return null; + }); + + const network = (stakingAction as any).getNetwork({network: "bradbury-clarke"}); + + expect(network.stakingContract.address).toBe(ADDR_3); + expect(network.stakingContract.abi).toBe((testnetBradbury as any).stakingContract.abi); + }); + + function writeDeployment(content: unknown): string { + const deploymentPath = path.join(tempHome, "deployment.json"); + fs.writeFileSync(deploymentPath, JSON.stringify(content, null, 2)); + return deploymentPath; + } + + function readConfig(): Record { + return JSON.parse(fs.readFileSync(path.join(tempHome, ".genlayer", "genlayer-config.json"), "utf-8")); + } +}); diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index 82ec1bc6..b6453055 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -1,7 +1,7 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; import fs from "fs"; import os from "os"; -import {createClient, createAccount, isSuccessful, formatStakingAmount} from "genlayer-js"; +import {createClient, createAccount, isSuccessful, formatStakingAmount, DEPLOY_CALL_KEY} from "genlayer-js"; import {DeployAction, DeployOptions} from "../../src/commands/contracts/deploy"; import {buildSync} from "esbuild"; import {pathToFileURL} from "url"; @@ -19,6 +19,7 @@ describe("DeployAction", () => { deployContract: vi.fn(), waitForTransactionReceipt: vi.fn(), initializeConsensusSmartContract: vi.fn(), + estimateTransactionFees: vi.fn(), }; const mockPrivateKey = "mocked_private_key"; @@ -35,9 +36,9 @@ describe("DeployAction", () => { vi.mocked(formatStakingAmount).mockImplementation((value: bigint) => `${value.toString()} GEN`); vi.mocked(isSuccessful).mockImplementation((receipt: any) => { const statusName = receipt.statusName ?? receipt.status; - const executionResultName = receipt.txExecutionResultName ?? ( - receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined - ); + const executionResultName = + receipt.txExecutionResultName ?? + (receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined); return ( (statusName === "ACCEPTED" || statusName === "FINALIZED") && executionResultName === "FINISHED_WITH_RETURN" @@ -124,11 +125,13 @@ describe("DeployAction", () => { leaderTimeunitsAllocation: "10", rotations: ["0"], }, - messageAllocations: [{ - messageType: "internal", - recipient: "0x0000000000000000000000000000000000000001", - budget: "5", - }], + messageAllocations: [ + { + messageType: "internal", + recipient: "0x0000000000000000000000000000000000000001", + budget: "5", + }, + ], }), feeValue: "123", validUntil: "999", @@ -158,7 +161,7 @@ describe("DeployAction", () => { messageAllocations: [{ messageType: 1, recipient: "0x0000000000000000000000000000000000000001", - callKey: "0x0000000000000000000000000000000000000000000000000000000000000001", + callKey: DEPLOY_CALL_KEY, budget: "5", }], feeValue: "123", @@ -167,6 +170,74 @@ describe("DeployAction", () => { }); }); + test("deploys contract with fees estimated from a fee profile", async () => { + const options: DeployOptions = { + contract: "/mocked/contract/path", + args: [1], + feeProfile: "/mocked/fee-profile.json", + feeValue: "999", + }; + const contractContent = "contract code"; + const feeProfile = { + version: 1, + network: "localnet", + deploy: { + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "0", + rotationsPerRound: "1", + }, + methods: {}, + }; + const feeEstimate = { + distribution: { + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "0", + appealRounds: "1", + rotations: ["1", "1"], + }, + feeValue: "123", + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation(((filePath: string) => { + const normalizedPath = filePath.replace(/\\/g, "/"); + if (normalizedPath === "/mocked/contract/path") return contractContent; + if (normalizedPath.endsWith("/fee-profile.json")) return JSON.stringify(feeProfile); + return JSON.stringify({activeAccount: "default"}); + }) as any); + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(feeEstimate); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResultName: "FINISHED_WITH_RETURN", + data: {contract_address: "0xdasdsadasdasdada"}, + }); + + await deployer.deploy(options); + + expect(mockClient.estimateTransactionFees).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "0", + appealRounds: "1", + rotations: ["1", "1"], + }); + expect(mockClient.deployContract).toHaveBeenCalledWith({ + code: contractContent, + args: [1], + leaderOnly: false, + fees: { + distribution: feeEstimate.distribution, + feeValue: "999", + }, + }); + }); + test("fails when deployment reaches consensus but execution fails", async () => { const options: DeployOptions = { contract: "/mocked/contract/path", diff --git a/tests/actions/estimateFees.test.ts b/tests/actions/estimateFees.test.ts index cf072465..605f4ef4 100644 --- a/tests/actions/estimateFees.test.ts +++ b/tests/actions/estimateFees.test.ts @@ -1,4 +1,7 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; import {createClient, createAccount, deriveInternalMessageCallKey} from "genlayer-js"; import {EstimateFeesAction} from "../../src/commands/contracts/estimateFees"; @@ -16,13 +19,21 @@ describe("EstimateFeesAction", () => { const mockPrivateKey = "mocked_private_key"; + const writeFeeProfile = (profile: Record): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "genlayer-cli-fees-")); + const profilePath = path.join(dir, "fee-profile.json"); + fs.writeFileSync(profilePath, JSON.stringify(profile)); + return profilePath; + }; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(createClient).mockReturnValue(mockClient as any); vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any); - vi.mocked(deriveInternalMessageCallKey).mockImplementation((methodName = "") => ( - `0x${Buffer.from(methodName, "utf8").toString("hex").padEnd(64, "0")}` as `0x${string}` - )); + vi.mocked(deriveInternalMessageCallKey).mockImplementation( + (methodName = "") => + `0x${Buffer.from(methodName, "utf8").toString("hex").padEnd(64, "0")}` as `0x${string}`, + ); action = new EstimateFeesAction(); vi.spyOn(action as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey}); vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); @@ -64,6 +75,55 @@ describe("EstimateFeesAction", () => { }); }); + test("builds a static fee estimate from the deploy fee profile entry", async () => { + const profilePath = writeFeeProfile({ + version: 1, + network: "localnet", + deploy: { + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "0", + rotationsPerRound: "1", + }, + methods: {}, + }); + const estimate = { + distribution: { + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + executionBudgetPerRound: 300n, + totalMessageFees: 0n, + appealRounds: 1n, + rotations: [1n, 1n], + }, + feeValue: 1700n, + }; + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(estimate); + + await action.estimate({feeProfile: profilePath}); + + expect(mockClient.estimateTransactionFees).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "0", + appealRounds: "1", + rotations: ["1", "1"], + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Fee estimate generated", { + distribution: { + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "0", + appealRounds: "1", + rotations: ["1", "1"], + }, + feeValue: "1700", + }); + }); + test("prints a static fee estimate as JSON without spinner output", async () => { const estimate = { distribution: {leaderTimeunitsAllocation: 100n, rotations: [0n]}, @@ -77,11 +137,13 @@ describe("EstimateFeesAction", () => { expect(action["startSpinner"]).not.toHaveBeenCalled(); expect(action["succeedSpinner"]).not.toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ - distribution: {leaderTimeunitsAllocation: "100", rotations: ["0"]}, - feeValue: "1100", - policy: {enabled: true}, - })); + expect(logSpy).toHaveBeenCalledWith( + JSON.stringify({ + distribution: {leaderTimeunitsAllocation: "100", rotations: ["0"]}, + feeValue: "1100", + policy: {enabled: true}, + }), + ); }); test("derives a fee estimate for a target write through the SDK one-call helper", async () => { @@ -99,20 +161,24 @@ describe("EstimateFeesAction", () => { method: "update", args: ["after"], fees: JSON.stringify({ - messageAllocations: [{ - messageType: "internal", - callKeyMethod: "settle_campaign", - budget: "110", - }], + messageAllocations: [ + { + messageType: "internal", + callKeyMethod: "settle_campaign", + budget: "110", + }, + ], }), }); expect(mockClient.estimateTransactionFeesForWrite).toHaveBeenCalledWith({ - messageAllocations: [{ - messageType: 1, - callKey: `0x${Buffer.from("settle_campaign", "utf8").toString("hex").padEnd(64, "0")}`, - budget: "110", - }], + messageAllocations: [ + { + messageType: 1, + callKey: `0x${Buffer.from("settle_campaign", "utf8").toString("hex").padEnd(64, "0")}`, + budget: "110", + }, + ], address: "0x0000000000000000000000000000000000000001", functionName: "update", args: ["after"], @@ -129,6 +195,80 @@ describe("EstimateFeesAction", () => { }); }); + test("derives a target write fee estimate from the matching method fee profile entry", async () => { + const profilePath = writeFeeProfile({ + version: 1, + network: "localnet", + methods: { + update: { + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "55", + rotationsPerRound: "1", + }, + }, + }); + const finalEstimate = { + distribution: { + leaderTimeunitsAllocation: 100n, + totalMessageFees: 80n, + appealRounds: 2n, + rotations: [1n, 1n, 1n], + }, + messageAllocations: [{messageType: 1, budget: 80n}], + feeValue: 1780n, + }; + vi.mocked(mockClient.estimateTransactionFeesForWrite).mockResolvedValue(finalEstimate); + + await action.estimate({ + contractAddress: "0x0000000000000000000000000000000000000001", + method: "update", + args: ["after"], + feeProfile: profilePath, + feePreset: "high", + fees: JSON.stringify({ + totalMessageFees: "80", + messageAllocations: [ + { + messageType: "internal", + callKeyMethod: "settle_campaign", + budget: "80", + }, + ], + }), + }); + + expect(mockClient.estimateTransactionFeesForWrite).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "80", + appealRounds: "2", + rotations: ["1", "1", "1"], + messageAllocations: [ + { + messageType: 1, + callKey: `0x${Buffer.from("settle_campaign", "utf8").toString("hex").padEnd(64, "0")}`, + budget: "80", + }, + ], + address: "0x0000000000000000000000000000000000000001", + functionName: "update", + args: ["after"], + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Fee estimate generated", { + distribution: { + leaderTimeunitsAllocation: "100", + totalMessageFees: "80", + appealRounds: "2", + rotations: ["1", "1", "1"], + }, + messageAllocations: [{messageType: 1, budget: "80"}], + feeValue: "1780", + }); + }); + test("falls back to explicit simulation when the SDK one-call helper is unavailable", async () => { const legacyClient = { ...mockClient, diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index 1e483526..22938c05 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -1,4 +1,7 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; import { createClient, createAccount, @@ -16,10 +19,18 @@ describe("WriteAction", () => { writeContract: vi.fn(), waitForTransactionReceipt: vi.fn(), initializeConsensusSmartContract: vi.fn(), + estimateTransactionFees: vi.fn(), }; const mockPrivateKey = "mocked_private_key"; + const writeFeeProfile = (profile: Record): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "genlayer-cli-fees-")); + const profilePath = path.join(dir, "fee-profile.json"); + fs.writeFileSync(profilePath, JSON.stringify(profile)); + return profilePath; + }; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(createClient).mockReturnValue(mockClient as any); @@ -27,18 +38,19 @@ describe("WriteAction", () => { vi.mocked(formatStakingAmount).mockImplementation((value: bigint) => `${value.toString()} GEN`); vi.mocked(deriveExternalMessageCallKey).mockImplementation( (selectorOrCalldata: `0x${string}` | Uint8Array = "0x") => { - const hex = typeof selectorOrCalldata === "string" - ? selectorOrCalldata.slice(2) - : Buffer.from(selectorOrCalldata).toString("hex"); + const hex = + typeof selectorOrCalldata === "string" + ? selectorOrCalldata.slice(2) + : Buffer.from(selectorOrCalldata).toString("hex"); if (hex.length < 8) return "0x0000000000000000000000000000000000000000000000000000000000000000"; return `0x${hex.slice(0, 8).padEnd(64, "0")}`; }, ); vi.mocked(isSuccessful).mockImplementation((receipt: any) => { const statusName = receipt.statusName ?? receipt.status; - const executionResultName = receipt.txExecutionResultName ?? ( - receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined - ); + const executionResultName = + receipt.txExecutionResultName ?? + (receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined); return ( (statusName === "ACCEPTED" || statusName === "FINALIZED") && executionResultName === "FINISHED_WITH_RETURN" @@ -85,10 +97,10 @@ describe("WriteAction", () => { waitUntil: "decided", fullTransaction: true, }); - expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( - "Write operation successfully executed", - {...mockReceipt, consensusStatus: "ACCEPTED"}, - ); + expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith("Write operation successfully executed", { + ...mockReceipt, + consensusStatus: "ACCEPTED", + }); }); test("calls writeContract with fee options", async () => { @@ -106,12 +118,14 @@ describe("WriteAction", () => { distribution: { totalMessageFees: "3", }, - messageAllocations: [{ - messageType: "external", - recipient: "0x0000000000000000000000000000000000000001", - callKeySelector: "0xaabbccdd", - budget: "3", - }], + messageAllocations: [ + { + messageType: "external", + recipient: "0x0000000000000000000000000000000000000001", + callKeySelector: "0xaabbccdd", + budget: "3", + }, + ], }), feeValue: "4", validUntil: "999", @@ -126,18 +140,80 @@ describe("WriteAction", () => { distribution: { totalMessageFees: "3", }, - messageAllocations: [{ - messageType: 0, - recipient: "0x0000000000000000000000000000000000000001", - callKey: `0xaabbccdd${"0".repeat(56)}`, - budget: "3", - }], + messageAllocations: [ + { + messageType: 0, + recipient: "0x0000000000000000000000000000000000000001", + callKey: `0xaabbccdd${"0".repeat(56)}`, + budget: "3", + }, + ], feeValue: "4", }, validUntil: "999", }); }); + test("calls writeContract with fees estimated from a method fee profile", async () => { + const profilePath = writeFeeProfile({ + version: 1, + network: "localnet", + methods: { + updateData: { + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "5", + rotationsPerRound: "1", + }, + }, + }); + const feeEstimate = { + distribution: { + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "5", + appealRounds: "2", + rotations: ["1", "1", "1"], + }, + feeValue: "123", + }; + const mockHash = "0xMockedTransactionHash"; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; + + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(feeEstimate); + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); + + await writeAction.write({ + contractAddress: "0xMockedContract", + method: "updateData", + args: [42], + feeProfile: profilePath, + feePreset: "high", + }); + + expect(mockClient.estimateTransactionFees).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "5", + appealRounds: "2", + rotations: ["1", "1", "1"], + }); + expect(mockClient.writeContract).toHaveBeenCalledWith({ + address: "0xMockedContract", + functionName: "updateData", + args: [42], + value: 0n, + fees: { + distribution: feeEstimate.distribution, + feeValue: "123", + }, + }); + }); + test("handles writeContract errors", async () => { vi.mocked(mockClient.writeContract).mockRejectedValue(new Error("Mocked write error")); @@ -290,9 +366,9 @@ describe("WriteAction", () => { args: [42, "Update"], value: 0n, }); - expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( - "Write operation successfully executed", - {...mockReceipt, consensusStatus: "ACCEPTED"}, - ); + expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith("Write operation successfully executed", { + ...mockReceipt, + consensusStatus: "ACCEPTED", + }); }); }); diff --git a/tests/commands/deploy.test.ts b/tests/commands/deploy.test.ts index 271e60ec..85a493f8 100644 --- a/tests/commands/deploy.test.ts +++ b/tests/commands/deploy.test.ts @@ -1,7 +1,7 @@ -import { Command } from "commander"; -import { vi, describe, beforeEach, afterEach, test, expect } from "vitest"; -import { initializeContractsCommands } from "../../src/commands/contracts"; -import { DeployAction } from "../../src/commands/contracts/deploy"; +import {Command} from "commander"; +import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; +import {initializeContractsCommands} from "../../src/commands/contracts"; +import {DeployAction} from "../../src/commands/contracts/deploy"; vi.mock("../../src/commands/contracts/deploy"); vi.mock("esbuild", () => ({ @@ -42,13 +42,13 @@ describe("deploy command", () => { "2", "3", "--rpc", - "https://custom-rpc-url.com" + "https://custom-rpc-url.com", ]); expect(DeployAction).toHaveBeenCalledTimes(1); expect(DeployAction.prototype.deploy).toHaveBeenCalledWith({ contract: "./path/to/contract", args: [1, 2, 3], - rpc: "https://custom-rpc-url.com" + rpc: "https://custom-rpc-url.com", }); }); @@ -77,32 +77,52 @@ describe("deploy command", () => { }); }); + test("DeployAction.deploy receives fee profile options", async () => { + program.parse([ + "node", + "test", + "deploy", + "--contract", + "./path/to/contract", + "--fee-profile", + "./artifacts/fee-profile.json", + "--fee-preset", + "high", + "--appeal-rounds", + "3", + ]); + + expect(DeployAction.prototype.deploy).toHaveBeenCalledWith({ + contract: "./path/to/contract", + args: [], + feeProfile: "./artifacts/fee-profile.json", + feePreset: "high", + appealRounds: "3", + }); + }); + test("DeployAction is instantiated when the deploy command is executed", async () => { program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"]); expect(DeployAction).toHaveBeenCalledTimes(1); }); test("throws error for unrecognized options", async () => { - const deployCommand = program.commands.find((cmd) => cmd.name() === "deploy"); + const deployCommand = program.commands.find(cmd => cmd.name() === "deploy"); deployCommand?.exitOverride(); expect(() => program.parse(["node", "test", "deploy", "--unknown"])).toThrowError( - "error: unknown option '--unknown'" + "error: unknown option '--unknown'", ); }); test("DeployAction.deploy is called without throwing errors for valid options", async () => { program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"]); vi.mocked(DeployAction.prototype.deploy).mockResolvedValueOnce(undefined); - expect(() => - program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"]) - ).not.toThrow(); + expect(() => program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"])).not.toThrow(); }); test("DeployAction.deployScripts is called without throwing errors", async () => { program.parse(["node", "test", "deploy"]); vi.mocked(DeployAction.prototype.deployScripts).mockResolvedValueOnce(undefined); - expect(() => - program.parse(["node", "test", "deploy"]) - ).not.toThrow(); + expect(() => program.parse(["node", "test", "deploy"])).not.toThrow(); }); }); diff --git a/tests/commands/estimateFees.test.ts b/tests/commands/estimateFees.test.ts index 942644da..cb8ba54a 100644 --- a/tests/commands/estimateFees.test.ts +++ b/tests/commands/estimateFees.test.ts @@ -23,15 +23,7 @@ describe("estimate-fees command", () => { test("EstimateFeesAction.estimate is called with static estimate options", async () => { const fees = '{"distribution":{"totalMessageFees":"3"}}'; - program.parse([ - "node", - "test", - "estimate-fees", - "--fees", - fees, - "--rpc", - "http://127.0.0.1:4000/api", - ]); + program.parse(["node", "test", "estimate-fees", "--fees", fees, "--rpc", "http://127.0.0.1:4000/api"]); expect(EstimateFeesAction).toHaveBeenCalledTimes(1); expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ @@ -43,6 +35,31 @@ describe("estimate-fees command", () => { }); }); + test("EstimateFeesAction.estimate receives fee profile options", async () => { + program.parse([ + "node", + "test", + "estimate-fees", + "0x0000000000000000000000000000000000000001", + "update", + "--fee-profile", + "./artifacts/fee-profile.json", + "--fee-preset", + "high", + "--appeal-rounds", + "3", + ]); + + expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ + args: [], + feeProfile: "./artifacts/fee-profile.json", + feePreset: "high", + appealRounds: "3", + contractAddress: "0x0000000000000000000000000000000000000001", + method: "update", + }); + }); + test("EstimateFeesAction.estimate is called with simulation target and args", async () => { program.parse([ "node", @@ -63,12 +80,7 @@ describe("estimate-fees command", () => { }); test("EstimateFeesAction.estimate receives json output flag", async () => { - program.parse([ - "node", - "test", - "estimate-fees", - "--json", - ]); + program.parse(["node", "test", "estimate-fees", "--json"]); expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ args: [], diff --git a/tests/commands/network.test.ts b/tests/commands/network.test.ts index b738071f..90e4b8b4 100644 --- a/tests/commands/network.test.ts +++ b/tests/commands/network.test.ts @@ -57,4 +57,60 @@ describe("network commands", () => { expect(NetworkActions).toHaveBeenCalledTimes(1); expect(NetworkActions.prototype.showInfo).toHaveBeenCalled(); }); + + test("NetworkActions.addNetwork is called with add options", async () => { + program.parse([ + "node", + "test", + "network", + "add", + "bradbury-clarke", + "--base", + "testnet-bradbury", + "--deployment", + "/tmp/dep.json", + "--deployment-key", + "genlayerTestnet.deployment_x", + "--rpc", + "http://localhost:9999", + "--consensus-main", + "0x1111111111111111111111111111111111111111", + "--consensus-data", + "0x2222222222222222222222222222222222222222", + "--staking", + "0x3333333333333333333333333333333333333333", + "--fee-manager", + "0x4444444444444444444444444444444444444444", + "--rounds-storage", + "0x5555555555555555555555555555555555555555", + "--appeals", + "0x6666666666666666666666666666666666666666", + "--chain-id", + "4222", + ]); + + expect(NetworkActions).toHaveBeenCalledTimes(1); + expect(NetworkActions.prototype.addNetwork).toHaveBeenCalledWith( + "bradbury-clarke", + expect.objectContaining({ + base: "testnet-bradbury", + deployment: "/tmp/dep.json", + deploymentKey: "genlayerTestnet.deployment_x", + rpc: "http://localhost:9999", + consensusMain: "0x1111111111111111111111111111111111111111", + consensusData: "0x2222222222222222222222222222222222222222", + staking: "0x3333333333333333333333333333333333333333", + feeManager: "0x4444444444444444444444444444444444444444", + roundsStorage: "0x5555555555555555555555555555555555555555", + appeals: "0x6666666666666666666666666666666666666666", + chainId: "4222", + }), + ); + }); + + test("NetworkActions.removeNetwork is called for network remove", async () => { + program.parse(["node", "test", "network", "remove", "bradbury-clarke"]); + expect(NetworkActions).toHaveBeenCalledTimes(1); + expect(NetworkActions.prototype.removeNetwork).toHaveBeenCalledWith("bradbury-clarke"); + }); }); diff --git a/tests/commands/staking.test.ts b/tests/commands/staking.test.ts index abcad7ef..3866529e 100644 --- a/tests/commands/staking.test.ts +++ b/tests/commands/staking.test.ts @@ -12,6 +12,7 @@ import {DelegatorJoinAction} from "../../src/commands/staking/delegatorJoin"; import {DelegatorExitAction} from "../../src/commands/staking/delegatorExit"; import {DelegatorClaimAction} from "../../src/commands/staking/delegatorClaim"; import {StakingInfoAction} from "../../src/commands/staking/stakingInfo"; +import {ValidatorsAction} from "../../src/commands/staking/validators"; vi.mock("../../src/commands/staking/validatorJoin"); vi.mock("../../src/commands/staking/validatorDeposit"); @@ -24,6 +25,7 @@ vi.mock("../../src/commands/staking/delegatorJoin"); vi.mock("../../src/commands/staking/delegatorExit"); vi.mock("../../src/commands/staking/delegatorClaim"); vi.mock("../../src/commands/staking/stakingInfo"); +vi.mock("../../src/commands/staking/validators"); describe("staking commands", () => { let program: Command; @@ -211,6 +213,35 @@ describe("staking commands", () => { }); }); + describe("validators", () => { + test("calls ValidatorsAction.execute with discovery options", async () => { + program.parse([ + "node", + "test", + "staking", + "validators", + "--json", + "--sort-by", + "uptime", + "--explorer-url", + "https://explorer.example.com", + "--network", + "testnet-asimov", + "--staking-address", + "0xStaking", + ]); + + expect(ValidatorsAction).toHaveBeenCalledTimes(1); + expect(ValidatorsAction.prototype.execute).toHaveBeenCalledWith({ + json: true, + sortBy: "uptime", + explorerUrl: "https://explorer.example.com", + network: "testnet-asimov", + stakingAddress: "0xStaking", + }); + }); + }); + describe("validator-prime", () => { test("calls ValidatorPrimeAction.execute", async () => { program.parse(["node", "test", "staking", "validator-prime", "--validator", "0xValidator"]); diff --git a/tests/commands/stakingValidators.test.ts b/tests/commands/stakingValidators.test.ts new file mode 100644 index 00000000..6ce6beb5 --- /dev/null +++ b/tests/commands/stakingValidators.test.ts @@ -0,0 +1,197 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from "vitest"; +import {ValidatorsAction} from "../../src/commands/staking/validators"; + +const A = "0x1111111111111111111111111111111111111111"; +const B = "0x2222222222222222222222222222222222222222"; +const OWNER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const OPERATOR = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const GEN = 10n ** 18n; + +function rawGen(amount: number): bigint { + return BigInt(amount) * GEN; +} + +function validatorInfo( + address: string, + selfStake: number, + delegatedStake: number, + moniker: string, + options: {live?: boolean} = {}, +) { + return { + address, + owner: OWNER, + operator: OPERATOR, + vStake: `${selfStake} GEN`, + vStakeRaw: rawGen(selfStake), + vShares: 0n, + dStake: `${delegatedStake} GEN`, + dStakeRaw: rawGen(delegatedStake), + dShares: 0n, + vDeposit: "0 GEN", + vDepositRaw: 0n, + vWithdrawal: "0 GEN", + vWithdrawalRaw: 0n, + ePrimed: 5n, + live: options.live ?? true, + banned: false, + needsPriming: false, + identity: {moniker}, + pendingDeposits: [], + pendingWithdrawals: [], + } as any; +} + +function jsonResponse(body: unknown) { + return { + ok: true, + json: vi.fn().mockResolvedValue(body), + } as any; +} + +function createMockClient({ + currentEpoch = 6n, + validatorMinStakeRaw = rawGen(75), + betaLive = true, +}: { + currentEpoch?: bigint; + validatorMinStakeRaw?: bigint; + betaLive?: boolean; +} = {}) { + const infos = new Map([ + [A.toLowerCase(), validatorInfo(A, 100, 20, "Alpha")], + [B.toLowerCase(), validatorInfo(B, 50, 10, "Beta", {live: betaLive})], + ]); + + return { + getActiveValidators: vi.fn().mockResolvedValue([A]), + getQuarantinedValidatorsDetailed: vi.fn().mockResolvedValue([]), + getBannedValidators: vi.fn().mockResolvedValue([]), + getEpochInfo: vi.fn().mockResolvedValue({ + currentEpoch, + validatorMinStakeRaw, + validatorMinStake: `${validatorMinStakeRaw / GEN} GEN`, + }), + getValidatorInfo: vi.fn((address: string) => Promise.resolve(infos.get(address.toLowerCase()))), + }; +} + +function setupAction(mockClient = createMockClient()) { + const action = new ValidatorsAction(); + + vi.spyOn(action as any, "startSpinner").mockImplementation(() => undefined); + vi.spyOn(action as any, "setSpinnerText").mockImplementation(() => undefined); + vi.spyOn(action as any, "stopSpinner").mockImplementation(() => undefined); + vi.spyOn(action as any, "failSpinner").mockImplementation((message: unknown, error?: unknown) => { + throw new Error(`${message}: ${String(error)}`); + }); + vi.spyOn(action as any, "getReadOnlyStakingClient").mockResolvedValue(mockClient); + vi.spyOn(action as any, "getAllValidatorsFromTree").mockResolvedValue([A, B]); + vi.spyOn(action as any, "getSignerAddress").mockRejectedValue(new Error("no account")); + vi.spyOn(action as any, "getConfig").mockReturnValue({network: "localnet"}); + vi.spyOn(action as any, "formatAmount").mockImplementation((amount: unknown) => String((amount as bigint) / GEN) + " GEN"); + + return action; +} + +describe("staking validators action", () => { + let logSpy: ReturnType; + const loggedOutput = () => logSpy.mock.calls.map(call => String(call[0])).join("\n"); + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + test("emits chain-only JSON without requiring explorer", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const action = setupAction(); + await action.execute({json: true}); + + const output = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(output.current_epoch).toBe("6"); + expect(output.explorer).toEqual({enabled: false, url: null}); + expect(output.validators).toHaveLength(2); + expect(output.validators[0].address).toBe(A); + expect(output.validators[0].active).toBe(true); + expect(output.validators[0].below_min).toBe(false); + expect(output.validators[0].status).toBe("active"); + expect(output.validators[0].stake.totalRaw).toBe(rawGen(120).toString()); + expect(output.validators[0].delegatorCount).toBeNull(); + expect(output.validators[0].performance).toBeNull(); + expect(output.validators[1].below_min).toBe(true); + expect(output.validators[1].status).toBe("inactive/below-min"); + }); + + test("renders epoch 0 below-min validators as pending activation", async () => { + const action = setupAction(createMockClient({currentEpoch: 0n, betaLive: false})); + await action.execute({}); + + const output = loggedOutput(); + + expect(output).toContain("Current epoch: 0"); + expect(output).toContain("pending-activation"); + expect(output).not.toContain("inactive/below-min"); + }); + + test("renders epoch 2 below-min validators as inactive below-min", async () => { + const action = setupAction(createMockClient({currentEpoch: 2n, betaLive: false})); + await action.execute({}); + + const output = loggedOutput(); + + expect(output).toContain("Current epoch: 2"); + expect(output).toContain("inactive/below-min"); + expect(output).not.toContain("pending-activation"); + }); + + test("merges explorer performance and sorts by uptime", async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.startsWith("https://explorer.example.com/api/v1/validators")) { + return jsonResponse({ + total: 2, + validators: [ + {validator_address: A, idle_pct_7d: 10, rotation_pct_7d: 2, minority_pct_7d: 1, apy: "5.00%", transaction_count: 7}, + {validator_address: B, idle_pct_7d: 1, rotation_pct_7d: 0, minority_pct_7d: 0, apy: "4.00%", transaction_count: 9}, + ], + }); + } + + if (url === `https://explorer.example.com/api/v1/address/${A}`) { + return jsonResponse({validator: {delegators: [{}], total_votes_7d: 11, minority_votes_7d: 1, successful_appeals_7d: 0}}); + } + + if (url === `https://explorer.example.com/api/v1/address/${B}`) { + return jsonResponse({validator: {delegators: [{}, {}], total_votes_7d: 21, minority_votes_7d: 0, successful_appeals_7d: 1}}); + } + + return {ok: false, json: vi.fn()} as any; + }); + vi.stubGlobal("fetch", fetchMock); + + const action = setupAction(); + await action.execute({json: true, explorerUrl: "https://explorer.example.com", sortBy: "uptime"}); + + const output = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string); + + expect(output.explorer).toEqual({ + enabled: true, + url: "https://explorer.example.com", + endpoint: "https://explorer.example.com/api/v1/validators", + }); + expect(output.sortBy).toBe("uptime"); + expect(output.validators[0].address).toBe(B); + expect(output.validators[0].delegatorCount).toBe(2); + expect(output.validators[0].performance.uptimePct).toBe(99); + expect(output.validators[0].performance.totalVotes7d).toBe(21); + expect(output.validators[1].address).toBe(A); + }); +}); diff --git a/tests/commands/vesting.test.ts b/tests/commands/vesting.test.ts new file mode 100644 index 00000000..88f0ac38 --- /dev/null +++ b/tests/commands/vesting.test.ts @@ -0,0 +1,493 @@ +import {Command} from "commander"; +import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; +import {initializeVestingCommands} from "../../src/commands/vesting"; +import {VestingAction} from "../../src/commands/vesting/VestingAction"; + +vi.mock("genlayer-js", () => ({ + createClient: vi.fn(), + createAccount: vi.fn(() => ({address: "0xBeneficiary"})), + formatStakingAmount: vi.fn((value: bigint) => `${Number(value) / 1e18} GEN`), + parseStakingAmount: vi.fn((value: string) => { + const lower = value.toLowerCase(); + if (lower.endsWith("gen") || lower.endsWith("eth")) { + return BigInt(Math.trunc(Number(lower.slice(0, -3)) * 1e18)); + } + return BigInt(value); + }), +})); + +vi.mock("genlayer-js/chains", () => ({ + localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}}, + studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studio.genlayer.com"]}}}, + testnetAsimov: {id: 3, name: "testnet-asimov", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, + testnetBradbury: {id: 4, name: "testnet-bradbury", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, +})); + +const mockTxResult = { + transactionHash: "0xTxHash" as `0x${string}`, + blockNumber: 123n, + gasUsed: 21000n, +}; + +const mockVestingState = { + name: "Team grant", + category: 1, + beneficiary: "0xBeneficiary", + creator: "0xCreator", + revoker: "0xRevoker", + factory: "0xFactory", + addressManager: "0xAddressManager", + totalAmount: "100 GEN", + totalAmountRaw: 100n, + startDate: 1710000000n, + cliffDuration: 86400n, + periodDuration: 604800n, + numberOfPeriods: 12n, + cliffUnlockBps: 1000n, + needsManualUnlock: false, + manualUnlocked: false, + revoked: false, + vestingStopped: false, + totalWithdrawn: "10 GEN", + totalWithdrawnRaw: 10n, + vestedAtRevocation: "0 GEN", + vestedAtRevocationRaw: 0n, + totalAmountAtRevocation: "0 GEN", + totalAmountAtRevocationRaw: 0n, + revokedAt: 0n, + vestingStoppedAt: 0n, + vestedAtStop: "0 GEN", + vestedAtStopRaw: 0n, + postRevocationBeneficiaryRewards: "0 GEN", + postRevocationBeneficiaryRewardsRaw: 0n, + postRevocationBeneficiaryLosses: "0 GEN", + postRevocationBeneficiaryLossesRaw: 0n, + accumulatedRewards: "0 GEN", + accumulatedRewardsRaw: 0n, + accumulatedLosses: "0 GEN", + accumulatedLossesRaw: 0n, + vestedAmount: "40 GEN", + vestedAmountRaw: 40n, + unvestedAmount: "60 GEN", + unvestedAmountRaw: 60n, + withdrawableAmount: "30 GEN", + withdrawableAmountRaw: 30n, +}; + +const mockClient = { + getBeneficiaryVestings: vi.fn(), + getVestingState: vi.fn(), + vestingDelegatorJoin: vi.fn(), + vestingDelegatorExit: vi.fn(), + vestingDelegatorClaim: vi.fn(), + vestingWithdraw: vi.fn(), + vestingValidatorJoin: vi.fn(), + vestingValidatorDeposit: vi.fn(), + vestingValidatorExit: vi.fn(), + vestingValidatorClaim: vi.fn(), + vestingValidatorInitiateOperatorTransfer: vi.fn(), + vestingValidatorCompleteOperatorTransfer: vi.fn(), + vestingValidatorCancelOperatorTransfer: vi.fn(), + vestingValidatorSetIdentity: vi.fn(), + getValidatorWallets: vi.fn(), + validatorWalletCount: vi.fn(), + validatorDeposited: vi.fn(), + isValidatorWallet: vi.fn(), + getStakeInfo: vi.fn(), +}; + +describe("vesting commands", () => { + let program: Command; + let consoleLogSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); + mockClient.getVestingState.mockResolvedValue(mockVestingState); + mockClient.vestingDelegatorJoin.mockResolvedValue({ + ...mockTxResult, + vesting: "0xVesting", + validator: "0xValidator", + beneficiary: "0xBeneficiary", + amount: "42 GEN", + amountRaw: 42n, + }); + mockClient.vestingDelegatorExit.mockResolvedValue(mockTxResult); + mockClient.vestingDelegatorClaim.mockResolvedValue(mockTxResult); + mockClient.vestingWithdraw.mockResolvedValue({ + ...mockTxResult, + vesting: "0xVesting", + beneficiary: "0xBeneficiary", + amount: "10 GEN", + amountRaw: 10n, + }); + mockClient.vestingValidatorJoin.mockResolvedValue({ + ...mockTxResult, + vesting: "0xVesting", + validatorWallet: "0xWallet", + operator: "0xOperator", + beneficiary: "0xBeneficiary", + amount: "42 GEN", + amountRaw: 42n, + }); + mockClient.vestingValidatorDeposit.mockResolvedValue(mockTxResult); + mockClient.vestingValidatorExit.mockResolvedValue(mockTxResult); + mockClient.vestingValidatorClaim.mockResolvedValue(mockTxResult); + mockClient.vestingValidatorInitiateOperatorTransfer.mockResolvedValue(mockTxResult); + mockClient.vestingValidatorCompleteOperatorTransfer.mockResolvedValue(mockTxResult); + mockClient.vestingValidatorCancelOperatorTransfer.mockResolvedValue(mockTxResult); + mockClient.vestingValidatorSetIdentity.mockResolvedValue(mockTxResult); + mockClient.getValidatorWallets.mockResolvedValue(["0xWallet"]); + mockClient.validatorWalletCount.mockResolvedValue(1n); + mockClient.validatorDeposited.mockResolvedValue(42n); + mockClient.isValidatorWallet.mockResolvedValue(true); + mockClient.getStakeInfo.mockResolvedValue({ + delegator: "0xVesting", + validator: "0xValidator", + shares: 50n, + stake: "50 GEN", + stakeRaw: 50n, + pendingDeposits: [], + pendingWithdrawals: [], + }); + + vi.spyOn(VestingAction.prototype as any, "getReadOnlyVestingClient").mockResolvedValue(mockClient); + vi.spyOn(VestingAction.prototype as any, "getVestingClient").mockResolvedValue(mockClient); + vi.spyOn(VestingAction.prototype as any, "getSignerAddress").mockResolvedValue("0xBeneficiary"); + vi.spyOn(VestingAction.prototype as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(VestingAction.prototype as any, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(VestingAction.prototype as any, "stopSpinner").mockImplementation(() => {}); + vi.spyOn(VestingAction.prototype as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(VestingAction.prototype as any, "failSpinner").mockImplementation(() => {}); + + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + program = new Command(); + initializeVestingCommands(program); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("list fetches beneficiary vesting contracts and state", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "list", + "--beneficiary", + "0xBeneficiary", + "--factory", + "0xFactory", + ]); + + expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xBeneficiary", { + factory: "0xFactory", + }); + expect(mockClient.getVestingState).toHaveBeenCalledWith("0xVesting"); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + test("delegate resolves vesting and calls vestingDelegatorJoin", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "delegate", + "0xValidator", + "--amount", + "42gen", + ]); + + expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xBeneficiary", undefined); + expect(mockClient.vestingDelegatorJoin).toHaveBeenCalledWith({ + vesting: "0xVesting", + validator: "0xValidator", + amount: expect.any(BigInt), + }); + }); + + test("delegate accepts explicit vesting address", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "delegate", + "--validator", + "0xValidator", + "--amount", + "42gen", + "--vesting", + "0xExplicitVesting", + ]); + + expect(mockClient.getBeneficiaryVestings).not.toHaveBeenCalled(); + expect(mockClient.vestingDelegatorJoin).toHaveBeenCalledWith({ + vesting: "0xExplicitVesting", + validator: "0xValidator", + amount: expect.any(BigInt), + }); + }); + + test("undelegate exits all current shares", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "undelegate", + "0xValidator", + ]); + + expect(mockClient.getStakeInfo).toHaveBeenCalledWith("0xVesting", "0xValidator"); + expect(mockClient.vestingDelegatorExit).toHaveBeenCalledWith({ + vesting: "0xVesting", + validator: "0xValidator", + shares: 50n, + }); + }); + + test("claim calls vestingDelegatorClaim", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "claim", + "0xValidator", + ]); + + expect(mockClient.vestingDelegatorClaim).toHaveBeenCalledWith({ + vesting: "0xVesting", + validator: "0xValidator", + }); + }); + + test("withdraw calls vestingWithdraw", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "withdraw", + "--amount", + "10gen", + ]); + + expect(mockClient.vestingWithdraw).toHaveBeenCalledWith({ + vesting: "0xVesting", + amount: expect.any(BigInt), + }); + }); + + test("validator create calls vestingValidatorJoin", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "create", + "0xOperator", + "--amount", + "42gen", + ]); + + expect(mockClient.vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xVesting", + operator: "0xOperator", + amount: expect.any(BigInt), + }); + }); + + test("validator join accepts operator option and explicit vesting address", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "join", + "--operator", + "0xOperator", + "--amount", + "42gen", + "--vesting", + "0xExplicitVesting", + ]); + + expect(mockClient.getBeneficiaryVestings).not.toHaveBeenCalled(); + expect(mockClient.vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xExplicitVesting", + operator: "0xOperator", + amount: expect.any(BigInt), + }); + }); + + test("validator deposit calls vestingValidatorDeposit", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "deposit", + "0xWallet", + "--amount", + "10gen", + ]); + + expect(mockClient.vestingValidatorDeposit).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + amount: expect.any(BigInt), + }); + }); + + test("validator exit calls vestingValidatorExit", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "exit", + "0xWallet", + "--shares", + "100", + ]); + + expect(mockClient.vestingValidatorExit).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + shares: 100n, + }); + }); + + test("validator claim calls vestingValidatorClaim", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "claim", + "0xWallet", + ]); + + expect(mockClient.vestingValidatorClaim).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + }); + }); + + test("validator operator-transfer initiate calls SDK action", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "operator-transfer", + "initiate", + "0xWallet", + "0xNewOperator", + ]); + + expect(mockClient.vestingValidatorInitiateOperatorTransfer).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + newOperator: "0xNewOperator", + }); + }); + + test("validator operator-transfer complete calls SDK action", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "operator-transfer", + "complete", + "0xWallet", + ]); + + expect(mockClient.vestingValidatorCompleteOperatorTransfer).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + }); + }); + + test("validator operator-transfer cancel calls SDK action", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "operator-transfer", + "cancel", + "0xWallet", + ]); + + expect(mockClient.vestingValidatorCancelOperatorTransfer).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + }); + }); + + test("validator set-identity calls vestingValidatorSetIdentity with empty-string defaults", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "set-identity", + "0xWallet", + "--moniker", + "My Validator", + "--website", + "https://example.com", + "--twitter", + "myhandle", + ]); + + expect(mockClient.vestingValidatorSetIdentity).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + moniker: "My Validator", + logoUri: "", + website: "https://example.com", + description: "", + email: "", + twitter: "myhandle", + telegram: "", + github: "", + extraCid: expect.any(String), + }); + }); + + test("validator list fetches wallets and deposited amounts", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "list", + "--vesting", + "0xVesting", + ]); + + expect(mockClient.getBeneficiaryVestings).not.toHaveBeenCalled(); + expect(mockClient.getValidatorWallets).toHaveBeenCalledWith("0xVesting"); + expect(mockClient.validatorDeposited).toHaveBeenCalledWith("0xVesting", "0xWallet"); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + test("validator status resolves vesting from beneficiary", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "status", + "--beneficiary", + "0xBeneficiary", + ]); + + expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xBeneficiary", undefined); + expect(mockClient.getValidatorWallets).toHaveBeenCalledWith("0xVesting"); + }); +}); diff --git a/tests/commands/write.test.ts b/tests/commands/write.test.ts index 5af4a623..7e562312 100644 --- a/tests/commands/write.test.ts +++ b/tests/commands/write.test.ts @@ -80,6 +80,31 @@ describe("write command", () => { }); }); + test("WriteAction.write receives fee profile options", async () => { + program.parse([ + "node", + "test", + "write", + "0xMockedContract", + "updateCounter", + "--fee-profile", + "./artifacts/fee-profile.json", + "--fee-preset", + "standard", + "--appeal-rounds", + "2", + ]); + + expect(WriteAction.prototype.write).toHaveBeenCalledWith({ + contractAddress: "0xMockedContract", + method: "updateCounter", + args: [], + feeProfile: "./artifacts/fee-profile.json", + feePreset: "standard", + appealRounds: "2", + }); + }); + test("WriteAction is instantiated when the write command is executed", async () => { program.parse(["node", "test", "write", "0xMockedContract", "anotherMethod"]); expect(WriteAction).toHaveBeenCalledTimes(1); diff --git a/tests/index.test.ts b/tests/index.test.ts index 16150c17..a5d99f68 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -49,6 +49,10 @@ vi.mock("../src/commands/staking", () => ({ initializeStakingCommands: vi.fn(), })); +vi.mock("../src/commands/vesting", () => ({ + initializeVestingCommands: vi.fn(), +})); + describe("CLI", () => { it("should initialize CLI", () => { expect(initializeCLI).not.toThrow(); diff --git a/tests/libs/system.test.ts b/tests/libs/system.test.ts index 5bcd80a4..be6c12c4 100644 --- a/tests/libs/system.test.ts +++ b/tests/libs/system.test.ts @@ -70,13 +70,15 @@ describe("System Functions - Error Paths", () => { await expect(getVersion(toolName)).rejects.toThrow(`Error getting ${toolName} version.`); }); - test("getVersion returns '' if stdout is empty", async () => { + test("getVersion throws when stdout does not match version pattern", async () => { vi.mocked(util.promisify).mockReturnValueOnce(() => Promise.resolve({ stdout: "", stderr: "" })); - const result = await getVersion('git'); - expect(result).toBe(""); + const toolName = "git"; + await expect(getVersion(toolName)).rejects.toThrow( + `Could not parse ${toolName} version from output` + ); }); test("getVersion throw error if stdout undefined", async () => { @@ -87,6 +89,17 @@ describe("System Functions - Error Paths", () => { await expect(getVersion(toolName)).rejects.toThrow(`Error getting ${toolName} version.`); }); + test("getVersion throws when stdout has non-matching version format (e.g. major-only)", async () => { + vi.mocked(util.promisify).mockReturnValueOnce(() => Promise.resolve({ + stdout: "Docker version 25", + stderr: "" + })); + const toolName = "docker"; + await expect(getVersion(toolName)).rejects.toThrow( + `Could not parse ${toolName} version from output` + ); + }); + test("checkCommand returns false if the command does not exist", async () => { vi.mocked(util.promisify).mockReturnValueOnce(() => Promise.reject({ stdout: "", @@ -96,6 +109,26 @@ describe("System Functions - Error Paths", () => { await expect(checkCommand(`${toolName} --version`, toolName)).rejects.toThrow(new MissingRequirementError(toolName)); }); + test("checkCommand throws MissingRequirementError when binary is not installed (ENOENT)", async () => { + vi.mocked(util.promisify).mockReturnValueOnce(() => Promise.reject({ + code: 'ENOENT', + stderr: '', + message: 'spawn ENOENT' + })); + const toolName = 'docker'; + await expect(checkCommand(`${toolName} --version`, toolName)).rejects.toThrow(new MissingRequirementError(toolName)); + }); + + test("checkCommand throws MissingRequirementError when command exits without stderr", async () => { + vi.mocked(util.promisify).mockReturnValueOnce(() => Promise.reject({ + code: 127, + stderr: '', + message: 'command failed' + })); + const toolName = 'docker'; + await expect(checkCommand(`${toolName} --version`, toolName)).rejects.toThrow(new MissingRequirementError(toolName)); + }); + test("executeCommand throws an error if the command fails", async () => { vi.mocked(util.promisify).mockReturnValueOnce(() => Promise.reject(new Error("Execution failed"))); await expect(executeCommand({ diff --git a/tests/services/simulator.test.ts b/tests/services/simulator.test.ts index c6e48178..70e184a3 100644 --- a/tests/services/simulator.test.ts +++ b/tests/services/simulator.test.ts @@ -317,6 +317,21 @@ describe("SimulatorService - Basic Tests", () => { expect(heuristaiProvider).toBeDefined(); }); + test("should expose the Gemini provider with backend id 'google' (regression #271)", () => { + const allProviders = simulatorService.getAiProvidersOptions(false); + + // The emitted value is forwarded verbatim to sim_createRandomValidators. + // It must match the backend's stored provider id ("google"); using + // "geminiai" makes init fail with: + // "Requested providers '{'geminiai'}' do not match any stored providers". + const geminiProvider = allProviders.find(p => p.name === "Gemini"); + expect(geminiProvider).toBeDefined(); + expect(geminiProvider!.value).toBe("google"); + + // The legacy mismatched id must no longer be emitted. + expect(allProviders.find(p => p.value === "geminiai")).toBeUndefined(); + }); + test("clean simulator should success", async () => { vi.mocked(rpcClient.request).mockResolvedValueOnce("Success"); await expect(simulatorService.cleanDatabase).not.toThrow(); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 56808871..7c34ed33 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -1,16 +1,19 @@ import {describe, it, expect, beforeAll} from "vitest"; -import {execSync} from "child_process"; +import {execFile} from "child_process"; +import {promisify} from "util"; import path from "path"; import {createClient, parseStakingAmount, formatStakingAmount} from "genlayer-js"; import {testnetAsimov, testnetBradbury} from "genlayer-js/chains"; import type {Address, GenLayerChain} from "genlayer-js/types"; const CLI = path.resolve(__dirname, "../dist/index.js"); +const execFileAsync = promisify(execFile); // Testnet validator-list fetches ALL validators + per-validator detail in -// batches; on bradbury/asimov that routinely passes 30s. 90s gives headroom -// without hiding real hangs. +// batches; on bradbury/asimov that routinely passes 30s. Keep RPC calls capped +// at 90s, but give the full CLI smoke path extra room for live testnet slowness. const TIMEOUT = 90_000; +const CLI_TIMEOUT = Number(process.env.CLI_SMOKE_TIMEOUT_MS ?? 180_000); const testnets: {name: string; chain: GenLayerChain}[] = [ {name: "Asimov", chain: testnetAsimov}, @@ -127,14 +130,16 @@ describe(`Testnet ${name} - CLI Staking Smoke Tests`, () => { } }, TIMEOUT); - it("CLI: genlayer staking validators lists validators", () => { - const output = execSync( - `node ${CLI} staking validators --network ${name === "Asimov" ? "testnet-asimov" : "testnet-bradbury"}`, - {encoding: "utf-8", timeout: TIMEOUT}, + it("CLI: genlayer staking validators lists validators", async () => { + const {stdout, stderr} = await execFileAsync( + "node", + [CLI, "staking", "validators", "--network", name === "Asimov" ? "testnet-asimov" : "testnet-bradbury"], + {encoding: "utf-8", timeout: CLI_TIMEOUT}, ); + const output = `${stdout}${stderr}`; expect(output).toContain("active"); expect(output).toMatch(/Total: \d+ validators/); - }, TIMEOUT); + }, CLI_TIMEOUT + 10_000); it("parseStakingAmount and formatStakingAmount round-trip", () => { const parsed = parseStakingAmount("1.5gen"); From 29f391a7c92d7344cafb6625c0485b0c1ffd8639 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:59:05 +0000 Subject: [PATCH 2/2] chore(deps): update codecov/codecov-action action to v7 --- .github/workflows/validate-code.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-code.yml b/.github/workflows/validate-code.yml index 0b4707de..b01abfea 100644 --- a/.github/workflows/validate-code.yml +++ b/.github/workflows/validate-code.yml @@ -45,7 +45,7 @@ jobs: - name: Upload coverage report if: success() && matrix.os == 'ubuntu-latest' - uses: codecov/codecov-action@v5.4.3 + uses: codecov/codecov-action@v7.0.0 with: verbose: true token: ${{ secrets.CODECOV_TOKEN }}