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/.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 }} 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");